fix(source-control): validate git ref syntax in worktree-create.sh - #1333
Conversation
The helper's up-front name check enforces a character class — letters, digits, dots, underscores, dashes per `/`-separated segment — and a 64-char cap, then documented that a name failing validation is refused with usage exit 2. That class is not a subset of git's ref grammar. `feat/foo..bar`, `feat/.`, `.foo`, `foo.`, `foo.lock`, `HEAD`, and `-lead` all satisfy it yet git rejects them as refs, so they fell through to `git worktree add` and surfaced as environment exit 4 — the code reserved for "not a git repo, or git worktree add failed". A caller's correction flow keys on exit 2, so an invalid name was indistinguishable from a broken environment. Follow the schema check with `git check-ref-format --branch`. Both streams are discarded: on success `--branch` echoes the name to stdout, which would corrupt the helper's sole-stdout-line path contract. Verified per-name against a fresh fixture repo that `check-ref-format --branch` and `git worktree add -b` agree on every name the character class admits, so no previously-creatable name is newly refused. The sibling bare-repo-root item on the same issue needs no code change: it was fixed inside PR #898 itself by the `--is-inside-git-dir` probe, and `worktree-create.test.sh` already covers it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 19s —— View job PR Review —
|
|
Claude finished @kyle-sexton's task in 3m 7s —— View job Security Review — PR #1333
Review:
|
main bumped source-control to 0.26.3 while this branch was open, so both sides landed the same version literal and git auto-merged plugin.json to a single 0.26.3 with no conflict shown. Retarget this branch's bump to 0.26.4 and move its CHANGELOG entry above main's 0.26.3 section, which is preserved verbatim. Merge rather than rebase: the equivalent rebase is content-identical but would need a force-push, which this environment declines.
|
Claude finished @kyle-sexton's task in 3m 18s —— View job PR Review —
|
| Criterion | Result |
|---|---|
Injection ($name to shell/git) |
$name is double-quoted at all call sites, including git check-ref-format --branch "$name". No injection surface. ✓ |
| Secrets / credentials | None added. ✓ |
| Atomicity | The ref check exits before any worktree is created; creation delegates entirely to git worktree add. ✓ |
| Authorization / tenant scope | Not applicable (local shell helper). ✓ |
Code observations (no action required)
- stdout-purity test (
worktree-create.test.shline 288–290): correctly asserts that the sole stdout line is still the worktree path after thecheck-ref-formatcall. A forgotten>/dev/nullon line 128 would silently corrupt theEnterWorktree(path:)contract in a way no other assertion would catch — this test makes the suppression verifiable. - Version bump (
0.26.3→0.26.4): appropriate — 0.26.3 landed in main before this PR, requiring the bump to avoid a conflict. create.mdbullet (line 24): accurate and correctly placed.
Verdict
LGTM. The fix is correct: git check-ref-format --branch is the right oracle, both streams are suppressed for the right reasons, and the test matrix covers the exit-code contract, the error-message content, and the stdout-purity invariant. No blocking issues; one open suggestion from the prior security review documented above.
|
Claude finished @kyle-sexton's task in 2m 11s —— View job Security Review — PR #1333
Review:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2d82def69
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review caught that `git check-ref-format --branch` takes a branchname-shorthand and therefore performs repository discovery. Run unscoped it inherited the caller's CWD, so standing in a stale checkout — a `.git` file naming a gitdir that no longer exists, exactly what this plugin's worktree cleanup handles — made git exit 128 and turned a perfectly valid name into a false exit 2 with no worktree created. The documented invocation omits `--repo-dir`, so the CWD is the default and this was the real-world shape, not a corner case. Reproduced end-to-end, then moved the grammar check past the exit-4 repository probe and scoped it with `-C "$toplevel"`, so a healthy repo is guaranteed and a non-zero exit can only mean the name. Consequence: exits 3 and 4 may now precede an invalid-name exit 2. That matches the file's existing shape — the `--base-ref` and empty-slug exit-2 checks already sit after both. The character-class and length checks still run first, before any git call. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Compose both CHANGELOG sides: main's 0.26.5 casefold entry stays, this branch's ref-syntax entry moves to a new 0.26.6 section, manifest bumped to 0.26.6.
|
Claude finished @kyle-sexton's task in 2m 0s —— View job PR Review —
|
|
Claude finished @kyle-sexton's task in 2m 34s —— View job Security Review — PR #1333
Review:
|
This was generated by AI during an autonomous work-loop execution session.
Closes #1016
Summary
#1016 batched two P2 follow-ups deferred from PR #898's review. Only one of them is still live — the other landed inside #898 itself, which the reproduction below establishes before any code was changed.
Site 2 — git ref-syntax validation (the actual fix)
worktree-create.shvalidates--nameagainst theEnterWorktreeschema (a character class of letters, digits, dots, underscores, and dashes per/-separated segment, plus a 64-char cap) and documents that a name failing validation is refused with usage exit 2. A header comment asserted that class was "a strict subset of what git refs allow, so a validated name is always a creatable branch."That assertion is false. These names satisfy the class yet git rejects them as refs:
feat/foo..bar..feat/.,.foo.foo..foo.lock,feat/x.lock.locksuffixHEAD-leadReproduced against a throwaway fixture repo on the pre-fix helper — every one of them fell through to
git worktree addand returned environment exit 4, the code reserved for "not a git repo, orgit worktree addfailed". Since the caller's correction flow keys on exit 2, an invalid name was indistinguishable from a broken environment.Fix: follow the schema check with
git check-ref-format --branch, per the disposition's own fix direction. Three details are load-bearing:--branchechoes the name to stdout — leaving it would corrupt the helper's "created worktree path is the SOLE stdout line" output contract, which/worktree createparses to feedEnterWorktree(path:). A regression test asserts stdout is still exactly the path.--branchas repository-scoped because of its@{-n}previous-branch expansion;@,{, and}are already outside the character class, so that path is unreachable and the check is correct run from anywhere. Verified empirically outside any repo.git check-ref-format --branch --help(and-h,--normalize,--branch) exit 128 with "not a valid branch name" — git parses no further options after--branch, so no name is mistaken for a flag and nothing opens a pager.No over-rejection. Ran a matrix over 24 names with a fresh fixture repo per name (an earlier shared-repo matrix produced false discrepancies from cross-case contamination): for every name the character class admits,
check-ref-format --branchandgit worktree add -bagree exactly.feat/-lead,foo-,head,_x,a, andfeat/scope.v2_final-1all still create.The false "strict subset" comment is corrected, and the exit-code header,
--helptext, andskills/worktree/context/create.md's name-validation list are brought in line. Those two files are the only tracked surfaces restating the rules — checked, not assumed.Site 1 — bare-repo root detection (no code change; already fixed)
Per the Bug Investigation Rule this was reproduced first, and it does not reproduce. A root under a bare clone exits 3 today with "worktree target is inside a git directory".
Git history explains why:
81627ac("reject worktree roots inside a git directory") landed inside PR #898, after the bare-repo thread was dispositioned. It added the--is-inside-git-dirprobe, which returnstruefor both a.gitdirectory and a bare repository — exactly the fix direction #1016 asks for. That commit's own resolution comment says so: "This closes the.git-directory case here and, via the same--is-inside-git-dirmechanism, the sibling bare-clone gap deferred to #657 — #657 can drop the bare-repo item." The #657 → #1016 batch conversion carried the stale line forward anyway.worktree-create.test.shalready covers it (root inside a bare clone refuses exit 3), so there is no coverage gap to close either. Nothing to do beyond recording the finding.Note for reviewers
The
--name %q sanitizes to an empty slugguard is now unreachable: an empty slug requires a name of only-and/, which must start with/(fails the character class) or-(failscheck-ref-format). Left in place deliberately — it is cheap defense-in-depth whose reachability depends on check ordering, and removing it is outside this item's scope.Test plan
plugins/source-control/scripts/worktree-create.test.sh— 81 assertions pass, 0 fail (was 60). Because this script is load-bearing for every worktree the work-loop provisions, the whole suite was run, not just the new cases: normal creation, slug/path computation, base-reffresh/head,.worktreeincludecopying, and all existing containment guards are unchanged and green.shellcheckclean on both scripts (repo.shellcheckrc).scripts/check-changelog-parity.sh --checkand--check-bump origin/mainpass (version0.26.3→0.26.4with a matching entry).scripts/check-skill-portability.sh origin/main,scripts/check-silent-skips.sh, andmarkdownlint-cli2on both changed markdown files pass.Review round 1 — CWD discovery regression (fixed in
a286444)Codex caught a regression the ref check itself introduced, and it was the real-world shape, not a corner case.
git check-ref-format --branchtakes a branchname-shorthand and so performs repository discovery. Run unscoped it inherited the caller's CWD, so from a directory whose.gitnames a gitdir that no longer exists — a stale checkout, exactly what this plugin's own worktree cleanup handles — git exits 128 and a valid name was rejected with exit 2, creating nothing. The documented invocation inSKILL.md/context/create.mdomits--repo-dir, so the CWD is the default.Reproduced end-to-end before fixing: identical invocation exited 2 from the stale dir and 0 from a neutral one.
Fixed by scoping the check — with a refinement on the suggestion:
-C "$repo_dir"alone would still misreport whenrepo_diris the broken default, so the grammar check now runs after the exit-4 repository probe and uses-C "$toplevel". A healthy repository is then guaranteed, so a non-zero exit can only mean the name.Ordering consequence, stated plainly: exits 3 (root unconfigured) and 4 (not a repository) can now precede an invalid-name exit 2. This matches the file's existing shape rather than introducing a new one — the pre-existing
--base-refand empty-slug exit-2 checks already sat after both. The character-class and length checks still run first, before any git call.--help, the exit-code header, andcreate.mdsay so.Two regression tests added: a valid name from a stale-
.gitCWD exits 0 and prints the path; an invalid name from that same CWD still exits 2. Suite is now 81/81.Conflict resolution (2026-07-25)
main bumped
source-controlto0.26.3while this PR was open. Both sides landed the same version literal, so git auto-mergedplugin.jsonto a single0.26.3with no conflict reported — a silent collision that would have shipped two different releases under one version. Caught by re-reading the merged file; this branch is retargeted to0.26.4, with main's0.26.3CHANGELOG section preserved verbatim below this PR's entry.Integrated as a merge commit rather than a rebase. The rebase was done and verified first, but publishing it needed a force-push that this environment declines, so the content-equivalent merge was used instead — the merged tree is byte-identical to the verified rebased tree. Squash merge collapses the merge commit at land time.
No semantic overlap with main's incoming
worktreechanges: those add external-root convention prose and touch neither name validation nor the exit-code contract.Related
81627ac— the commit that already closed Site 1.