Packages

fnord

0.9.28

AI code archaeology

Current section

Files

Jump to
fnord lib cmd worktrees.ex
Raw

lib/cmd/worktrees.ex

defmodule Cmd.Worktrees do
@behaviour Cmd
@impl Cmd
def requires_project?(), do: true
@impl Cmd
def spec do
[
worktrees: [
name: "worktrees",
about: "Manage git worktrees for the current project",
options: [
project: Cmd.project_arg()
],
subcommands: [
list: [
name: "list",
about: "List worktrees with branch, merge status, and size"
],
create: [
name: "create",
about: "Create a new conversation-scoped worktree",
options: [
conversation: [
value_name: "CONVERSATION_ID",
long: "--conversation",
short: "-c",
help: "Conversation id for worktree naming",
parser: :string,
required: true
],
branch: [
value_name: "BRANCH",
long: "--branch",
short: "-b",
help: "Branch name (default: auto-generated)",
parser: :string,
required: false
]
]
],
delete: [
name: "delete",
about: "Remove a conversation worktree",
options: [
conversation: [
value_name: "CONVERSATION_ID",
long: "--conversation",
short: "-c",
help: "Conversation id whose worktree to remove",
parser: :string,
required: true
]
]
],
view: [
name: "view",
about: "Show the diff of a conversation worktree from its fork point",
options: [
conversation: [
value_name: "CONVERSATION_ID",
long: "--conversation",
short: "-c",
help: "Conversation id whose worktree diff to view",
parser: :string,
required: true
]
]
],
merge: [
name: "merge",
about: "Review, merge, and optionally clean up a conversation worktree",
options: [
conversation: [
value_name: "CONVERSATION_ID",
long: "--conversation",
short: "-c",
help: "Conversation id whose worktree to merge",
parser: :string,
required: true
]
]
]
]
]
]
end
@impl Cmd
def run(_opts, [:list], _unknown) do
with {:ok, project} <- Store.get_project(),
{:ok, root} <- GitCli.Worktree.project_root(),
{:ok, entries} <- GitCli.Worktree.list_raw(root) do
UI.info("Found #{length(entries)} worktrees")
managed =
entries
|> Enum.filter(fn entry ->
GitCli.Worktree.fnord_managed?(project.name, entry.path)
end)
UI.info("Found #{length(managed)} fnord-managed worktrees")
managed =
managed
|> Enum.map(fn entry ->
UI.info("Found #{entry.path}:")
UI.info(" - Verifying worktree state")
GitCli.Worktree.enrich(root, entry)
end)
if managed == [] do
UI.info("No fnord-managed worktrees found")
else
managed
|> Enum.map(fn entry ->
%{
"Conversation" => Path.basename(entry.path),
"Branch" => entry.branch || "-",
"Base" => Map.get(entry, :base_branch) || "-",
"Status" => format_merge_status(entry.merge_status),
"Dirty" =>
if(GitCli.Worktree.has_uncommitted_changes?(entry.path), do: "yes", else: "-"),
"Size" => format_size(entry.size),
"Path" => entry.path
}
end)
|> Owl.Table.new(
padding_x: 1,
sort_columns: fn a, b -> column_order(a) <= column_order(b) end
)
|> UI.puts()
print_merge_hints()
end
:ok
else
{:error, :not_a_repo} ->
UI.error("Not inside a git repository")
{:error, :not_a_repo}
{:error, reason} ->
UI.error("Failed to list worktrees: #{format_reason(reason)}")
{:error, reason}
end
end
def run(%{conversation: conv_id}, [:view], _unknown) do
with {:ok, meta} <- resolve_worktree_meta(conv_id),
{:ok, root} <- resolve_repo_root_for_view(meta.path),
{:ok, diff} <- GitCli.Worktree.diff_from_fork_point(root, meta.branch, meta.base_branch) do
if byte_size(diff) > 0 do
diff
|> GitCli.Worktree.Review.colorize_diff()
|> UI.puts()
else
UI.info("No changes from fork point")
end
:ok
else
{:error, :missing_worktree_path} ->
UI.error("Worktree path does not exist or is inaccessible")
{:error, :missing_worktree_path}
{:error, :not_a_repo} ->
UI.error("Worktree path is not inside a git repository")
{:error, :not_a_repo}
{:error, reason} ->
UI.error("Failed to view worktree diff: #{format_reason(reason)}")
{:error, reason}
end
end
def run(%{conversation: conversation_id} = opts, [:create], _unknown) do
with {:ok, project} <- Store.get_project(),
{:ok, entry} <-
GitCli.Worktree.create(project.name, conversation_id, Map.get(opts, :branch)) do
UI.info("Created worktree", entry.path)
:ok
else
{:error, :not_a_repo} ->
UI.error("Not inside a git repository")
{:error, :not_a_repo}
{:error, reason} ->
UI.error("Failed to create worktree: #{format_reason(reason)}")
{:error, reason}
end
end
def run(%{conversation: conv_id}, [:delete], _unknown) do
with {:ok, meta} <- resolve_worktree_meta(conv_id),
{:ok, root} <- GitCli.Worktree.project_root(),
:ok <- Cmd.WorktreeLifecycle.warn_if_unmerged(root, meta),
{:ok, :ok} <- Cmd.WorktreeLifecycle.delete_worktree(root, meta.path) do
UI.info("Deleted worktree", meta.path)
case GitCli.Worktree.delete_branch(root, meta.branch) do
{:ok, :ok} ->
UI.info("Deleted branch", meta.branch)
{:error, _} ->
case GitCli.Worktree.force_delete_branch(root, meta.branch) do
{:ok, :ok} ->
UI.info("Force-deleted branch", meta.branch)
{:error, _} ->
UI.debug("worktrees", "Branch #{meta.branch} not found (already cleaned up)")
end
end
Cmd.WorktreeLifecycle.clear_worktree_from_conversation(conv_id)
:ok
else
{:error, :cancelled} ->
UI.info("Worktree deletion cancelled")
:ok
{:error, :not_a_repo} ->
UI.error("Not inside a git repository")
{:error, :not_a_repo}
{:error, reason} ->
UI.error("Failed to delete worktree: #{format_reason(reason)}")
{:error, reason}
end
end
def run(%{conversation: conv_id}, [:merge], _unknown) do
with {:ok, meta} <- resolve_worktree_meta(conv_id),
{:ok, root} <- GitCli.Worktree.project_root() do
# Copy gitignored/excluded files before the review flow may delete the
# worktree. Same eager-copy strategy as Cmd.Ask.
copied_files = copy_ignored_writes_for_conversation(conv_id, meta.path, root)
case GitCli.Worktree.Review.interactive_review(root, meta, ignored_files: copied_files) do
{:cleaned_up, _sha, _mode} ->
Cmd.WorktreeLifecycle.clear_worktree_from_conversation(conv_id)
_ ->
:ok
end
else
{:error, :not_a_repo} ->
UI.error("Not inside a git repository")
{:error, :not_a_repo}
{:error, reason} ->
UI.error("Failed to merge worktree: #{format_reason(reason)}")
{:error, reason}
end
end
def run(_opts, [], _unknown) do
UI.error("No subcommand specified. Use 'fnord worktrees --help' for help.")
end
def run(_opts, _subcommands, _unknown) do
UI.error("Unknown subcommand. Use 'fnord worktrees --help' for help.")
end
# Reads the gitignored_writes accumulator from a conversation's persisted
# metadata and copies those files (plus any found by the safety-net scan)
# from the worktree to the source repo. Returns the list of successfully
# copied relative paths for display in the review section.
@spec copy_ignored_writes_for_conversation(String.t(), String.t(), String.t()) :: [String.t()]
defp copy_ignored_writes_for_conversation(conv_id, worktree_path, source_root) do
conv = Store.Project.Conversation.new(conv_id)
conv_meta =
case Store.Project.Conversation.read(conv) do
{:ok, data} -> data.metadata
_ -> %{}
end
files =
conv_meta
|> Map.get(:gitignored_writes, [])
|> Enum.filter(&File.exists?(Path.join(worktree_path, &1)))
if files != [] do
results = GitCli.Worktree.copy_ignored_files(source_root, worktree_path, files)
Enum.each(results, fn
{:ok, _rel} -> :ok
{:error, rel, reason} -> UI.warn("Failed to copy #{rel}: #{reason}")
end)
for {:ok, rel} <- results, do: rel
else
[]
end
end
# Resolves worktree metadata from a conversation id by first consulting the
# conversation's persisted metadata and then falling back to a matching
# fnord-managed worktree discovered from the repository's worktree list.
@spec resolve_worktree_meta(String.t()) ::
{:ok, GitCli.Worktree.Review.worktree_info()} | {:error, atom()}
defp resolve_worktree_meta(conv_id) do
case resolve_worktree_meta_from_conversation(conv_id) do
{:ok, _} = ok -> ok
{:error, _} -> resolve_worktree_meta_from_disk(conv_id)
end
end
defp resolve_worktree_meta_from_conversation(conv_id) do
conv = Store.Project.Conversation.new(conv_id)
with {:ok, data} <- Store.Project.Conversation.read(conv) do
raw =
data.metadata
|> GitCli.Worktree.normalize_worktree_meta_in_parent()
|> Map.get(:worktree)
case raw do
%{path: path, branch: branch, base_branch: base_branch}
when is_binary(path) and is_binary(branch) and is_binary(base_branch) ->
{:ok, raw}
_ ->
{:error, :no_worktree_metadata}
end
end
end
defp resolve_worktree_meta_from_disk(conv_id) do
with {:ok, project} <- Store.get_project(),
{:ok, root} <- GitCli.Worktree.project_root(),
{:ok, entries} <- GitCli.Worktree.list_raw(root) do
case Enum.find(entries, fn entry ->
GitCli.Worktree.fnord_managed?(project.name, entry.path) and
Path.basename(entry.path) == conv_id and
is_binary(entry.branch) and
is_binary(entry.base_branch)
end) do
nil -> {:error, :no_worktree_metadata}
entry -> {:ok, %{path: entry.path, branch: entry.branch, base_branch: entry.base_branch}}
end
end
end
@spec resolve_repo_root_for_view(String.t()) ::
{:ok, String.t()} | {:error, :missing_worktree_path | :not_a_repo}
defp resolve_repo_root_for_view(path) when is_binary(path) do
if File.dir?(path) do
case System.cmd("git", ["rev-parse", "--show-toplevel"], cd: path, stderr_to_stdout: true) do
{out, 0} -> {:ok, String.trim(out)}
_ -> {:error, :not_a_repo}
end
else
{:error, :missing_worktree_path}
end
end
defp format_merge_status(:merged), do: "merged"
defp format_merge_status(:ahead), do: "ahead"
defp format_merge_status(:diverged), do: "diverged"
defp format_merge_status(_), do: "unknown"
# Appends the merge-command template below the table so the user has the
# invocation in front of them without having to remember the flag shape.
# The conversation id placeholder stays generic - the ids are already
# visible in the Conversation column of the table above.
defp print_merge_hints() do
UI.puts("")
UI.puts("To merge a worktree:")
UI.puts(" fnord worktrees merge -c <conversation-id>")
UI.puts("")
end
defp format_size(bytes) when bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB"
defp format_size(bytes) when bytes >= 1_024, do: "#{Float.round(bytes / 1_024, 1)} KB"
defp format_size(bytes), do: "#{bytes} B"
@column_order %{
"Conversation" => 0,
"Branch" => 1,
"Base" => 2,
"Status" => 3,
"Dirty" => 4,
"Size" => 5,
"Path" => 6
}
defp column_order(col), do: Map.get(@column_order, col, 99)
defp format_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
end