Packages
fnord
0.9.24
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/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(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 <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