fix(FN-5456): tighten branch-autocorrect verify and add real-git regression

Follow-up to a3ec2e55c addressing code-review feedback:

- Restrict the existence check to local heads via
  `git show-ref --verify --quiet refs/heads/<name>` so a stray tag or
  remote ref with the same name cannot satisfy the check and lead the
  subsequent `git checkout` to a detached HEAD on the wrong object.
- Use `git branch -M` (force) instead of `-m` for the rename path so
  case-only renames succeed on case-insensitive filesystems (macOS,
  default Windows).
- Document the FN-5456 invariant on the function's doc comment.
- Add a real-git regression suite covering the three paths the fix
  governs: no creation when expected ref is missing, switch-only when
  it exists, and rejection of same-named tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-22 21:40:40 -07:00
parent a3ec2e55c8
commit bdfe620fab
3 changed files with 149 additions and 7 deletions

View File

@@ -40,6 +40,12 @@ async function runGit(command: string, worktreePath: string): Promise<{ ok: true
/**
* FN-4474: Recover wrong-branch invariant failures by renaming/checking out
* the expected branch without throwing terminal executor errors.
*
* FN-5456 invariant: this function MUST NOT create a branch from arbitrary
* HEAD. It may only rename an existing branch or switch to an already-existing
* expected ref; if neither applies it returns `failed` so upstream recovery
* (which knows the proper base SHA) can re-anchor via `prepareForTask` /
* `reanchorBranchToBase`.
*/
export async function attemptBranchAutocorrect({
worktreePath,
@@ -76,7 +82,9 @@ export async function attemptBranchAutocorrect({
}
if (isFreshBranch) {
const rename = await runGit(`git branch -m ${observedArg} ${expectedArg}`, worktreePath);
// `-M` (force) lets us recover from case-only renames on case-insensitive
// filesystems (macOS, default Windows) where `-m` rejects `foo` → `Foo`.
const rename = await runGit(`git branch -M ${observedArg} ${expectedArg}`, worktreePath);
if (rename.ok) {
return { status: "renamed" };
}
@@ -85,10 +93,13 @@ export async function attemptBranchAutocorrect({
// FN-5456: must NOT use `git checkout -B` with no start point — that would
// create (or reset) the expected branch at whatever HEAD currently is,
// capturing the previous occupant's tip (the "branch: Created from HEAD"
// contamination pattern). Plain `git checkout` only switches to an existing
// ref; if `expected` doesn't already exist the caller will surface a
// wrong-branch failure with proper base resolution upstream.
const verifyExpected = await runGit(`git rev-parse --verify --quiet ${expectedArg}`, worktreePath);
// contamination pattern). Restrict the verify to refs/heads/ so a stray tag
// or remote ref with the same name cannot satisfy the check and lead the
// subsequent `git checkout` to a detached HEAD on the wrong object.
const verifyExpected = await runGit(
`git show-ref --verify --quiet refs/heads/${expectedArg}`,
worktreePath,
);
if (!verifyExpected.ok) {
return { status: "failed", reason: `expected branch ${expected} does not exist` };
}