From 15584c72f709d6cdffee2dd787dab3ff4d0cff8c Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 24 Apr 2026 12:46:53 +0200 Subject: [PATCH] feat: filled last gaps in existing stuff and added start for git support --- lib/bds/git.ex | 378 +++++++++++++++++++++++++++++ lib/bds/mcp/agent_config.ex | 31 +++ lib/bds/projects.ex | 32 +++ test/bds/git_test.exs | 133 ++++++++++ test/bds/mcp_agent_config_test.exs | 46 ++++ test/bds/projects_test.exs | 48 ++++ 6 files changed, 668 insertions(+) create mode 100644 lib/bds/git.ex create mode 100644 test/bds/git_test.exs diff --git a/lib/bds/git.ex b/lib/bds/git.ex new file mode 100644 index 0000000..4d1d740 --- /dev/null +++ b/lib/bds/git.ex @@ -0,0 +1,378 @@ +defmodule BDS.Git do + @moduledoc false + + alias BDS.Projects + + @lfs_patterns [ + "*.jpg", + "*.jpeg", + "*.png", + "*.gif", + "*.webp", + "*.svg", + "*.tif", + "*.tiff", + "*.bmp", + "*.heic", + "*.heif" + ] + + @gitignore_lines [ + "/html/", + "/thumbnails/", + "/pagefind/", + "/.DS_Store", + "/node_modules/", + "/deps/", + "/_build/" + ] + + def initialize_repo(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + :ok <- write_gitignore(project_dir), + {:ok, _init} <- run_git(project_dir, ["init", "-b", "master"], opts), + {:ok, _track} <- configure_lfs(project_dir, opts), + {:ok, branch} <- current_branch(project_dir, opts), + {:ok, remote_url} <- remote_url(project_dir, opts), + {:ok, has_lfs} <- has_lfs?(project_dir, opts) do + {:ok, + %{ + is_initialized: true, + remote_url: remote_url, + provider: provider_info(remote_url), + current_branch: branch, + has_lfs: has_lfs + }} + end + end + + def repository(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, branch} <- current_branch(project_dir, opts), + {:ok, remote_url} <- remote_url(project_dir, opts) do + {:ok, + %{ + is_initialized: true, + remote_url: remote_url, + provider: provider_info(remote_url), + current_branch: branch, + has_lfs: has_lfs_configured?(project_dir) + }} + else + {:error, :not_found} = error -> error + {:error, _reason} -> + {:ok, + %{ + is_initialized: false, + remote_url: nil, + provider: nil, + current_branch: nil, + has_lfs: false + }} + end + end + + def status(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, output} <- run_git(project_dir, ["status", "--porcelain=v1", "--untracked-files=all"], opts) do + {:ok, %{files: parse_status(output)}} + end + end + + def diff(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, staged_diff} <- run_git(project_dir, ["diff", "--cached", "--no-ext-diff"], opts), + {:ok, unstaged_diff} <- run_git(project_dir, ["diff", "--no-ext-diff"], opts) do + {:ok, %{staged_diff: staged_diff, unstaged_diff: unstaged_diff}} + end + end + + def history(project_id, branch, opts \\ []) + when is_binary(project_id) and is_binary(branch) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, local_log} <- run_git(project_dir, ["log", "--format=%H%x09%s", branch], opts), + {:ok, remote_log} <- run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do + local_commits = parse_local_history(local_log) + remote_hashes = MapSet.new(parse_remote_history(remote_log)) + local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash)) + + remote_only = + remote_hashes + |> MapSet.difference(local_hashes) + |> MapSet.to_list() + |> Enum.map(fn hash -> %{hash: hash, subject: nil, sync_status: %{kind: :remote_only}} end) + + commits = + Enum.map(local_commits, fn commit -> + kind = if MapSet.member?(remote_hashes, commit.hash), do: :both, else: :local_only + Map.put(commit, :sync_status, %{kind: kind}) + end) ++ remote_only + + {:ok, %{commits: commits}} + end + end + + def fetch(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id) do + case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do + {:ok, output} -> {:ok, %{updated: true, output: output}} + {:error, {:git_failed, message}} -> structured_git_error(project_dir, :fetch, message, opts) + other -> other + end + end + end + + def pull(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, output} <- run_git(project_dir, ["pull", "--ff-only"], opts) do + {:ok, %{updated: true, output: output}} + end + end + + def push(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, output} <- run_git(project_dir, ["push"], opts) do + {:ok, %{updated: true, output: output}} + end + end + + def commit_all(project_id, message, opts \\ []) + when is_binary(project_id) and is_binary(message) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, _stage} <- run_git(project_dir, ["add", "-A"], opts), + {:ok, output} <- run_git(project_dir, ["commit", "-m", message], opts) do + {:ok, %{message: message, output: output}} + end + end + + def reconcile(project_id, old_commit, new_commit, opts \\ []) + when is_binary(project_id) and is_binary(old_commit) and is_binary(new_commit) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, output} <- run_git(project_dir, ["diff", "--name-status", old_commit, new_commit], opts) do + {:ok, %{changed: parse_changed_files(output)}} + end + end + + def prune_lfs_cache(project_id, retain_recent, opts \\ []) + when is_binary(project_id) and is_integer(retain_recent) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, output} <- run_git(project_dir, ["lfs", "prune", "--recent"], opts) do + {:ok, %{retained_recent: retain_recent, output: output}} + end + end + + defp project_dir(project_id) do + case Projects.get_project(project_id) do + nil -> {:error, :not_found} + project -> {:ok, Projects.project_data_dir(project)} + end + end + + defp configure_lfs(project_dir, opts) do + Enum.reduce_while(@lfs_patterns, {:ok, []}, fn pattern, {:ok, acc} -> + case run_git(project_dir, ["lfs", "track", pattern], opts) do + {:ok, output} -> {:cont, {:ok, [output | acc]}} + error -> {:halt, error} + end + end) + |> case do + {:ok, _outputs} -> + write_gitattributes(project_dir) + {:ok, :configured} + + error -> + error + end + end + + defp write_gitignore(project_dir) do + File.write!(Path.join(project_dir, ".gitignore"), Enum.join(@gitignore_lines, "\n") <> "\n") + :ok + end + + defp write_gitattributes(project_dir) do + contents = + @lfs_patterns + |> Enum.map(&"#{&1} filter=lfs diff=lfs merge=lfs -text") + |> Enum.join("\n") + + File.write!(Path.join(project_dir, ".gitattributes"), contents <> "\n") + :ok + end + + defp current_branch(project_dir, opts) do + run_git(project_dir, ["rev-parse", "--abbrev-ref", "HEAD"], opts) + |> normalize_optional_string() + end + + defp remote_url(project_dir, opts) do + case run_git(project_dir, ["remote", "get-url", "origin"], opts) do + {:ok, output} -> {:ok, blank_to_nil(output)} + {:error, {:git_failed, _message}} -> {:ok, nil} + other -> other + end + end + + defp has_lfs?(project_dir, opts) do + case run_git(project_dir, ["lfs", "ls-files"], opts) do + {:ok, _output} -> {:ok, true} + {:error, {:git_failed, _message}} -> {:ok, false} + other -> other + end + end + + defp has_lfs_configured?(project_dir) do + File.exists?(Path.join(project_dir, ".gitattributes")) + end + + defp provider_info(nil), do: nil + + defp provider_info(remote_url) do + kind = + cond do + String.contains?(remote_url, "github.com") -> :github + String.contains?(remote_url, "gitlab") -> :gitlab + true -> :gitea_forgejo + end + + %{kind: kind} + end + + defp run_git(project_dir, args, opts) do + runner = Keyword.get(opts, :runner, &system_runner/3) + + case runner.("git", args, command_opts(project_dir)) do + {output, 0} -> {:ok, String.trim_trailing(output)} + {output, _status} -> {:error, {:git_failed, String.trim(output)}} + end + end + + defp system_runner(command, args, opts) do + env = opts |> Keyword.get(:env, %{}) |> Enum.to_list() + cwd = Keyword.fetch!(opts, :cd) + System.cmd(command, args, cd: cwd, env: env, stderr_to_stdout: true) + end + + defp command_opts(project_dir) do + ssh_command = "ssh -oBatchMode=yes" + + [ + cd: project_dir, + env: %{ + "GIT_TERMINAL_PROMPT" => "0", + "GCM_INTERACTIVE" => "never", + "GIT_SSH_COMMAND" => ssh_command + } + ] + end + + defp parse_status(output) do + output + |> String.split("\n", trim: true) + |> Enum.map(&parse_status_line/1) + end + + defp parse_status_line("?? " <> path), do: %{path: path, status: :untracked} + + defp parse_status_line("R " <> rename) do + [old_path, new_path] = String.split(rename, " -> ", parts: 2) + %{path: new_path, old_path: old_path, status: :renamed} + end + + defp parse_status_line(<>) do + %{path: path, status: status_from_porcelain(index, worktree)} + end + + defp status_from_porcelain("A", _worktree), do: :added + defp status_from_porcelain("D", _worktree), do: :deleted + defp status_from_porcelain("M", _worktree), do: :modified + defp status_from_porcelain(_, "D"), do: :deleted + defp status_from_porcelain(_, "M"), do: :modified + defp status_from_porcelain(_, _), do: :modified + + defp parse_local_history(output) do + output + |> String.split("\n", trim: true) + |> Enum.map(fn line -> + case String.split(line, "\t", parts: 2) do + [hash, subject] -> %{hash: hash, subject: subject} + [hash] -> %{hash: hash, subject: nil} + end + end) + end + + defp parse_remote_history(output) do + String.split(output, "\n", trim: true) + end + + defp parse_changed_files(output) do + base = fn -> %{added: [], modified: [], deleted: [], renamed: []} end + + Enum.reduce(String.split(output, "\n", trim: true), %{posts: base.(), scripts: base.(), templates: base.()}, fn line, acc -> + case String.split(line, "\t", trim: true) do + ["A", path] -> update_changed(acc, path, :added, path) + ["M", path] -> update_changed(acc, path, :modified, path) + ["D", path] -> update_changed(acc, path, :deleted, path) + ["R" <> _score, old_path, new_path] -> update_changed(acc, new_path, :renamed, %{old: old_path, new: new_path}) + _other -> acc + end + end) + end + + defp update_changed(acc, path, key, value) do + case category_for_path(path) do + nil -> acc + category -> Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end)) + end + end + + defp category_for_path("posts/" <> _rest), do: :posts + defp category_for_path("scripts/" <> _rest), do: :scripts + defp category_for_path("templates/" <> _rest), do: :templates + defp category_for_path(_path), do: nil + + defp structured_git_error(project_dir, _operation, message, opts) do + provider = + case remote_url(project_dir, opts) do + {:ok, remote} -> provider_info(remote) + _other -> nil + end + + if auth_error?(message) do + {:error, + %{ + kind: :auth, + provider: provider && provider.kind, + platform: current_platform(), + guidance: auth_guidance(provider && provider.kind, current_platform()) + }} + else + {:error, %{kind: :git_failed, message: message}} + end + end + + defp auth_error?(message) do + downcased = String.downcase(message) + String.contains?(downcased, "auth") or String.contains?(downcased, "permission denied") + end + + defp auth_guidance(provider, platform) do + provider_label = provider || :git + "Authentication failed for #{provider_label} on #{platform}. Configure SSH keys or a credential helper that works non-interactively." + end + + defp current_platform do + case :os.type() do + {:win32, _type} -> :windows + {:unix, :darwin} -> :macos + {:unix, _type} -> :linux + end + end + + defp normalize_optional_string({:ok, value}), do: {:ok, blank_to_nil(value)} + defp normalize_optional_string(other), do: other + + defp blank_to_nil(nil), do: nil + defp blank_to_nil(value) when value in ["", "\n"], do: nil + defp blank_to_nil(value), do: String.trim(value) +end diff --git a/lib/bds/mcp/agent_config.ex b/lib/bds/mcp/agent_config.ex index 4d1ed7c..26a4038 100644 --- a/lib/bds/mcp/agent_config.ex +++ b/lib/bds/mcp/agent_config.ex @@ -18,6 +18,19 @@ defmodule BDS.MCP.AgentConfig do {:ok, %{config_path: config_path, server_name: @server_name}} end + def remove_from_config(agent, opts \\ []) when is_atom(agent) and is_list(opts) do + home_dir = Keyword.get(opts, :home_dir, System.user_home!()) + config_path = config_path(agent, home_dir) + + File.mkdir_p!(Path.dirname(config_path)) + + config = read_config(config_path) + updated = remove_server_entry(agent, config) + File.write!(config_path, Jason.encode!(updated, pretty: true)) + + {:ok, %{config_path: config_path, server_name: @server_name}} + end + def config_path(:claude_code, home_dir), do: Path.join(home_dir, ".claude.json") def config_path(:github_copilot, home_dir), do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"]) @@ -60,6 +73,24 @@ defmodule BDS.MCP.AgentConfig do Map.put(config, "mcpServers", Map.put(servers, @server_name, %{"command" => command, "args" => args})) end + defp remove_server_entry(:github_copilot, config) do + servers = + config + |> Map.get("servers", %{}) + |> Map.delete(@server_name) + + Map.put(config, "servers", servers) + end + + defp remove_server_entry(:claude_code, config) do + servers = + config + |> Map.get("mcpServers", %{}) + |> Map.delete(@server_name) + + Map.put(config, "mcpServers", servers) + end + defp current_platform do case :os.type() do {:win32, _type} -> :windows diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index 0d62812..517e5af 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -114,6 +114,38 @@ defmodule BDS.Projects do end end + def delete_project(project_id) when is_binary(project_id) do + case Repo.get(Project, project_id) do + nil -> + {:error, :not_found} + + %Project{id: @default_project_id} -> + {:error, :cannot_delete_default_project} + + %Project{is_active: true} -> + {:error, :cannot_delete_active_project} + + %Project{} = project -> + internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil + + Repo.transaction(fn -> + Repo.delete!(project) + project + end) + |> case do + {:ok, deleted_project} -> + if is_binary(internal_dir) do + _ = File.rm_rf(internal_dir) + end + + {:ok, deleted_project} + + {:error, reason} -> + {:error, reason} + end + end + end + defp unique_slug(base_slug) do normalized = if base_slug in [nil, ""], do: "project", else: base_slug diff --git a/test/bds/git_test.exs b/test/bds/git_test.exs new file mode 100644 index 0000000..74743c5 --- /dev/null +++ b/test/bds/git_test.exs @@ -0,0 +1,133 @@ +defmodule BDS.GitTest do + use ExUnit.Case, async: false + + alias BDS.Git + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_root = Path.join(System.tmp_dir!(), "bds-git-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_root) + + on_exit(fn -> File.rm_rf(temp_root) end) + + project_dir = Path.join(temp_root, "project") + File.mkdir_p!(project_dir) + + {:ok, project} = BDS.Projects.create_project(%{name: "Git Project", data_path: project_dir}) + + %{project: project, project_dir: project_dir, temp_root: temp_root} + end + + test "initialize_repo writes ignore files, configures LFS tracking, and returns repo info", %{ + project: project, + project_dir: project_dir + } do + runner = fake_runner(fn + "git", ["init", "-b", "master"], _opts -> {"", 0} + "git", ["lfs", "track" | _rest], _opts -> {"Tracking *.png\n", 0} + "git", ["rev-parse", "--abbrev-ref", "HEAD"], _opts -> {"master\n", 0} + "git", ["remote", "get-url", "origin"], _opts -> {"", 2} + "git", ["lfs", "ls-files"], _opts -> {"", 0} + end) + + assert {:ok, repo} = Git.initialize_repo(project.id, runner: runner) + + assert repo.is_initialized == true + assert repo.current_branch == "master" + assert repo.has_lfs == true + assert File.read!(Path.join(project_dir, ".gitignore")) =~ "/html/" + assert File.read!(Path.join(project_dir, ".gitattributes")) =~ "*.png filter=lfs" + end + + test "status, diff, history, and provider detection are parsed from git output", %{project: project} do + runner = fake_runner(fn + "git", ["status", "--porcelain=v1", "--untracked-files=all"], _opts -> + {"A posts/new.md\n M meta/project.json\nR old.txt -> new.txt\n?? note.txt\n", 0} + + "git", ["diff", "--cached", "--no-ext-diff"], _opts -> {"staged diff", 0} + "git", ["diff", "--no-ext-diff"], _opts -> {"unstaged diff", 0} + "git", ["remote", "get-url", "origin"], _opts -> {"git@github.com:owner/repo.git\n", 0} + "git", ["rev-parse", "--abbrev-ref", "HEAD"], _opts -> {"main\n", 0} + "git", ["log", "--format=%H%x09%s", "main"], _opts -> {"a1\tLocal commit\nb2\tShared commit\n", 0} + "git", ["log", "--format=%H", "origin/main"], _opts -> {"b2\nc3\n", 0} + end) + + assert {:ok, status} = Git.status(project.id, runner: runner) + assert Enum.any?(status.files, &(&1.path == "posts/new.md" and &1.status == :added)) + assert Enum.any?(status.files, &(&1.path == "new.txt" and &1.status == :renamed and &1.old_path == "old.txt")) + assert Enum.any?(status.files, &(&1.path == "note.txt" and &1.status == :untracked)) + + assert {:ok, diff} = Git.diff(project.id, runner: runner) + assert diff.staged_diff == "staged diff" + assert diff.unstaged_diff == "unstaged diff" + + assert {:ok, history} = Git.history(project.id, "main", runner: runner) + assert Enum.find(history.commits, &(&1.hash == "a1")).sync_status.kind == :local_only + assert Enum.find(history.commits, &(&1.hash == "b2")).sync_status.kind == :both + assert Enum.find(history.commits, &(&1.hash == "c3")).sync_status.kind == :remote_only + + assert {:ok, repo} = Git.repository(project.id, runner: runner) + assert repo.provider.kind == :github + assert repo.current_branch == "main" + end + + test "fetch, pull, push, commit_all, reconcile, and prune_lfs_cache run non-interactively", %{ + project: project + } do + parent = self() + + runner = fn command, args, opts -> + send(parent, {:git_command, command, args, opts}) + + case {command, args} do + {"git", ["fetch", "--all", "--prune"]} -> {"", 0} + {"git", ["pull", "--ff-only"]} -> {"", 0} + {"git", ["push"]} -> {"", 0} + {"git", ["add", "-A"]} -> {"", 0} + {"git", ["commit", "-m", "save everything"]} -> {"", 0} + {"git", ["diff", "--name-status", "old", "new"]} -> + {"A\tposts/2026/04/new-post.md\nM\tscripts/tool.lua\nD\ttemplates/old.liquid\n", 0} + + {"git", ["lfs", "prune", "--recent"]} -> {"", 0} + _other -> {"", 0} + end + end + + assert {:ok, _fetch} = Git.fetch(project.id, runner: runner) + assert {:ok, _pull} = Git.pull(project.id, runner: runner) + assert {:ok, _push} = Git.push(project.id, runner: runner) + assert {:ok, commit} = Git.commit_all(project.id, "save everything", runner: runner) + assert commit.message == "save everything" + + assert {:ok, reconcile} = Git.reconcile(project.id, "old", "new", runner: runner) + assert reconcile.changed.posts.added == ["posts/2026/04/new-post.md"] + assert reconcile.changed.scripts.modified == ["scripts/tool.lua"] + assert reconcile.changed.templates.deleted == ["templates/old.liquid"] + + assert {:ok, prune} = Git.prune_lfs_cache(project.id, 5, runner: runner) + assert prune.retained_recent == 5 + + assert_received {:git_command, "git", ["fetch", "--all", "--prune"], fetch_opts} + assert fetch_opts[:env]["GIT_TERMINAL_PROMPT"] == "0" + assert fetch_opts[:env]["GCM_INTERACTIVE"] == "never" + assert fetch_opts[:env]["GIT_SSH_COMMAND"] =~ "BatchMode=yes" + end + + test "fetch returns structured auth errors with provider guidance", %{project: project} do + runner = fake_runner(fn + "git", ["remote", "get-url", "origin"], _opts -> {"git@gitlab.com:owner/repo.git\n", 0} + "git", ["fetch", "--all", "--prune"], _opts -> {"fatal: Authentication failed for 'origin'", 128} + end) + + assert {:error, error} = Git.fetch(project.id, runner: runner) + assert error.kind == :auth + assert error.provider == :gitlab + assert error.platform == :macos + assert is_binary(error.guidance) + end + + defp fake_runner(handler) do + fn command, args, opts -> handler.(command, args, opts) end + end +end diff --git a/test/bds/mcp_agent_config_test.exs b/test/bds/mcp_agent_config_test.exs index ad3364a..655aa46 100644 --- a/test/bds/mcp_agent_config_test.exs +++ b/test/bds/mcp_agent_config_test.exs @@ -53,6 +53,52 @@ defmodule BDS.MCPAgentConfigTest do assert written["mcpServers"]["bDS"] == %{"command" => Path.join(install_root, "mcp/bin/bds-mcp"), "args" => []} end + test "github copilot uninstall removes only the bDS server entry", %{home_dir: home_dir} do + config_path = Path.join(home_dir, "Library/Application Support/Code/User/mcp.json") + + File.mkdir_p!(Path.dirname(config_path)) + + File.write!( + config_path, + Jason.encode!(%{ + "servers" => %{ + "bDS" => %{"type" => "stdio", "command" => "/tmp/bds-mcp", "args" => []}, + "other" => %{"type" => "stdio", "command" => "python", "args" => ["server.py"]} + }, + "theme" => "dark" + }) + ) + + assert {:ok, result} = AgentConfig.remove_from_config(:github_copilot, home_dir: home_dir) + + written = Jason.decode!(File.read!(result.config_path)) + assert written["theme"] == "dark" + refute Map.has_key?(written["servers"], "bDS") + assert written["servers"]["other"] == %{"type" => "stdio", "command" => "python", "args" => ["server.py"]} + end + + test "claude code uninstall removes only the bDS server entry", %{home_dir: home_dir} do + config_path = Path.join(home_dir, ".claude.json") + + File.write!( + config_path, + Jason.encode!(%{ + "mcpServers" => %{ + "bDS" => %{"command" => "/tmp/bds-mcp", "args" => []}, + "other" => %{"command" => "python", "args" => ["server.py"]} + }, + "theme" => "dark" + }) + ) + + assert {:ok, result} = AgentConfig.remove_from_config(:claude_code, home_dir: home_dir) + + written = Jason.decode!(File.read!(result.config_path)) + assert written["theme"] == "dark" + refute Map.has_key?(written["mcpServers"], "bDS") + assert written["mcpServers"]["other"] == %{"command" => "python", "args" => ["server.py"]} + end + test "packaged executable path resolves inside the distributable payload" do assert AgentConfig.packaged_executable_path("/Applications/bDS2.app/Contents/Resources", :macos) == "/Applications/bDS2.app/Contents/Resources/mcp/bin/bds-mcp" diff --git a/test/bds/projects_test.exs b/test/bds/projects_test.exs index c7db178..8e951b0 100644 --- a/test/bds/projects_test.exs +++ b/test/bds/projects_test.exs @@ -134,4 +134,52 @@ defmodule BDS.ProjectsTest do assert same_project.id == default_project.id assert Repo.aggregate(Project, :count, :id) == 1 end + + test "delete_project rejects the default and active projects", %{temp_root: temp_root} do + Repo.delete_all(Project) + + assert {:ok, default_project} = BDS.Projects.ensure_default_project() + assert {:error, :cannot_delete_default_project} = BDS.Projects.delete_project(default_project.id) + + temp_dir = Path.join(temp_root, "active-delete") + File.mkdir_p!(temp_dir) + + assert {:ok, project} = BDS.Projects.create_project(%{name: "Delete Me", data_path: temp_dir}) + assert {:ok, _active_project} = BDS.Projects.set_active_project(project.id) + + assert {:error, :cannot_delete_active_project} = BDS.Projects.delete_project(project.id) + project_id = project.id + assert %Project{id: ^project_id} = BDS.Projects.get_project(project.id) + end + + test "delete_project removes internal project data but preserves external data paths", %{temp_root: temp_root} do + assert {:ok, internal_project} = BDS.Projects.create_project(%{name: "Internal Project"}) + + internal_dir = BDS.Projects.project_data_dir(internal_project) + + on_exit(fn -> + _ = File.rm_rf(internal_dir) + end) + + assert File.exists?(Path.join(internal_dir, "templates/single-post.liquid")) + + assert {:ok, deleted_internal_project} = BDS.Projects.delete_project(internal_project.id) + assert deleted_internal_project.id == internal_project.id + assert BDS.Projects.get_project(internal_project.id) == nil + refute File.exists?(internal_dir) + + external_dir = Path.join(temp_root, "external-delete") + File.mkdir_p!(external_dir) + + assert {:ok, external_project} = + BDS.Projects.create_project(%{name: "External Project", data_path: external_dir}) + + marker_path = Path.join(external_dir, "keep.txt") + File.write!(marker_path, "preserve me") + + assert {:ok, deleted_external_project} = BDS.Projects.delete_project(external_project.id) + assert deleted_external_project.id == external_project.id + assert BDS.Projects.get_project(external_project.id) == nil + assert File.read!(marker_path) == "preserve me" + end end