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(root) do managed = Enum.filter(entries, fn entry -> GitCli.Worktree.fnord_managed?(project.name, entry.path) 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(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 ") 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