Packages
fnord
0.9.34
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/git_cli/worktree.ex
defmodule GitCli.Worktree do
@moduledoc """
Shared worktree context for listing, creating, deleting, merging, and
recreating project worktrees.
"""
@type worktree_meta :: %{
optional(atom()) => any()
}
@type worktree_entry :: %{
path: String.t(),
branch: String.t() | nil,
base_branch: String.t() | nil,
merge_status: :ahead | :diverged | :unknown,
size: non_neg_integer(),
exists?: boolean()
}
@type recreation_result :: %{
root: String.t(),
path: String.t(),
branch: String.t(),
meta: worktree_meta()
}
@spec default_root(String.t()) :: String.t()
@doc """
Returns the default on-disk worktree root for a project under the user's home
directory.
"""
def default_root(project) when is_binary(project) do
Path.join([Settings.get_user_home(), ".fnord", "projects", project, "worktrees"])
end
@spec conversation_path(String.t(), String.t()) :: String.t()
@doc """
Returns the default path for a conversation worktree within a project.
"""
def conversation_path(project, conversation_id)
when is_binary(project) and is_binary(conversation_id) do
Path.join([default_root(project), conversation_id])
end
@spec list(String.t() | nil) :: {:ok, [worktree_entry()]} | {:error, atom()}
@doc """
Lists Git worktrees for a repository root and enriches each entry with merge
status, size, and existence information.
"""
def list(nil), do: {:error, :not_a_repo}
def list(root) when is_binary(root) do
with {:ok, worktrees} <- git_worktree_list(root) do
{:ok, Enum.map(worktrees, &enrich_worktree(root, &1))}
end
end
@spec list_raw(String.t() | nil) :: {:ok, [map()]} | {:error, atom()}
@doc """
Lists Git worktrees for a repository root without enrichment. Returns the
parsed records as maps containing at least :path and optionally :branch and
:base_branch. Callers that only need to filter/select should prefer this to
avoid per-worktree git calls and filesystem walks.
"""
def list_raw(nil), do: {:error, :not_a_repo}
def list_raw(root) when is_binary(root) do
git_worktree_list(root)
end
@spec enrich(String.t(), map()) :: map()
@doc """
Enriches a raw worktree record with merge status, size, and existence
information.
"""
def enrich(root, entry) when is_binary(root) and is_map(entry) do
enrich_worktree(root, entry)
end
@spec create(String.t(), String.t(), String.t() | nil) ::
{:ok, worktree_entry()} | {:error, atom()}
@doc """
Creates a local conversation worktree under the default project conversation
path.
"""
def create(project, conversation_id, branch \\ nil) do
branch = branch || "fnord-#{conversation_id}"
with {:ok, root} <- project_root(),
{:ok, base_branch} <- resolve_default_base_branch(root),
{:ok, path} <- ensure_conversation_path(project, conversation_id),
{:ok, _out} <- git_worktree_add_branch(root, path, branch, base_branch) do
{:ok, normalize_worktree_entry(path, branch, base_branch, root)}
end
end
@spec duplicate(String.t(), worktree_meta(), String.t()) ::
{:ok, worktree_entry()} | {:error, atom()}
@doc """
Creates an independent copy of an existing worktree for a forked
conversation. The new worktree:
* lives at the default conversation path for `new_conversation_id`
* is on a fresh branch named `fnord-<new_conversation_id>` that points at
the source branch's HEAD (so all source commits are inherited along with
the same merge-base relative to the original base branch)
* inherits the source's `base_branch` so future merges still target the
original base (typically `main`), not the source's fnord-* branch
* carries the source's uncommitted state (modified, deleted, and untracked
files) replicated via direct file ops, leaving the source worktree
untouched
On any failure the new worktree and branch are torn down before returning so
the caller can fall back cleanly.
"""
def duplicate(project, source_meta, new_conversation_id)
when is_binary(project) and is_map(source_meta) and is_binary(new_conversation_id) do
source_path = meta_path(source_meta)
source_branch = meta_branch(source_meta)
source_base_branch = meta_base_branch(source_meta)
cond do
not is_binary(source_path) or not File.dir?(source_path) ->
{:error, :source_missing}
not is_binary(source_branch) ->
{:error, :source_branch_missing}
true ->
new_branch = "fnord-#{new_conversation_id}"
with {:ok, root} <- project_root(),
{:ok, base_branch} <-
resolve_duplicate_base_branch(root, source_base_branch),
{:ok, new_path} <- ensure_conversation_path(project, new_conversation_id),
{:ok, _out} <-
git_worktree_add_branch(root, new_path, new_branch, source_branch),
:ok <- overlay_dirty_state(source_path, new_path) do
{:ok, normalize_worktree_entry(new_path, new_branch, base_branch, root)}
else
{:error, reason} ->
cleanup_failed_duplicate(new_conversation_id, project, new_branch)
{:error, reason}
end
end
end
# Prefer the source's recorded base branch so the duplicate keeps merging
# into the same target. Fall back to the repo default only when the source
# didn't carry one, which is the legacy-metadata case.
defp resolve_duplicate_base_branch(_root, base) when is_binary(base), do: {:ok, base}
defp resolve_duplicate_base_branch(root, _), do: resolve_default_base_branch(root)
# Replays the source worktree's uncommitted state into the freshly created
# destination worktree without touching the source. Three name lists drive
# three file ops, in order: modified tracked files are copied (covering both
# staged and unstaged edits, including binaries), deletions are propagated so
# the destination doesn't silently resurrect removed files, and untracked
# files (excluding .gitignore'd noise) are copied last.
defp overlay_dirty_state(source_path, dest_path) do
with {:ok, modified} <- list_modified_tracked(source_path),
{:ok, deleted} <- list_deleted_tracked(source_path),
{:ok, untracked} <- list_untracked(source_path),
:ok <- copy_files(source_path, dest_path, modified),
:ok <- delete_files(dest_path, deleted),
:ok <- copy_files(source_path, dest_path, untracked) do
:ok
end
end
defp list_modified_tracked(path) do
case git_cmd(path, ["diff", "--name-only", "--diff-filter=ACMRT", "HEAD"]) do
{:ok, out} -> {:ok, parse_name_list(out)}
{:error, reason} -> {:error, reason}
end
end
defp list_deleted_tracked(path) do
case git_cmd(path, ["ls-files", "--deleted"]) do
{:ok, out} -> {:ok, parse_name_list(out)}
{:error, reason} -> {:error, reason}
end
end
defp list_untracked(path) do
case git_cmd(path, ["ls-files", "--others", "--exclude-standard"]) do
{:ok, out} -> {:ok, parse_name_list(out)}
{:error, reason} -> {:error, reason}
end
end
defp parse_name_list(out) do
out
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp copy_files(_source_root, _dest_root, []), do: :ok
defp copy_files(source_root, dest_root, [rel | rest]) do
src = Path.join(source_root, rel)
dst = Path.join(dest_root, rel)
with :ok <- File.mkdir_p(Path.dirname(dst)),
{:ok, _} <- File.copy(src, dst) do
copy_files(source_root, dest_root, rest)
else
_ -> {:error, :copy_failed}
end
end
defp delete_files(_dest_root, []), do: :ok
defp delete_files(dest_root, [rel | rest]) do
dst = Path.join(dest_root, rel)
case File.rm(dst) do
:ok -> delete_files(dest_root, rest)
# Tolerate already-absent destinations: the source recorded a deletion
# against a file that the destination branch never had, so the desired
# end state (file gone) already holds.
{:error, :enoent} -> delete_files(dest_root, rest)
{:error, _} -> {:error, :delete_failed}
end
end
# Best-effort rollback after a failed duplicate. Ignores errors because the
# caller is already returning the original failure reason and partial state
# left behind here is preferable to masking the real cause.
defp cleanup_failed_duplicate(new_conversation_id, project, new_branch) do
new_path = conversation_path(project, new_conversation_id)
case project_root() do
{:ok, root} ->
if File.exists?(new_path) do
_ = git_cmd(root, ["worktree", "remove", "--force", new_path])
end
_ = git_cmd(root, ["branch", "-D", new_branch])
:ok
_ ->
:ok
end
end
@spec delete(String.t(), String.t()) :: {:ok, any()} | {:error, atom()}
@doc """
Removes a worktree at the given path from the repository.
"""
def delete(root, path) when is_binary(root) and is_binary(path) do
with {:ok, _out} <- git_worktree_remove(root, path) do
{:ok, :ok}
end
end
@spec merge(String.t(), String.t()) :: {:ok, any()} | {:error, atom()}
@doc """
Merges the checked-out worktree branch into the repository root current
branch. Rebases the branch onto the target first, then fast-forward merges
for a linear history. Falls back to a regular merge if the rebase fails.
"""
def merge(root, path) when is_binary(root) and is_binary(path) do
with {:ok, branch} <- git_worktree_branch(root, path) do
rebase_and_ff_merge(root, path, branch)
else
{:error, :worktree_not_found} -> {:error, :worktree_not_found}
{:error, :not_a_repo} -> {:error, :not_a_repo}
end
end
# Rebase the worktree branch onto the current root branch, then ff-merge.
# If the rebase conflicts, abort it and fall back to a regular merge.
defp rebase_and_ff_merge(root, path, branch) do
target = current_branch(root) || "HEAD"
case git_cmd(path, ["rebase", target]) do
{:ok, _} ->
case git_cmd(root, ["merge", "--ff-only", branch]) do
{:ok, _out} -> {:ok, :ok}
_ -> {:error, :merge_failed}
end
_ ->
git_cmd(path, ["rebase", "--abort"])
case git_cmd(root, ["merge", branch]) do
{:ok, _out} -> {:ok, :ok}
_ -> {:error, :merge_failed}
end
end
end
@spec recreate_conversation_worktree(String.t(), String.t(), worktree_meta()) ::
{:ok, worktree_meta()} | {:error, atom()}
@doc """
Recreates a missing conversation worktree at its default path from stored
metadata.
"""
def recreate_conversation_worktree(project, conversation_id, meta)
when is_binary(project) and is_binary(conversation_id) and is_map(meta) do
with {:ok, prepared} <- prepare_recreated_worktree(project, conversation_id, meta),
{:ok, _out} <- git_worktree_add(prepared.root, prepared.path, prepared.branch) do
{:ok, prepared.meta}
end
end
@spec has_uncommitted_changes?(String.t()) :: boolean()
@doc """
Returns true when the worktree at `path` has staged, unstaged, or untracked
changes that would be lost by a non-force removal.
"""
def has_uncommitted_changes?(path) when is_binary(path) do
case git_cmd(path, ["status", "--porcelain"]) do
{:ok, ""} -> false
{:ok, _output} -> true
_ -> false
end
end
@spec path_ignored?(String.t(), String.t()) :: boolean()
@doc """
Returns true when the file at `path` is ignored by git in the given `root`
directory (via .gitignore or other exclusion mechanisms). Uses
`git check-ignore -q` which returns exit 0 for ignored paths, 1 otherwise.
"""
def path_ignored?(root, path) when is_binary(root) and is_binary(path) do
case System.cmd("git", ["check-ignore", "-q", path], cd: root, stderr_to_stdout: true) do
{_, 0} -> true
_ -> false
end
end
@spec has_changes_to_merge?(String.t(), String.t(), String.t() | nil, String.t() | nil) ::
boolean()
@doc """
Returns true when the worktree at `path` either has uncommitted changes OR
has commits on `branch` that are not yet on `base_branch`. This is the
filesystem/git source of truth for "is there work in this worktree that
would be lost if we discarded it without merging?"
Used by Cmd.Ask to gate the end-of-session merge flow without relying on
agent-side tool tracking, which can miss edits made via cmd_tool, frobs,
MCP servers, or any code path the heuristic does not enumerate.
"""
def has_changes_to_merge?(root, path, branch, base_branch)
when is_binary(root) and is_binary(path) do
cond do
not File.dir?(path) ->
false
has_uncommitted_changes?(path) ->
true
is_binary(branch) and is_binary(base_branch) ->
branch_ahead_of_base?(root, branch, base_branch)
true ->
false
end
end
def has_changes_to_merge?(_root, _path, _branch, _base_branch), do: false
@spec copy_ignored_files(String.t(), String.t(), [String.t()]) ::
[{:ok, String.t()} | {:error, String.t(), term}]
@doc """
Copies a list of relative paths from the worktree to the source repo,
creating parent directories as needed. Returns a list of results for each
path attempted.
"""
def copy_ignored_files(source_root, worktree_path, rel_paths)
when is_binary(source_root) and is_binary(worktree_path) and is_list(rel_paths) do
Enum.map(rel_paths, fn rel ->
src = Path.join(worktree_path, rel)
dest = Path.join(source_root, rel)
File.mkdir_p!(Path.dirname(dest))
case File.cp(src, dest) do
:ok -> {:ok, rel}
{:error, reason} -> {:error, rel, reason}
end
end)
end
# Returns true when `branch` has commits that `base_branch` does not. Uses
# the same fork-point logic as diff_from_fork_point/3 so the answer matches
# what the merge flow would actually try to land.
defp branch_ahead_of_base?(root, branch, base_branch) do
case diff_from_fork_point(root, branch, base_branch) do
{:ok, diff} -> byte_size(diff) > 0
_ -> false
end
end
@spec force_delete(String.t(), String.t()) :: {:ok, :ok} | {:error, atom()}
@doc """
Removes a worktree even when it contains uncommitted changes.
"""
def force_delete(root, path) when is_binary(root) and is_binary(path) do
case File.dir?(path) do
false ->
{:error, :worktree_not_found}
true ->
case git_cmd(root, ["worktree", "remove", "--force", path]) do
{:ok, _out} -> {:ok, :ok}
{:error, :not_a_repo} -> {:error, :not_a_repo}
_ -> {:error, :git_failed}
end
end
end
@spec fnord_managed?(String.t(), String.t()) :: boolean()
@doc """
Returns true when the given worktree path resolves to the default
fnord-managed worktree root for the project or to a path beneath it.
Worktrees at this location are always created by fnord with an `fnord-`
prefixed branch, so a normalized path check is sufficient to identify
internally managed worktrees.
"""
def fnord_managed?(project, path)
when is_binary(project) and is_binary(path) do
default_root = Path.expand(default_root(project)) |> String.trim_trailing("/")
candidate = Path.expand(path) |> String.trim_trailing("/")
candidate == default_root or String.starts_with?(candidate, default_root <> "/")
end
@spec commit_all(String.t(), String.t()) :: {:ok, :ok} | {:error, atom()}
@doc """
Stages all changes and commits them in the given worktree directory.
Returns `{:error, :nothing_to_commit}` when there is nothing to commit.
"""
def commit_all(path, message) when is_binary(path) and is_binary(message) do
with {:ok, _} <- git_cmd(path, ["add", "-A"]),
{:ok, _} <- git_cmd(path, ["commit", "-m", message]) do
{:ok, :ok}
else
{:error, :git_failed} -> {:error, :nothing_to_commit}
error -> error
end
end
@spec diff_against_base(String.t(), String.t(), String.t()) ::
{:ok, String.t()} | {:error, atom()}
@doc """
Returns the diff between a base branch and a worktree branch, run from the
repository root.
"""
def diff_against_base(root, branch, base_branch)
when is_binary(root) and is_binary(branch) and is_binary(base_branch) do
git_cmd(root, ["diff", "#{base_branch}...#{branch}"])
end
@spec diff_from_fork_point(String.t(), String.t(), String.t()) ::
{:ok, String.t()} | {:error, atom()}
@doc """
Returns the diff between the fork point (merge-base) of the worktree branch
and its base branch. This shows all changes since the branch was created,
regardless of what has happened on the base branch since.
"""
def diff_from_fork_point(root, branch, base_branch)
when is_binary(root) and is_binary(branch) and is_binary(base_branch) do
with {:ok, merge_base} <- git_cmd(root, ["merge-base", base_branch, branch]) do
git_cmd(root, ["diff", String.trim(merge_base), branch])
end
end
@spec delete_branch(String.t(), String.t()) :: {:ok, :ok} | {:error, atom()}
@doc """
Deletes a local branch from the repository. Uses `-d` (safe delete) which
refuses to delete unmerged branches.
"""
def delete_branch(root, branch) when is_binary(root) and is_binary(branch) do
with {:ok, _} <- git_cmd(root, ["branch", "-d", branch]) do
{:ok, :ok}
end
end
@spec head_sha(String.t()) :: String.t() | nil
@doc """
Returns the short SHA of HEAD in the given repo root, or nil on failure.
"""
def head_sha(root) when is_binary(root) do
case git_cmd(root, ["rev-parse", "--short", "HEAD"]) do
{:ok, out} -> String.trim(out)
_ -> nil
end
end
@spec head_sha_full(String.t()) :: String.t() | nil
@doc """
Returns the full (unabbreviated) SHA of HEAD, suitable for use as a reset
target.
"""
def head_sha_full(root) when is_binary(root) do
case git_cmd(root, ["rev-parse", "HEAD"]) do
{:ok, out} -> String.trim(out)
_ -> nil
end
end
@spec log_oneline(String.t(), String.t(), String.t()) :: [String.t()]
@doc """
Returns a list of one-line commit summaries in the range `from..to`,
newest first. Each entry is a short-sha + subject line.
"""
def log_oneline(root, from, to) when is_binary(root) and is_binary(from) and is_binary(to) do
case git_cmd(root, ["log", "--oneline", "#{from}..#{to}"]) do
{:ok, ""} -> []
{:ok, out} -> String.split(out, "\n", trim: true)
_ -> []
end
end
@spec reset_hard(String.t(), String.t()) :: {:ok, :ok} | {:error, atom()}
@doc """
Hard-resets the repository at `root` to the given `target` ref. Used to
revert a fast-forward merge that may have advanced HEAD by multiple commits.
"""
def reset_hard(root, target) when is_binary(root) and is_binary(target) do
with {:ok, _} <- git_cmd(root, ["reset", "--hard", target]) do
{:ok, :ok}
end
end
@spec force_delete_branch(String.t(), String.t()) :: {:ok, :ok} | {:error, atom()}
@doc """
Force-deletes a local branch regardless of merge status.
"""
def force_delete_branch(root, branch) when is_binary(root) and is_binary(branch) do
with {:ok, _} <- git_cmd(root, ["branch", "-D", branch]) do
{:ok, :ok}
end
end
@spec project_root() :: {:ok, String.t()} | {:error, atom()}
@doc """
Returns the current repository root or `:not_a_repo` when the process is not
inside a Git repository.
"""
def project_root do
case GitCli.repo_root() do
nil -> {:error, :not_a_repo}
root -> {:ok, root}
end
end
@spec normalize_worktree_meta(map()) :: worktree_meta()
@doc """
Normalizes stored worktree metadata into the shape expected by the context.
"""
def normalize_worktree_meta(meta) when is_map(meta) do
%{path: meta_path(meta), branch: meta_branch(meta), base_branch: meta_base_branch(meta)}
end
@spec normalize_worktree_meta_in_parent(map()) :: map()
@doc """
Normalizes the worktree sub-map within a parent metadata map, handling both
atom and string keys for the worktree entry itself. Returns the parent map
with a normalized :worktree value, or unchanged if no worktree is present.
"""
def normalize_worktree_meta_in_parent(meta) when is_map(meta) do
raw = Map.get(meta, :worktree) || Map.get(meta, "worktree")
case raw do
nil -> meta
m when is_map(m) -> Map.put(meta, :worktree, normalize_worktree_meta(m))
end
end
@spec recursive_size(String.t()) :: non_neg_integer()
def recursive_size(path) when is_binary(path) do
path
|> recursive_entries()
|> Enum.reduce(0, fn entry, acc -> acc + file_size(entry) end)
end
defp resolve_default_base_branch(root) do
case default_base_branch(root) do
branch when is_binary(branch) -> {:ok, branch}
nil -> {:error, :invalid_branch}
end
end
defp ensure_conversation_path(project, conversation_id) do
path = conversation_path(project, conversation_id)
case File.mkdir_p(default_root(project)) do
:ok -> {:ok, path}
_ -> {:error, :git_failed}
end
end
defp ensure_parent_dir(path) do
case File.mkdir_p(Path.dirname(path)) do
:ok -> :ok
_ -> {:error, :git_failed}
end
end
@spec prepare_recreated_worktree(String.t(), String.t(), worktree_meta()) ::
{:ok, recreation_result()} | {:error, atom()}
# Preserves the stored worktree path when one exists in metadata, falling
# back to the default conversation path only for worktrees that were created
# without an explicit location.
defp prepare_recreated_worktree(project, conversation_id, meta) do
stored_path = meta_path(meta)
target_path =
if is_binary(stored_path),
do: stored_path,
else: conversation_path(project, conversation_id)
branch = meta_branch(meta)
base_branch = meta_base_branch(meta)
with {:ok, root} <- project_root(),
:ok <- ensure_parent_dir(target_path),
{:ok, resolved_base_branch} <- resolve_default_base_branch(root),
{:ok, branch} <- normalize_branch(branch, base_branch || resolved_base_branch) do
normalized_meta =
meta
|> Map.put(:path, target_path)
|> Map.put(:branch, branch)
|> Map.put(:base_branch, base_branch || resolved_base_branch)
|> normalize_worktree_meta()
{:ok, %{root: root, path: target_path, branch: branch, meta: normalized_meta}}
end
end
defp normalize_worktree_entry(path, branch, base_branch, root) do
%{
path: path,
branch: branch,
base_branch: base_branch,
merge_status: merge_status(path, root, branch, base_branch),
size: recursive_size(path),
exists?: true
}
end
defp enrich_worktree(root, %{path: path} = entry) do
branch = Map.get(entry, :branch) || Map.get(entry, "branch")
base_branch =
Map.get(entry, :base_branch) || Map.get(entry, "base_branch") || default_base_branch(root)
Map.merge(entry, %{
path: path,
branch: branch,
base_branch: base_branch,
merge_status: merge_status(path, root, branch, base_branch),
size: recursive_size(path),
exists?: File.dir?(path)
})
end
@spec merge_status(String.t(), String.t(), String.t() | nil, String.t() | nil) ::
:ahead | :diverged | :unknown
@doc """
Returns the merge status of a worktree branch relative to its base branch.
Returns `:ahead` when the worktree branch contains the base branch and
`:unknown` when the worktree path or any branch metadata is unavailable.
Any other git result is reported as `:diverged` so callers can treat the
branch as needing manual attention before merge.
"""
def merge_status(path, root, branch, base_branch) do
cond do
not File.dir?(path) ->
:unknown
is_nil(branch) ->
:unknown
is_nil(base_branch) ->
:unknown
true ->
case git_cmd(root, ["merge-base", "--is-ancestor", base_branch, branch]) do
{:ok, _out} -> :ahead
_ -> :diverged
end
end
end
defp git_worktree_list(root) do
with {:ok, out} <- git_cmd(root, ["worktree", "list", "--porcelain"]) do
{:ok, parse_worktree_list(out)}
end
end
defp parse_worktree_list(out) do
out
|> String.split(~r/\n\s*\n/, trim: true)
|> Enum.map(&parse_worktree_record/1)
|> Enum.reject(&is_nil/1)
end
defp parse_worktree_record(record) do
fields =
record
|> String.split("\n", trim: true)
|> Enum.reduce(%{}, fn line, acc -> parse_worktree_line(line, acc) end)
case Map.fetch(fields, :path) do
{:ok, path} ->
%{
path: path,
branch: Map.get(fields, :branch),
base_branch: Map.get(fields, :base_branch)
}
:error ->
nil
end
end
defp parse_worktree_line("worktree " <> path, acc), do: Map.put(acc, :path, path)
defp parse_worktree_line("branch refs/heads/" <> branch, acc), do: Map.put(acc, :branch, branch)
defp parse_worktree_line("detached", acc), do: Map.put(acc, :branch, nil)
defp parse_worktree_line("HEAD " <> _head, acc), do: acc
defp parse_worktree_line(_, acc), do: acc
# Creates a worktree with a new branch forked from a start point. Used by
# create/3 for fresh conversation worktrees.
defp git_worktree_add_branch(root, path, branch, start_point) do
case git_cmd(root, ["worktree", "add", "--force", "-b", branch, path, start_point]) do
{:ok, out} -> {:ok, out}
{:error, :invalid_branch} -> {:error, :invalid_branch}
{:error, :not_a_repo} -> {:error, :not_a_repo}
_ -> {:error, :git_failed}
end
end
# Checks out an existing branch into a worktree. Used by
# recreate_conversation_worktree/3 to restore a previously created worktree.
defp git_worktree_add(root, path, branch) do
case git_cmd(root, ["worktree", "add", "--force", path, branch]) do
{:ok, out} -> {:ok, out}
{:error, :invalid_branch} -> {:error, :invalid_branch}
{:error, :not_a_repo} -> {:error, :not_a_repo}
_ -> {:error, :git_failed}
end
end
defp git_worktree_remove(root, path) do
case File.dir?(path) do
false ->
{:error, :worktree_not_found}
true ->
case git_cmd(root, ["worktree", "remove", path]) do
{:ok, out} -> {:ok, out}
{:error, :not_a_repo} -> {:error, :not_a_repo}
_ -> {:error, :git_failed}
end
end
end
defp git_worktree_branch(root, path) do
with true <- File.dir?(path) or {:error, :worktree_not_found},
{:ok, branch} <- git_cmd(root, ["rev-parse", "--abbrev-ref", "HEAD"], cd: path) do
{:ok, String.trim(branch)}
end
end
defp normalize_branch(nil, base_branch), do: {:ok, base_branch}
defp normalize_branch(branch, _base_branch) when is_binary(branch), do: {:ok, branch}
defp normalize_branch(_, _), do: {:error, :invalid_branch}
@spec default_base_branch(String.t()) :: String.t() | nil
@doc """
Returns the default base branch for the repository — either the remote HEAD
(e.g., `main`) or the current branch as a fallback.
"""
def default_base_branch(root) when is_binary(root) do
case repo_default_branch(root) do
branch when is_binary(branch) -> branch
nil -> current_branch(root)
end
end
defp repo_default_branch(root) when is_binary(root) do
with {:ok, out} <- git_cmd(root, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]) do
out
|> String.trim()
|> String.replace_prefix("refs/remotes/origin/", "")
|> case do
"" -> nil
branch -> branch
end
else
_ -> nil
end
end
@spec current_branch(String.t()) :: String.t() | nil
@doc """
Returns the currently checked-out branch name for the given repo root, or
nil when HEAD is detached or the branch cannot be determined.
"""
def current_branch(root) when is_binary(root) do
with {:ok, branch} <- git_cmd(root, ["rev-parse", "--abbrev-ref", "HEAD"]) do
branch = String.trim(branch)
case branch do
"HEAD" -> nil
"" -> nil
valid_branch -> valid_branch
end
else
_ -> nil
end
end
defp git_cmd(root, args, opts \\ []) do
case System.cmd("git", args, Keyword.merge([cd: root, stderr_to_stdout: true], opts)) do
{out, 0} -> {:ok, out}
{_, 128} -> {:error, :not_a_repo}
{_, 129} -> {:error, :invalid_branch}
{_, 1} -> {:error, :git_failed}
{_, _} -> {:error, :git_failed}
end
end
defp file_size(path) do
case File.stat(path) do
{:ok, %{size: size}} -> size
_ -> 0
end
end
defp recursive_entries(path) do
if File.dir?(path) do
path
|> Path.join("**/*")
|> Path.wildcard()
|> Enum.filter(&File.regular?/1)
else
[]
end
end
defp meta_path(meta), do: Map.get(meta, :path) || Map.get(meta, "path")
defp meta_branch(meta), do: Map.get(meta, :branch) || Map.get(meta, "branch")
defp meta_base_branch(meta), do: Map.get(meta, :base_branch) || Map.get(meta, "base_branch")
end