feat: filled last gaps in existing stuff and added start for git support
This commit is contained in:
133
test/bds/git_test.exs
Normal file
133
test/bds/git_test.exs
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user