feat: filled last gaps in existing stuff and added start for git support
This commit is contained in:
378
lib/bds/git.ex
Normal file
378
lib/bds/git.ex
Normal 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
|
||||||
@@ -18,6 +18,19 @@ defmodule BDS.MCP.AgentConfig do
|
|||||||
{:ok, %{config_path: config_path, server_name: @server_name}}
|
{:ok, %{config_path: config_path, server_name: @server_name}}
|
||||||
end
|
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(: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"])
|
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}))
|
Map.put(config, "mcpServers", Map.put(servers, @server_name, %{"command" => command, "args" => args}))
|
||||||
end
|
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
|
defp current_platform do
|
||||||
case :os.type() do
|
case :os.type() do
|
||||||
{:win32, _type} -> :windows
|
{:win32, _type} -> :windows
|
||||||
|
|||||||
@@ -114,6 +114,38 @@ defmodule BDS.Projects do
|
|||||||
end
|
end
|
||||||
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
|
defp unique_slug(base_slug) do
|
||||||
normalized = if base_slug in [nil, ""], do: "project", else: base_slug
|
normalized = if base_slug in [nil, ""], do: "project", else: base_slug
|
||||||
|
|
||||||
|
|||||||
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" => []}
|
assert written["mcpServers"]["bDS"] == %{"command" => Path.join(install_root, "mcp/bin/bds-mcp"), "args" => []}
|
||||||
end
|
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
|
test "packaged executable path resolves inside the distributable payload" do
|
||||||
assert AgentConfig.packaged_executable_path("/Applications/bDS2.app/Contents/Resources", :macos) ==
|
assert AgentConfig.packaged_executable_path("/Applications/bDS2.app/Contents/Resources", :macos) ==
|
||||||
"/Applications/bDS2.app/Contents/Resources/mcp/bin/bds-mcp"
|
"/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 same_project.id == default_project.id
|
||||||
assert Repo.aggregate(Project, :count, :id) == 1
|
assert Repo.aggregate(Project, :count, :id) == 1
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user