feat(source-control): landed-vs-stranded worktree detection - #1970
Conversation
## Summary
Adds `landed-work.sh`, a read-only classifier that answers one question per
worktree: if this checkout were removed and its branch deleted, would any
commit be lost? Nothing in the plugin could answer that before, and no git
hook can prevent a misplaced or abandoned worktree, so detection is the only
mechanism available.
The engine emits one TSV row per worktree — path, branch, head, unpushed,
landed, method, base, in-progress operation, four independent working-tree
counts, peers, risk, and reason. Prose in the `worktree` skill maps a row to
an operator disposition; the operator judges. The script itself never
removes, fetches, writes a ref, or touches the network.
**Fail-closed rule.** Only affirmative proof yields `landed=yes`. Every failed
command, empty result set, unresolvable base, and ambiguity yields `?`, which
a guard must treat exactly as it treats `no`. A false `no` costs a
confirmation prompt; a false `yes` destroys work.
**Method, each part measured rather than assumed.**
- The unpushed set is `HEAD --not --remotes`. `--branches` reports every other
branch in the repository and says nothing about a detached worktree's own
commits — the one case where removal makes commits unreachable immediately.
`@{upstream}..HEAD` silently returns nothing for a locally created branch.
- `landed` is decided by RANGE patch-id first. A squash-merge collapses N
commits into one patch, so no per-commit primitive — `git cherry` included —
can ever match it, while the branch's range id equals the squash commit's
exactly and stays in the base's per-commit id set as the base advances.
Range-vs-range does not work: the base's own range id moves, the branch's
does not.
- The path-scoped two-dot fallback is direction-tested and stamped with the
base SHA it was computed against, because on its own it decays to a false
`no` as the base advances over the same paths. Additions are what
discriminate: a branch that adds nothing the base lacks is behind, not
stranded.
- Not-a-worktree-root is probed with `rev-parse --show-prefix`, not
`--is-inside-work-tree`, which returns true for an empty leftover directory
inside a repository and reports the containing repository's clean state as
the husk's own.
- In-progress sequencer state is probed through `rev-parse --git-path`, which
is the only form that resolves the per-worktree vs common-dir split for a
linked worktree.
- The four working-tree counts are reported separately because a single
`dirty` number is not a work-at-risk signal: a paused merge inflates it with
that merge's own recomputable staged result.
`cygpath` is not used. The path-comparison key follows `worktree-create.sh`'s
recorded rejection and its remedy — a pure separator swap that defers all
resolution to existing machinery — extended with the drive-letter fold the
audit plugin's `path_key()` never needed, since both of its operands come from
one source while these come from git and the filesystem respectively.
A row-count assertion fails the run loudly when the emitted rows do not match
the enumerated worktrees. A short list read as "nothing at risk" is the one
failure mode a stranded-work detector cannot have.
## Test plan
`landed-work.test.sh` — 37 cases against throwaway bare-origin + clone
fixtures, no network. The four discriminating ones: a multi-commit
squash-merge classifies landed; it stays landed after the base advances over a
path the branch touched; a genuinely unmerged branch is not landed; and a
directory inside a repository is `notgit` rather than inheriting the
repository's state. The rest cover the detached-HEAD count, peers, the
behind-not-stranded direction test, superseded drafts, the paused merge, each
degradation path carrying a reason, and the row-count assertion's own failure.
Green: `landed-work.test.sh`, `check-shell-portability.sh`,
`check-silent-skips.sh`, `check-orphaned-fixtures.sh --check`,
`check-changelog-parity.sh --check` and `--check-bump`,
`check-cross-plugin-source-drift.sh`, `validate-plugins.sh`,
`validate-plugin-contracts.mjs`, `shellcheck --rcfile .shellcheckrc -x`,
`shfmt -d`.
## Related
First phase of the stranded-work detection work. Skill-surface changes
(status, cleanup, placement drift, evals, CHANGELOG) follow in later commits
and consume this record.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…tion verdict ## Summary Two gaps in the detection engine, both found by reading the row a real layout would produce rather than the one the fixtures produce. **A bare-clone hub was reported UNKNOWN.** Its own entry is the first row `git worktree list --porcelain` emits and it carries no `HEAD` line. A bare repository's `rev-parse --show-prefix` is empty, so the entry passed the work-tree-root probe, reached the landed computation, failed at `merge-base`, and surfaced as `risk=UNKNOWN` — which a guard treats exactly as it treats stranded work. The hub now short-circuits to `risk=bare` with a reason saying it holds no working tree to strand. The bare-clone hub is a layout the cleanup context already documents as supported, so this is a live shape, not a hypothetical one. **The EOL-renormalization case was named in the phase's checks and never written.** It is the case that justifies the two-dot fallback existing at all: a whitespace-only divergence is exactly where a naive line comparison reports work as stranded when it landed. `git patch-id` strips whitespace before hashing, so the verdict is reached before the fallback is needed — the test asserts the verdict rather than the route, so it keeps holding if the route changes. ## Test plan `landed-work.test.sh` — 40 cases, exit 0. Three added: the EOL divergence classifies landed, a bare hub is `risk=bare`, and a bare hub has no landed verdict to give. Green: `landed-work.test.sh`, `shellcheck --rcfile .shellcheckrc -x`, `shfmt -d`, `check-shell-portability.sh`. ## Related Follows the detection engine's first commit on this branch. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…k axis
## Summary
`status` and `cleanup` could tell you a worktree was old and quiet. Neither
could tell you whether removing it would destroy a commit — and those are
different questions with the same surface symptoms. Both actions now read the
detection engine's record.
**status** gains a `Work` axis, collected in a new data-collection step and
classified before the existing Status axis, because `stale` describes
attention while `stranded` describes loss. `merged` widens to "PR merged **or**
every unpushed commit landed on the base" — the branch's content is on the base
either way. `stale` narrows: it now requires Work to be `safe`, so a worktree
with unpushed unlanded commits is never presented as merely old. Three states
join the table (`stranded`, `superseded`, `notgit`), plus `unknown` for a
verdict the engine could not prove. `unknown` is treated exactly as `stranded`
throughout — the engine reports `?` rather than `no` precisely so that an
ambiguity is never read as safe. The `peers` column is consumed rather than
decorative: a stranded row whose commits survive in another worktree is a
materially different decision, and it is presented as one.
The degradation path is explicit and refuses a hand-rolled fallback: when the
engine cannot run, the column reads `unknown`, because `--branches` reports
other branches' commits, `@{upstream}..HEAD` returns nothing for a branch with
no upstream, and a per-commit patch-id cannot see a multi-commit squash-merge.
An unproven column is honest; a wrong one is not.
**cleanup** gets the guard at both places work actually dies. Removal is
recoverable — it leaves the branch ref intact — so the guard is stated at the
pre-removal site AND carried through to the `git branch -D` that Step 4c emits,
which is where the commits are destroyed one step later. A detached-HEAD
worktree is the exception the first site covers: it has no branch ref holding
its commits, so removal makes them unreachable immediately.
Both guards at the pre-removal site now have a stated order — stranded first,
because it can abort the removal outright and the carried-file reconciliation
would then be work spent on a worktree that is not going to be removed. The
override is `--acknowledge-stranded`, per worktree; `--force` answers git's
dirty-tree check, which is a different question, and one flag must not silently
answer both.
The escalation guard's unpushed probe moves from `--branches` to `HEAD`. On a
detached HEAD — the one case where removal loses commits immediately —
`--branches` reports every other branch in the repository and nothing about this
worktree's own commits, so the guard read clean at exactly the moment it
mattered most.
Every path offers the non-destructive resolution first: pushing the branch makes
the commits durable and reclassifies the row without anyone having to judge
whether the work matters.
## Test plan
Sanity checks from the phase, run against the edited files:
- `grep -c -- '--branches --not --remotes' context/cleanup.md` = 0
- `grep -c -- 'HEAD --not --remotes' context/cleanup.md` = 2 (pre-removal guard,
escalation guard). Step 4c's precondition reads the collected record rather
than re-probing, so it states the condition without repeating the idiom.
- `grep -n 'landed' context/status.md` matches inside the classification
conditions, not only in prose.
Green: `markdownlint-cli2 --config .markdownlint-cli2.jsonc`,
`check-skill-portability.sh --paths`, `check-changed-skills.sh origin/main`
(PASS, 0 errors), `check-contract-slice-prune.sh --check`,
`check-skill-leaf-names.sh`, `validate-plugins.sh`,
`validate-plugin-contracts.mjs`.
## Related
Consumes the detection engine added earlier on this branch. Placement-drift
detection, evals, and the CHANGELOG follow.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
## Summary Closes the remaining two phases of the stranded-work work: the placement-drift findings in `repo-fleet-hygiene:audit`, and the surface pass across both plugins. **repo-fleet-hygiene:audit gains three worktree findings.** They are computed natively in the worktree loop rather than copied from the source-control engine: a copy at a different relative path is invisible to `check-cross-plugin-source-drift.sh`, which is the same argument that kept the engine in one plugin. `worktree-not-a-root` (HIGH) fires when a registered path exists but `rev-parse --show-prefix` is non-empty — the path is a subdirectory of a work tree rather than its root, so every `git -C` probe of it answers with the CONTAINING repository's state at exit 0. That is indistinguishable from a healthy clean worktree, and it is exactly how a leftover directory reads as safe to remove. `worktree-root-unverifiable` (UNKNOWN) covers that probe failing; both stop worktree classification for the registration instead of describing the wrong repository. `worktree-nested-in-repository` (MEDIUM) reports a non-main registration rooted inside the canonical checkout's own working tree rather than at an external root. Two supporting changes. `rev-parse --show-prefix` joins the probe allowlist, matching `--show-toplevel`'s shape — read-only, operand-free, fixed arity. And the containment test resolves the canonical checkout through git rather than reusing the discovered path, so both operands come from one source: a filesystem-derived path and a git-emitted one differ by drive spelling on Windows, and `path_key()` normalizes separators and case but not the drive form, so the comparison would have silently never matched. **Surfaces.** Both plugins' SKILL.md action prose, the audit plugin's confidence-model tier table (whose set equality against the collector's emitted kinds is itself gated), four evals covering the phase's success criteria, and a minor bump plus CHANGELOG entry for each plugin touched — source-control 0.46.2 to 0.47.0, repo-fleet-hygiene 0.8.1 to 0.9.0. The `worktree` SKILL.md frontmatter is deliberately unchanged: the skill declares no `allowed-tools`, and adding one would convert it from "no restriction" to an allowlist. ## Test plan `audit-fleet.test.sh` — 84 cases, exit 0. Two fixtures added to the mock under one existing repository, so no repository count changes: a real work-tree root nested inside the canonical checkout, and a registered path that is a plain subdirectory. The assertions check the Target lines WITHIN each finding block rather than the whole file — a whole-file match would be satisfied by the other finding's block and prove nothing, which is how the first version of them passed while measuring nothing. Green: `audit-fleet.test.sh`, `check-changelog-parity.sh --check` and `--check-bump origin/main`, `validate-plugin-contracts.mjs`, `validate-plugins.sh`, `markdownlint-cli2`, `shellcheck --rcfile .shellcheckrc -x`, `shfmt -d`, `check-shell-portability.sh --paths`. ## Related Completes the W1 phases on this branch: the detection engine, the status and cleanup surfaces, placement drift, and the surface pass. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ire two dead citations
## Summary
The `worktree` skill's nesting-invariant paragraph is the canonical statement of
why worktrees live at an external root. Two things in it had gone stale, and one
of them would have sent a reader to closed issues to check whether the invariant
still holds.
**Evidence.** The as-of stamp moves from 2.1.220 / 2026-07-31 to 2.1.224 /
2026-08-07, and three control arms are added that narrow what the invariant
actually rests on. The leak is **not** specific to `.claude/worktrees/`: a
worktree at a plain non-dot subdirectory leaks identically, so nesting inside the
parent's tree is the cause and a dot-prefixed directory buys nothing. A worktree
nested inside an **unrelated** repository is worse rather than better — it
inherits `CLAUDE.md` and unconditional rules at `session_start` as well as scoped
rules. The mechanism behind that asymmetry is stated because the convention rests
on it: session-start ancestor traversal is suppressed for ancestors of the
worktree's own repository but not for a different one, while `path_glob_match`
discovery is suppressed in neither.
**Citations.** The recheck trigger named #29599 and #23565. Both are CLOSED —
verified live against the GitHub API rather than carried from notes: #29599 is
labeled `duplicate` and closed COMPLETED (it reported the eager double-load that
v2.1.69 fixed), #23565 closed NOT_PLANNED. The trigger now names #16600, which is
OPEN and labeled `enhancement` / `memory`, and states the gap that leaves rather
than papering over it: #16600 concerns memory files, which 2.1.224 already
handles correctly, so the surface still leaking — path-scoped rules — has no open
upstream issue at all. Both dead numbers survive in exactly one place, the
sentence explaining that they are retired.
`context/create.md` carried the same two citations inline. It now points at the
skill's paragraph instead of restating them, so the upstream state lives in one
place and cannot drift between the two.
**Also fixed here:** the `audit` action's closed enumeration of what Step 1 flags
went stale under this branch's own change — it listed stale / merged-not-cleaned
/ prunable and missed every Work-axis class, including the stranded one the whole
workstream exists for. Its findings presentation now leads with stranded and
unproven counts, reported even when zero: a class that appears only when non-zero
cannot be told apart from one that was never measured.
## Test plan
- Issue states read live: `gh issue view {16600,29599,23565} --repo
anthropics/claude-code --json state,stateReason,labels`.
- Version claim read live: `claude --version` reports 2.1.224.
- `grep -rn '29599\|23565' plugins/source-control/ --include=*.md` outside the
CHANGELOG returns only the SKILL.md sentence that documents their retirement.
Green: `check-changed-skills.sh origin/main`, `markdownlint-cli2`,
`check-changelog-parity.sh --check-bump origin/main`,
`validate-plugin-contracts.mjs`.
## Related
W2-P2 of the stranded-work work. Folded onto this branch rather than the dotfiles
branch the plan named: these are edits to the same skill the rest of the branch
touches, and only the dotfiles doc's mechanism-correction sentence belongs in the
other repository.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 6m 26s —— View job Code reviewReviewed the diff against The architecture is sound: fail-closed by construction ( 🔴 Important —
|
|
Claude finished @kyle-sexton's task in 2m 17s —— View job Security review — no findingsTodo list
This PR was scoped to the 16 files changed vs No security issues found. Notes from the review (informational, not findings):
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2770bcf681
ℹ️ 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".
|
Claude has reviewed this PR 5 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…treeCreate gate
## Summary
An independent cross-vendor review of the detection engine returned ten findings.
Eight were real; all eight were in the direction that loses work. The engine's
whole value is that `landed=yes` is never wrong, so every one is fixed here
rather than filed.
**A delete-only branch classified as landed.** `git diff base..HEAD` reports
deletions both for a branch that is merely BEHIND the base and for a branch whose
own unique work IS a deletion — the numstat rows are byte-identical. The
direction test read "additions are zero" as proof of the first and so classified
the second as landed, while its deletion commit existed nowhere else. The
direction test is removed rather than repaired: the behind-the-base shapes it was
written for are already caught by the range patch-id, so nothing needs to replace
it, and the fallback now answers only whether the touched paths differ from the
base at all.
**`git patch-id` hashed two different contents alike.** The default and
`--stable` strip whitespace before hashing, so `a b` and `ab` produce one id —
reproduced on git 2.54, both `7ad14294…`. A branch whose unique change differed
from the base's only in whitespace classified as landed. Ids are now computed
`--verbatim`, verified in a fixture to still match a multi-commit squash and to
still match after the base advances. The cost is real and asserted rather than
hidden: an EOL-renormalized branch no longer matches and reports `no`. That is a
confirmation prompt in exchange for a silent deletion.
**Two diffs disagreed about path quoting.** `--name-only` ran with git's default
`core.quotePath=true` while `--numstat` was pinned to false, so a path with a
non-ASCII byte appeared in two spellings, joined against nothing, and produced
`matched=0` — which this function reads as "identical to the base". Both are
pinned now, and both take `-z`, since a path may also contain a newline.
**An incomplete patch-id set spoke for the branch.** A commit that produces no
patch — an empty commit among them — is invisible to patch-id. The set was only
checked for being non-empty, so "every id is on the base" was a statement about
the commits that happened to hash. The id count must now equal the non-merge
commit count before any affirmative verdict.
**Failures produced the favourable answer.** `comm`'s exit status was never
checked, and a failed `comm` emits empty stdout — the exact shape that means
"every branch id is on the base". The numstat reducer's result was read into a
variable that, left empty by a failed `awk`, compared numerically as zero, which
means "no touched path differs". `git status` failing left all four counts at
their zero initialisation, which is indistinguishable from a clean tree, so an
unreadable index could report `risk=ok`. Each is now checked explicitly and
degrades to `?`.
**A truncated enumeration passed the row-count assertion.** The worktree list was
streamed through a process substitution whose exit status the loop cannot see, so
an enumeration that failed halfway produced a short list that every downstream
count — the assertion included — then agreed with. The assertion can only catch a
truncated PASS; a truncated ENUMERATION has to be caught where it happens. It is
now captured to a file and status-checked before parsing.
**An ambiguous base was silently disambiguated.** `refs/tags/release` and
`refs/heads/release` can both exist, and a criss-cross history has more than one
merge base. Both were resolved by silently taking one, which means testing
against a history the work did not diverge at. Both now yield `?`.
Two of the ten findings were reachable only by exporting shell-function overrides
into the script's environment, which is not a threat model this can defend
against — but the underlying unchecked exit statuses were real on their own
terms, and those are among the fixes above.
**Also added: `worktree-create-gate`, the `WorktreeCreate` hook.** It was
deferred pending two unanswered questions about the event. Both were measured, so
it ships. A user-scope hook does fire — verified with a settings.json under a
`CLAUDE_CONFIG_DIR`, headless, before login was even resolved — and
`${CLAUDE_PROJECT_DIR}` resolves to the project root the session started in,
never the worktree being created. Stdout's last non-empty line is taken as the
path: a hook printing a banner before the path still succeeds, refuting the claim
that any other output fails the session. The hook covers the three creation paths
that bypass `/worktree create` entirely — `claude --worktree`, a subagent with
`isolation: "worktree"`, and a background session — by delegating to the same
helper the skill uses.
## Test plan
`landed-work.test.sh` — 46 cases, exit 0. Five added, each of which fails against
the previous implementation: a delete-only branch is not landed; a
whitespace-only difference is not landed; an empty commit makes the id set
incomplete and yields `?`; a criss-cross history never yields an affirmative
verdict; and the EOL case now asserts the `--verbatim` trade rather than the old
tolerance. The fixtures pin `core.autocrlf=false`, because the Windows default
normalizes CRLF into the object store and the EOL case would otherwise have
passed for a reason unrelated to the classifier.
`worktree-create-gate.test.sh` — 20 cases, exit 0: placement under a configured
root, the plugin-data fallback, an unexpanded placeholder treated as unset, four
refusals that fail creation rather than falling through, the disabled path, and a
payload reader that is order-independent. The no-root refusal runs under
`env -u CLAUDE_PLUGIN_DATA` deliberately — in a general subprocess that variable
is not scoped to the invoking plugin (this machine's names an unrelated one), so
an inherited value would have made the case unfalsifiable.
Green: `shellcheck --rcfile .shellcheckrc -x`, `shfmt -d`,
`check-shell-portability.sh --paths`, `check-silent-skips.sh`,
`check-hook-userconfig-argv.sh`, `check-changelog-parity.sh --check-bump`,
`validate-plugins.sh`, `validate-plugin-contracts.mjs`, `markdownlint-cli2`.
## Related
Hardens the engine added earlier on this branch, and closes W2-P3, which the plan
had deferred as blocked.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 9s —— View job Security reviewScope: files changed in this PR vs
🔴 IMPORTANT —
|
|
Claude finished @kyle-sexton's task in 4m 59s —— View job Code review
Scoped to what changed since the last full review (commit The eight fixes in 🔴 Not fixed —
|
## Summary Tab is IFS whitespace, and bash's `read` collapses a run of IFS whitespace into one separator. A row with an empty `base`, `peers`, or `reason` therefore shifted every later column left for any consumer using `while IFS=$'\t' read` — which is the shape this plugin's own prose tells callers to use. The reader got the reason string under the name `risk`, silently and with no error anywhere. Found by consuming the output that way while sweeping the fleet: thirteen checkouts reported a blank risk column and one reported `risk=nothing-unpushed`, which is a reason value. The engine was right and the format was wrong. Every field now emits `-` when it has no value, and the header contract says so. ## Test plan `landed-work.test.sh` — 49 cases, exit 0. Three added, and they fail against the previous output: a `while IFS=$'\t' read` consumer lands on the risk column, lands on the reason column, and no row contains two adjacent tabs. Re-swept the fleet after the fix: 14 worktree rows across 13 checkouts parse correctly. Green: `shellcheck --rcfile .shellcheckrc -x`, `shfmt -d`. ## Related Follows the engine hardening earlier on this branch. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 28s —— View job Code review
Scoped to what changed since the last full review round: commit The stated goal of 🔴 One field was missed —
|
|
Claude finished @kyle-sexton's task in 1m 0s —— View job Security reviewScope: files changed in this PR vs
🔴 IMPORTANT —
|
## Summary Four independent reviewers reported after the previous round. Every finding is acted on here; none is deferred. **The quoting fix was incomplete (engine review, MEDIUM).** Pinning `core.quotepath=false` on both diffs closed the non-ASCII byte class but not the one git escapes regardless of that setting — `"`, `\`, and control characters, which only `-z` suppresses. The same representation mismatch, a narrower trigger, the same outcome: an empty join read as "identical to the base", an unproven `landed=yes`. Not reproducible on this machine (git-for-windows `core.protectNTFS` defaults true and refuses such paths even through plumbing) but live on Linux and macOS, and grounded in git's own documentation rather than a local repro — the reviewer flagged that distinction and it is worth preserving. Rather than chase escaping rules one byte class at a time, the touched paths are now handed BACK to git as `:(literal)` pathspecs and git does its own matching. That removes the whole mismatch class instead of the current member of it. `:(literal)` because a path is not a pattern: a file named `star[1].txt` or one beginning with `:` would otherwise be read as pathspec magic and match something else. Chunked at 200 so a branch touching thousands of files cannot exceed the platform's command-line limit and surface as an ordinary probe failure. `git diff --pathspec-from-file` was tried first and is not supported (exit 129 on git 2.54); the array form is what works. **The removal guard's dichotomy did not cover every risk value (prose review, HIGH).** `cleanup.md` enumerated `landed`/`ok`/`bare`/`superseded` → proceed and `STRANDED`/`UNKNOWN` → stop, while the engine emits nine values. `in-progress` and `dirty` were in neither branch, so an agent had no instruction for a paused merge or for uncommitted edits — and could read the silence either way. Worse since the last round: `status_counts` now reports `?` when the working tree cannot be read at all, and that routes into `dirty` too, so `dirty` means both "ordinary local edits" and "we could not look". Both now stop, with the reason stated, and **any unlisted value maps to STRANDED** — the list is closed on the safe side only. `status.md`'s Work-axis table gains the same two rows and the same default. **The `-` placeholder was undocumented (prose review).** `cleanup.md` instructs the agent to present the `base` stamp; for a row whose landedness failed before a base resolved, that is now the literal `-`, which reads as broken output rather than "no base was resolved". Both files now say to render `-` as "not resolved". **A bare hub silently skipped the placement check (audit review).** `--show-toplevel` fails on a bare repository by design, so `worktree-nested-in-repository` was never evaluated for any registration under a bare hub and nothing said so — indistinguishable from a check that ran and found nothing. A bare hub is now recognized as such (no working tree, so nothing to be nested inside — a legitimate skip) and any other failure emits `worktree-placement-unverifiable` (UNKNOWN), matching what every sibling probe in that function already does. **`worktree-root-unverifiable` had no test coverage (audit review, MODERATE).** The mock's `--show-prefix` arm succeeded for every input, so the collector's probe-failure branch was dead code as far as the suite was concerned. A fixture now fails that probe. **One handoff row overstated its own evidence (both reviewers).** It claimed "every `git -C` probe describes the containing repository" for `worktree-not-a-root` AND `worktree-root-unverifiable`. That holds only for the former; the latter's probe FAILED, so root-ness is unproven rather than disproven — which `confidence-model.md` already said correctly. Separate rows now. **Base-side completeness (engine review, LOW, inert).** The branch side requires count parity before any affirmative verdict; the base side only checked non-emptiness. Confirmed by the reviewer as unable to produce a false `yes` — an under-complete base set only makes matches less likely — so this is symmetry rather than a hole, and it is here so the two sides cannot diverge under a later refactor. ## Test plan - `landed-work.test.sh` — **52 cases, exit 0.** Three added: a non-ASCII filename, a filename containing a glob metacharacter, and one beginning with `:` all classify STRANDED, and the verdict is not the vacuous empty-match one. The reviewer reproduced the non-ASCII case as a live false `landed=yes` against the previous code, so this is a genuine regression test. - `audit-fleet.test.sh` — **86 cases, exit 0.** The tier-table drift gate caught the new `worktree-placement-unverifiable` kind before it could ship undocumented, which is exactly its purpose. - `worktree-create-gate.test.sh` — 20 cases, exit 0. - Green: `shellcheck --rcfile .shellcheckrc -x`, `shfmt -d`, `check-shell-portability.sh --paths`, `check-skill-portability.sh --paths`, `markdownlint-cli2`, `check-changelog-parity.sh --check-bump origin/main`, `validate-plugins.sh`, `validate-plugin-contracts.mjs`. ## Related Closes the independent-review round on this branch. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 31s —— View job Code review
Scoped to what changed since the last full review round: commit This commit is good work and closes real gaps: the quotepath fix's remaining escaping gap ( 🔴 Still open —
|
|
Claude finished @kyle-sexton's task in 3m 17s —— View job Security reviewScope: files changed in this PR vs
🔴 IMPORTANT —
|
The hygiene gate's spell-check flagged `unparseable`; the accepted spelling is `unparsable`. It appears in a `reason` column value, which is operator-facing output rather than an internal token. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 17s —— View job Code review
Scoped to what changed since the last full code-review round (commit - L_REASON="touched-paths-unparseable"
+ L_REASON="touched-paths-unparsable"This is correct and harmless — it aligns Still open — carried forward from prior review rounds, unchanged by this commit
No other files changed since Recommend fixing the |
|
Claude finished @kyle-sexton's task in 39s —— View job Security reviewScope: files changed in this PR vs
|
The merged-refs evidence matches on the branch NAME, so a name reused after that pull request merged still matches. Such a row carries new commits, is `landed=no`, and was on the safe side of both destructive gates: removal proceeded without confirmation and `git branch -D` was emitted for it, which is the only copy of that work. `superseded` stays as a label — it narrows the reading of a `landed=no` row — but it no longer authorizes anything. Consumers gate it exactly as `STRANDED`, and the Status axis folds it into `stranded`. Proving the stronger claim needs the merged pull request's head revision, which is unavailable to an offline classifier once the remote ref is deleted on merge. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 14s —— View job Security reviewScope: files changed in this PR vs
✅ RESOLVED —
|
Summary
A worktree that is old, quiet, and clean looks exactly like a worktree holding four unpushed commits that exist nowhere else. Nothing in this repo could tell those apart, and
/worktree cleanupwas happy to remove either — then emit agit branch -Dthat finished the job. This PR is the detection that separates them, the guards that consume it, and the hook that stops the placement problem at its source.The engine —
plugins/source-control/scripts/landed-work.sh, read-only, no network. One TSV row per registered worktree:unpushed,landed, the method and base SHA the verdict was reached with, the in-progress sequencer operation, four independent working-tree counts, peer worktrees, a risk class, and a reason.Only affirmative proof yields
landed=yes. Every failed command, empty result set, unresolvable base, and ambiguity yields?, and every consumer treats?exactly asno. A falsenocosts a confirmation prompt; a falseyesdestroys work.Each method choice is a measured rejection of an obvious alternative:
HEAD --not --remotes.--branchesreports every other branch in the repository and nothing about a detached worktree's own commits — the one case where removal is immediately terminal.@{upstream}..HEADreturns nothing at all for a locally created branch, which described all 17 worktrees this work started from.git cherryincluded — can ever match it, while the branch's range id equals the squash commit's and stays matched as the base advances.--verbatim. The default and--stablehash the patch after stripping whitespace, soa bandabproduce one id (git 2.54: both7ad14294…). The cost is that an EOL-renormalized branch no longer matches and reportsno— a confirmation prompt in exchange for a silent deletion.git diff base..HEADreports deletions both for a branch merely BEHIND the base and for a branch whose own unique work IS a deletion; the numstat rows are identical, so "additions are zero" classified a delete-only branch as landed.rev-parse --show-prefix.--is-inside-work-treereturns true for a leftover directory inside a repository and reports that repository's clean state as the directory's own.rev-parse --git-path, the only form that resolves the per-worktree vs common-dir split for a linked worktree.-for absent). Tab is IFS whitespace, so an empty field shifts every later column left for a tab-splittingreadconsumer — the shape this plugin's own prose tells callers to use.cygpathis not used — the path key followsworktree-create.sh:247's recorded rejection and its remedy, extended with the drive-letter fold thatpath_key()in the audit plugin never needed because both of its operands come from one source.The consumers.
statusgains a Work axis classified ahead of Status, so a worktree with unpushed unlanded commits isstrandedrather than merelystale, and the summary names the at-risk commit total.cleanupguards both places work dies — the pre-removal site AND thegit branch -Dit emits one step later, which is where commits actually die since removal leaves the branch ref intact. Two pre-removal guards with a stated order,--acknowledge-strandedper worktree rather than a bare--forceanswering a different question, and every path offersgit -C <path> push -u origin HEADfirst.The hook.
worktree-create-gatecovers the three creation paths that bypass/worktree createentirely —claude --worktree, a subagent withisolation: "worktree", and a background session — by delegating to the same helper. It was deferred pending two unanswered questions about theWorktreeCreateevent; both were measured, so it ships. A user-scope hook does fire (settings.json under aCLAUDE_CONFIG_DIR, headless, before login was even resolved),${CLAUDE_PROJECT_DIR}resolves to the project root the session started in rather than the worktree being created, and stdout's last non-empty line is taken as the path — a banner line before the path still succeeds, refuting the claim that any other output fails the session.repo-fleet-hygiene:auditgains three findings computed natively rather than copied:worktree-not-a-root,worktree-root-unverifiable, andworktree-nested-in-repository.Also fixed: the nesting invariant's as-of stamp was 2.1.220 / 2026-07-31 and its recheck trigger cited two issues that are both CLOSED (verified live: #29599
duplicate/COMPLETED, #23565 NOT_PLANNED). It now names #16600 (OPEN) and states the gap that leaves — #16600 concerns memory files, which 2.1.224 already handles correctly, so the surface still leaking has no open upstream issue.Independent review
Routed to four independent reviewers — three fresh-context, one cross-vendor (Codex) — over two rounds. Between them they returned eighteen findings; every real one is fixed in this PR rather than filed. The Codex pass alone found eight, all in the direction that loses work:
landedpatch-id --stablestrips whitespacea bandabhashed alike → falselanded--name-onlyand--numstatdisagreed oncore.quotePathmatched=0→ falselandedcommexit status uncheckedcommemits empty stdout — the "all landed" shapelandedgit statusexit status uncheckedTwo further findings were reachable only by exporting shell-function overrides into the script's environment — not a defensible threat model — but the underlying unchecked exit statuses were real on their own terms and are among the fixes above.
The second round, run against the already-fixed code, found more:
",\, and control characters are escaped regardless ofcore.quotePathlanded, on a narrower character class. Live on Linux/macOS; not reproducible on Windows, wherecore.protectNTFSrefuses such pathscleanup.md's guard covered 4 of 9 risk values;status.md's table covered 7 of 9in-progressordirtyand could read the silence either way-placeholder was undocumentedbasestamp" would show a literal-as the base--show-toplevelfails by designworktree-root-unverifiablehad no testworktree-root-unverifiableproves what onlyworktree-not-a-rootprovesno— but asymmetric with the branch sideThe first of those retired the text-matching approach entirely: the touched paths are now handed back to git as
:(literal)pathspecs so git does its own matching, which removes the whole escaping-mismatch class rather than its current member.Test plan
landed-work.test.sh— 52 cases, exit 0. The discriminating ones: a multi-commit squash classifies landed and stays landed after the base advances; a genuinely unmerged branch does not; a delete-only branch does not; a whitespace-only difference does not; an empty commit yields?; a criss-cross history never yields an affirmative verdict; a directory inside a repository isnotgit; a bare hub isbare; a non-ASCII filename, one containing a glob metacharacter, and one beginning with:all classify STRANDED; and a tab-splittingreadconsumer lands on the columns it names. Fixtures pincore.autocrlf=false, because the Windows default normalizes CRLF into the object store and the EOL case would otherwise have passed for a reason unrelated to the classifier.worktree-create-gate.test.sh— 20 cases, exit 0.audit-fleet.test.sh— 86 cases, exit 0. Its tier-table drift gate caught the newworktree-placement-unverifiablekind before it could ship undocumented.source-controlsuite —pr-body-linkage-gate,pr-linkage-mcp-gate,babysit-wrapper-help,babysit-readiness-gate,fetch-all-pr-comments,worktree-create— all exit 0.check-shell-portability.sh --paths,check-silent-skips.sh,check-hook-userconfig-argv.sh,check-orphaned-fixtures.sh --check,check-cross-plugin-source-drift.sh,check-contract-slice-prune.sh --check,check-skill-leaf-names.sh,check-skill-portability.sh --paths,check-changed-skills.sh origin/main(both skills PASS, 0 errors),check-changelog-parity.sh --checkand--check-bump origin/main,validate-plugins.sh,validate-plugin-contracts.mjs,markdownlint-cli2,shellcheck --rcfile .shellcheckrc -x,shfmt -d.plugin-gate(the whole plugin suite) included.Three pre-existing failures on clean
main, none from this branch —claude-ops/.../fleet-state.test.sh,ruff-format/hooks/ruff-format.test.sh, andbabysit-prs/scripts/engine.test.sh(597 unit tests OK; the exit code comes from its ruff lint pass).git diff --name-only origin/main...HEADconfirms this branch touches none of those files, and each was reproduced in isolation. Filed as #1972 rather than folded in here.Related
Closes #1977.
Implements every phase of the stranded-work plan: the detection engine, the
statusandcleanupguards, placement drift, the surface pass, the corrected nesting-invariant citations, and theWorktreeCreatehook the plan had deferred as blocked.The dotfiles half — granting the worktree root, retiring
~/.claude-loop-worktrees, the Codex pointer, the Cursor density capture — is melodic-software/dotfiles#410 and needs a human-run deploy step.Filed alongside: melodic-software/standards#334 (a
git restore <path>deny asymmetry), #1971 (whether the path-scoped rule leak deserves an upstream issue), #1972 (the three red suites on main), #1976 (the 52stranded/*quarantine refs — 50 of which hold content that exists nowhere else).