diff --git a/.agents/skills/worktrees-pnpm/SKILL.md b/.agents/skills/worktrees-pnpm/SKILL.md index ab18930e..3a1ae353 100644 --- a/.agents/skills/worktrees-pnpm/SKILL.md +++ b/.agents/skills/worktrees-pnpm/SKILL.md @@ -69,6 +69,13 @@ the skill gets them; wiring them up is two edits. replaces the default placement logic entirely — it runs `git worktree add` itself and prints the path it made — and `WorktreeRemove` is its counterpart. +**Every worktree starts from the remote's default branch as of the moment it is made.** The +hook fetches `origin`'s default branch and branches from `origin/`. It never uses the +main checkout's `HEAD`, which is whatever that checkout was last pulled to: a worktree cut from it +can start dozens of commits behind `main`, and nothing flags it until someone reads stale code as +current. If the fetch fails (offline, no `origin`), creation fails with a message rather than +falling back to a stale base. The new branch has no upstream; push it with an explicit refspec. + ```json { "hooks": { @@ -107,7 +114,8 @@ Verify without waiting for an agent, by feeding a hook its payload directly: ```bash printf '{"hook_event_name":"WorktreeCreate","cwd":"%s","worktree_id":"probe"}' "$PWD" \ | .agents/skills/worktrees-pnpm/scripts/worktree-create.sh -# expect: /worktrees/probe on stdout, git chatter on stderr, exit 0 +# expect: /worktrees/probe on stdout, git chatter on stderr, exit 0, +# and `git -C worktrees/probe rev-parse HEAD` equal to `origin/main` just fetched # The id field is not stable across callers, so .worktreeId and .name are read # too — `.name` is what a background agent's payload actually carries. diff --git a/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh b/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh index 24d317cc..ff740d18 100755 --- a/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh +++ b/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh @@ -73,10 +73,49 @@ fi mkdir -p "$(dirname "$worktree_dir")" -# Mirror the default: a fresh branch per worktree, based on current HEAD. The -# agent checks out whatever branch it actually needs once inside. -B rather than -# -b so a leftover branch from a removed worktree does not wedge creation. +# A fresh branch per worktree, based on the remote's default branch as of NOW. +# Not HEAD: the main checkout's HEAD is whatever branch it last had checked out, +# at whatever commit it was last pulled to, so a worktree cut from it can start +# dozens of commits behind and nothing says so until someone reads stale code +# as current. Fetch first, every time, and fail rather than fall back: a +# worktree on a stale base is the failure this exists to prevent, and "could +# not fetch" is a far clearer thing to be told than "your base was old". +# The agent checks out whatever branch it actually needs once inside. -B rather +# than -b so a leftover branch from a removed worktree does not wedge creation. # All git chatter goes to stderr; stdout carries the path and nothing else. -git -C "$repo_root" worktree add -B "worktree-$worktree_id" "$worktree_dir" >&2 +remote=origin +default_branch=$(git -C "$repo_root" ls-remote --symref "$remote" HEAD 2>/dev/null \ + | awk '$1 == "ref:" && $3 == "HEAD" { sub("^refs/heads/", "", $2); print $2 }') \ + || true # pipefail would exit here silently; the check below says why +if [ -z "$default_branch" ]; then + echo "WorktreeCreate: could not resolve $remote's default branch (offline?)" >&2 + exit 1 +fi +# The refspec is spelled out because a bare branch name only updates the +# remote-tracking ref when remote..fetch covers it, and a single-branch +# clone (which --depth implies) covers only the branch it was cloned on: the +# fetch would succeed, write FETCH_HEAD alone, and leave the ref below stale or +# missing. FETCH_HEAD is no substitute, since every concurrent fetch rewrites it. +# +# Concurrent creations race on that ref: each fetch records the value it expects +# to replace, and all but one find it already moved ("cannot lock ref ... is at +# X but expected Y"). Measured at 55 failures in 60 concurrent fetches, and +# core.filesRefLockTimeout does not help, since nothing is waiting on a lock. A +# loser's retry finds the ref already at the tip and succeeds, so retry. +fetched= +for attempt in 1 2 3; do + if git -C "$repo_root" fetch --quiet "$remote" \ + "+refs/heads/$default_branch:refs/remotes/$remote/$default_branch" >&2; then + fetched=1 + break + fi + [ "$attempt" -lt 3 ] && sleep "$attempt" +done +if [ -z "$fetched" ]; then + echo "WorktreeCreate: fetching $remote/$default_branch failed; refusing to branch from a stale base" >&2 + exit 1 +fi +git -C "$repo_root" worktree add --no-track -B "worktree-$worktree_id" \ + "$worktree_dir" "refs/remotes/$remote/$default_branch" >&2 echo "$worktree_dir"