Packages
fnord
0.9.28
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/ai/tools/git/worktree.ex
defmodule AI.Tools.Git.Worktree do
@behaviour AI.Tools
@impl AI.Tools
def async?, do: false
@impl AI.Tools
def is_available?() do
GitCli.is_git_repo?()
end
@impl AI.Tools
def read_args(args), do: {:ok, args}
@impl AI.Tools
def ui_note_on_request(_), do: nil
@impl AI.Tools
def ui_note_on_result(%{"action" => action, "branch" => branch}, _result) do
{"Worktree", "#{branch} -> #{action}"}
end
def ui_note_on_result(_, _), do: nil
@impl AI.Tools
def tool_call_failure_message(_args, _reason), do: :default
@impl AI.Tools
def spec do
%{
type: "function",
function: %{
name: "git_worktree_tool",
description: """
Manage git worktrees for the current project conversation.
Required fields per action:
- list: (none, root is optional)
- create: branch (optional, defaults to fnord-<conversation_id>)
- commit: message (required), wip (optional, default false)
- delete: root, path
- merge: root, path
Create derives the project and conversation from the active session.
""",
parameters: %{
type: "object",
additionalProperties: false,
required: ["action"],
properties: %{
"action" => %{
type: "string",
enum: ["list", "create", "commit", "delete", "merge"],
description: "Which worktree operation to perform."
},
"branch" => %{
type: "string",
description:
"Optional branch name for create. Provide a short descriptive name " <>
"for the work being done. Defaults to fnord-<conversation_id>."
},
"message" => %{
type: "string",
description:
"Required for commit. Commit message describing the changes. " <>
"When wip is true, describe what was accomplished and what problems remain."
},
"wip" => %{
type: "boolean",
description:
"Optional for commit (default false). Set to true when stopping " <>
"due to blockers or incomplete work; prefixes the message with 'WIP: '."
},
"path" => %{
type: "string",
description: "Required for delete and merge. Absolute worktree path."
},
"root" => %{
type: "string",
description: "Git repo root. Required for delete and merge, optional for list."
}
}
}
}
}
end
@impl AI.Tools
def call(%{"action" => "list"} = args) do
root = Map.get(args, "root", GitCli.repo_root())
with {:ok, results} <- GitCli.Worktree.list(root) do
{:ok, results}
end
end
@doc """
Create is the integration point where a conversation-scoped worktree becomes
part of the active session. It derives the project and conversation from the
active session, creates the worktree, binds the resulting metadata back to
that conversation, and rolls back the created worktree if metadata binding
fails. An optional branch name lets the coordinator label the work.
"""
def call(%{"action" => "create"} = args) do
with {:ok, conversation_pid} <- active_conversation_pid(),
{:ok, project} <- project_name(),
conversation_id = Services.Conversation.get_id(conversation_pid),
:ok <- check_no_existing_worktree(conversation_pid),
{:ok, result} <-
GitCli.Worktree.create(project, conversation_id, Map.get(args, "branch")),
{:ok, result} <- finalize_created_worktree(conversation_pid, result) do
Settings.set_project_root_override(result.path)
{:ok, result}
end
end
# Commits all staged and unstaged changes in the active worktree. Only works
# in fnord-managed worktrees. When `wip` is true, prefixes the commit message
# to signal incomplete work.
def call(%{"action" => "commit", "message" => message} = args) do
wip? = Map.get(args, "wip", false)
commit_message =
if wip? do
# Strip any leading "WIP:" the LLM included in the message so the
# wip prefix is applied exactly once. The tool description tells the
# LLM to describe incomplete work, which naturally produces messages
# that start with "WIP:", and prefixing again yields "WIP: WIP: ..."
# on the merged commit subject.
"WIP: #{String.replace(message, ~r/^\s*WIP:\s*/i, "")}"
else
# When wip? is false, respect the caller's message verbatim. Stripping
# a leading "WIP:" here would silently rewrite an explicit intent.
message
end
with {:ok, path} <- active_worktree_path(),
{:ok, project} <- project_name(),
true <- GitCli.Worktree.fnord_managed?(project, path) || {:error, :not_fnord_managed} do
GitCli.Worktree.commit_all(path, commit_message)
else
{:error, :not_fnord_managed} ->
{:error, "Commits via this tool are only allowed in fnord-managed worktrees."}
{:error, reason} ->
{:error, reason}
end
end
def call(%{"action" => "delete", "root" => root, "path" => path}) do
GitCli.Worktree.delete(root, path)
end
def call(%{"action" => "merge", "root" => root, "path" => path}) do
GitCli.Worktree.merge(root, path)
end
def call(%{"action" => "commit"}) do
{:error, "Missing required field 'message' for action 'commit'"}
end
def call(%{"action" => action}) when action in ["delete", "merge"] do
{:error, "Missing required fields 'root' and 'path' for worktree action"}
end
def call(_), do: {:error, "Missing required field 'action'"}
# This is the handoff point between low-level worktree creation and the
# higher-level session state that makes the worktree part of the active
# conversation. If metadata binding fails for any reason, including a crashed
# conversation process, the created worktree is rolled back before the error
# is surfaced to the caller.
@spec finalize_created_worktree(pid(), map()) :: {:ok, map()} | {:error, term()}
defp finalize_created_worktree(conversation_pid, result) do
# bind_worktree_to_conversation/2 always returns :ok on success; the only
# realistic failure mode is a dead or unresponsive conversation server,
# which surfaces as a GenServer.call exit. Catch those to roll back the
# half-created worktree and surface a typed error to the tool caller.
try do
:ok = bind_worktree_to_conversation(conversation_pid, result)
{:ok, result}
catch
:exit, reason ->
rollback_created_worktree(result)
{:error, {:conversation_bind_failed, {:exit, reason}}}
end
end
@spec rollback_created_worktree(map()) :: :ok
defp rollback_created_worktree(%{path: path}) do
with root when is_binary(root) <- GitCli.repo_root(),
{:ok, :ok} <- GitCli.Worktree.delete(root, path) do
:ok
else
_ -> :ok
end
end
@spec active_conversation_pid() :: {:ok, pid} | {:error, String.t()}
defp active_conversation_pid do
case Services.Globals.get_env(:fnord, :current_conversation, nil) do
nil ->
{:error,
"No active conversation is available for worktree binding. " <>
"Create the worktree from within the target conversation session."}
pid ->
{:ok, pid}
end
end
@spec bind_worktree_to_conversation(pid, map) :: :ok
defp bind_worktree_to_conversation(pid, result) do
meta =
GitCli.Worktree.normalize_worktree_meta(%{
path: result.path,
branch: result.branch,
base_branch: result.base_branch
})
Services.Conversation.upsert_conversation_meta(pid, %{worktree: meta})
end
@spec check_no_existing_worktree(pid) :: :ok | {:error, String.t()}
defp check_no_existing_worktree(pid) do
case existing_worktree_path(pid) do
nil ->
:ok
path ->
{:error,
"This conversation already has a worktree at #{path}. " <>
"Use the existing worktree or ask the user before creating another."}
end
end
@spec existing_worktree_path(pid) :: String.t() | nil
defp existing_worktree_path(pid) do
pid
|> Services.Conversation.get_conversation_meta()
|> GitCli.Worktree.normalize_worktree_meta_in_parent()
|> case do
%{worktree: %{path: path}} when is_binary(path) -> path
_ -> nil
end
end
@spec active_worktree_path() :: {:ok, String.t()} | {:error, String.t()}
defp active_worktree_path do
case Settings.get_project_root_override() do
nil -> {:error, "No active worktree for this conversation."}
path -> {:ok, path}
end
end
@spec project_name() :: {:ok, String.t()} | {:error, String.t()}
defp project_name do
case Store.get_project() do
{:ok, project} -> {:ok, project.name}
_ -> {:error, "No project selected."}
end
end
end