Relative-Path-Safe, Idempotent Worktree Setup (Issue #1121)¶
step-04-setup-worktree now works reliably when the recipe is invoked with a
relative repo_path (for example -c repo_path="."). Re-running against a
worktree that is already registered and present on disk no longer aborts the
whole recipe with git worktree add ... already exists (exit 128). Setup now
converges: an existing, matching worktree is silently reused, and a
directory left behind on disk is repaired in place instead of crashing the run.
Affects:
- amplifier-bundle/recipes/workflow-worktree.yaml — step-04-setup-worktree
Closes: #1121
Quick Start¶
No configuration is required. Both absolute and relative repo_path values are
now handled identically. The following invocations are equivalent and both
succeed on first run and on every re-run:
# Absolute repo_path (worked before)
amplihack recipe run default-workflow \
-c task_description="Fix issue #1234" \
-c repo_path="$(pwd)"
# Relative repo_path (now equally safe)
amplihack recipe run default-workflow \
-c task_description="Fix issue #1234" \
-c repo_path="."
Running the setup step a second time when the worktree already exists prints:
INFO: Branch 'feat-issue-1234' and worktree '/abs/repo/worktrees/feat-issue-1234' already exist and are clean — reusing.
and the step exits 0 with created=false. It never prints
fatal: '...' already exists and never exits 128 for an already-present,
matching worktree.
Problem¶
Each workflow run creates an isolated git worktree under
${REPO_PATH}/worktrees/<branch> (see
Worktree Support). step-04-setup-worktree uses a
three-state idempotency guard to make re-runs safe:
| State | Condition | Action |
|---|---|---|
| 1 | branch and worktree both registered | reuse silently (created=false) |
| 2 | branch registered, worktree missing | attach a worktree for the branch |
| 3 | neither exists | create branch + worktree |
Two defects combined to break this guard when repo_path was relative.
Defect A — non-canonical path defeats the registration check¶
The guard derives the target path as
WORKTREE_PATH="${REPO_PATH}/worktrees/${BRANCH_NAME}" and tests whether that
worktree is already registered with an exact match:
git worktree list --porcelain always emits absolute, canonical paths
(e.g. /abs/repo/worktrees/feat-issue-1234). But with repo_path=".",
WORKTREE_PATH was ./worktrees/feat-issue-1234. The grep -Fx exact-line
match therefore never matched a registered worktree, so a worktree that was
in fact present got misclassified as missing (State 2). The same breakage
occurred for any repo_path containing /./, a trailing slash, or a
symlinked / otherwise non-canonical prefix.
Defect B — the add path had no existing-directory guard¶
Once misclassified as State 2 (or State 3), the guard ran a bare
git worktree add "${WORKTREE_PATH}" .... Because the directory was actually
present on disk, git aborted:
with exit code 128, which propagated up and marked the entire recipe run
as status: failure. The existing-branch / PR code path (issue #342) already
guarded its adds against a present-on-disk directory (issue #642); the
three-state guard had regressed that robustness.
Fix¶
Two changes, both in step-04-setup-worktree.
1. Canonicalize repo_path once, early¶
Immediately after the step validates it is inside a work tree and cds into
repo_path, it now canonicalizes the path:
cd "$REPO_PATH"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { ... }
# Canonicalize so every derived WORKTREE_PATH is absolute and matches
# `git worktree list --porcelain` output (fixes issue #1121 Defect A).
REPO_PATH="$(pwd -P)"
Because the step has already cd'd into the repo, pwd -P yields the absolute,
symlink-resolved canonical path. Every downstream
WORKTREE_PATH="${REPO_PATH}/worktrees/${BRANCH_NAME}" is therefore absolute
and canonical, so the grep -Fx registration check matches git's porcelain
output. This alone fixes the exact reported failure: a registered and
present worktree is now correctly classified as State 1 (reuse).
2. Idempotent, guarded git worktree add¶
Every git worktree add in the step is now routed through a single shared
helper, wt_add_idempotent, defined once near the top of the step. Before
adding, it prunes stale registrations and repairs a present-on-disk directory
instead of crashing:
# wt_add_idempotent MODE WORKTREE_PATH BRANCH_NAME [REF]
# MODE ∈ attach | track | create
wt_add_idempotent() {
local mode="$1" wt="$2" branch="$3" ref="${4:-}"
git worktree prune 2>/dev/null || true # drop stale registrations
if [ -d "$wt" ]; then
local cur
cur="$(git -C "$wt" symbolic-ref --short HEAD 2>/dev/null || true)"
if [ "$cur" = "$branch" ]; then
return 0 # already correct — reuse
fi
git worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt"
fi
case "$mode" in
attach) git worktree add -- "$wt" "$branch" ;;
track) git worktree add --track -b "$branch" -- "$wt" "origin/$branch" ;;
create) git worktree add -b "$branch" -- "$wt" "$ref" ;;
esac
}
--placement matters. Git stops parsing options at--, so every option (-b,--track) must appear before the separator.createtherefore usesadd -b "$branch" -- "$wt" "$ref"(notadd -- "$wt" -b …, which git rejects because-bafter--is read as a pathspec). This mirrors the existing--trackform and preserves the flag-injection guard the--separator provides.
Key guarantees:
- Never
git worktree add --forceunconditionally. A blind--forcecan silently clobber a legitimately populated directory. The helper only removes a directory after confirming its checked-out branch does not match the target. - Reuse when the branch already matches. A present directory whose
HEADis the target branch is reused as-is (return 0), so no data is lost. - Repair when it does not match. A stale / mismatched directory is removed
(
git worktree remove --force, falling back torm -rf) and re-added. - Prune first.
git worktree pruneclears registrations left dangling by a partial prior run, so a present-but-unregistered directory still converges. - Validation preserved. The helper is only ever reached after branch names
pass
git check-ref-format, and it always uses the--end-of-options separator, so path/branch values can never be interpreted as flags.
The three-state guard's State 2 and State 3 adds, and the existing-branch /
PR (#342) adds, all call this one helper. Because the several previously
duplicated git worktree add invocations collapse into a single function, the
change adds the guard while net-reducing line count, keeping
workflow-worktree.yaml strictly under 400 lines (enforced by check A10 in
test-issue-840-worktree-leak-proof.sh).
Line-budget watch-item.
workflow-worktree.yamlis already 398/400 lines, so the guarantee above is tight. The net reduction is only real if the helper genuinely collapses the duplicated State-2, State-3, and #342 add-sites into single-line calls; a helper that is added alongside the existing invocations would blow the budget. If collapsing alone does not clear 400 lines, the documented contingencies are, in order: (1) trim redundant inline comments in the step, then (2) externalize the helper to atools/-hosted script sourced by the step. Check A10 remains the hard gate either way.
Behavior Matrix¶
With both fixes, step-04-setup-worktree converges for every combination of
registration state, on-disk presence, and repo_path form:
repo_path |
Worktree registered? | Directory on disk? | Branch matches? | Result |
|---|---|---|---|---|
. or absolute |
yes | yes | yes | State 1 — reuse, exit 0, created=false |
. or absolute |
yes | yes | no (dirty/ahead) | reset to BASE_REF, exit 0 |
. or absolute |
yes | no | — | State 2 — attach, exit 0 |
. or absolute |
no | yes | yes | prune + reuse, exit 0 |
. or absolute |
no | yes | no | prune + remove + re-add, exit 0 |
. or absolute |
no | no | — | State 3 — create, exit 0 |
No combination results in exit 128 or an already exists abort.
Preserved Behavior¶
This fix is surgical. It does not change:
- The #858 caller-checkout refusal — a run never reuses the caller's own repository as its task worktree.
- The #200 cleanliness resets — a dirty worktree or a branch with commits
ahead of
BASE_REFis stillreset --hard "${BASE_REF}"before reuse. - The #829 / #840 foreign-worktree deconfliction and the orphan sweep helper.
- The #342 existing-branch / PR matching (
git worktree list --porcelainawk on the branch ref) and its #642 existing-directory guard semantics. - The
created=/CREATEDoutput contract and the step's JSON output. - Branch-name validation via
git check-ref-formatbefore any path use.
Regression Test¶
amplifier-bundle/recipes/tests/test-issue-1121-relative-repo-path.sh guards
this behavior. It follows the same idioms as
test-issue-840-worktree-leak-proof.sh (repo-root discovery, pass/fail
counters, set -euo pipefail, mktemp -d + trap cleanup, a build_repo
helper, and exit codes 0 pass / 1 fail / 2 harness error). It exercises
the real step body extracted from the recipe (via the same extract_step awk
idiom as the #840 test) against a temporary git repo:
Dynamic scenarios
- Registered + present. Pre-register and leave on disk a worktree for the
target branch, then run the step with
repo_path=".". Asserts the step exits0, does not printalready exists, and reuses the worktree (State 1,created=false). - Present but unregistered. Leave a directory on disk for the target
branch with no registration (a stale/partial state). Asserts the guard
still converges to exit
0(reuse-if-branch-matches, else remove + re-add), never exit128.
Static contract checks
- (a) step-04 canonicalizes
repo_pathwithpwd -Pafter thecd. - (b) the new-branch add path has a prune / existing-directory guard.
- (c)
workflow-worktree.yamlis still < 400 lines (check A10).
Run it directly:
The test is registered in CI (.github/workflows/ci.yml) alongside the other
recipes/tests/*.sh worktree tests.