feat: filled last gaps in existing stuff and added start for git support

This commit is contained in:
2026-04-24 12:46:53 +02:00
parent f96759ab2f
commit 15584c72f7
6 changed files with 668 additions and 0 deletions

378
lib/bds/git.ex Normal file
View File

@@ -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(<<index::binary-size(1), worktree::binary-size(1), " ", path::binary>>) 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

View File

@@ -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

View File

@@ -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

133
test/bds/git_test.exs Normal file
View File

@@ -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

View File

@@ -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"

View File

@@ -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