fix(audit-git-checkouts): audits should never lose commits that only a submodule holds #78

Merged
jercik merged 7 commits from docs/audit-mode-submodule-writes into main 2026-09-25 06:44:53 +00:00
Owner

A default audit no longer destroys commits that live only inside a submodule. Before this, a submodule move could strand a detached commit, a configured-tag fetch could overwrite a local tag, and removing a linked worktree deleted its submodules' Git directories. The reviews on this PR reproduced all three.

Before a fast-forward or selector move, the driver walks every populated submodule at any depth. It finds them from the Gitlinks, so .gitmodules edits and ignore settings can't hide one. A checkout with a submodule on a commit nothing else holds is left alone and reported as a decision (new strandedSubmodules field). Removal keeps a worktree whose submodules hold uncommitted files, stashes, or commits that no remote-tracking ref holds. The new outcome is judgment/submodule-local-work. A submodule the driver cannot read or query blocks updates and removal, so it is never assumed empty.

The report schema moves to version 6. The mode table now lists every write a default audit makes, and a condition sends agents to --no-fetch --no-remove when submodules must stay put.

Deferred from #77 review round 4.

🤖 Generated with Claude Code

A default audit no longer destroys commits that live only inside a submodule. Before this, a submodule move could strand a detached commit, a configured-tag fetch could overwrite a local tag, and removing a linked worktree deleted its submodules' Git directories. The reviews on this PR reproduced all three. Before a fast-forward or selector move, the driver walks every populated submodule at any depth. It finds them from the Gitlinks, so `.gitmodules` edits and `ignore` settings can't hide one. A checkout with a submodule on a commit nothing else holds is left alone and reported as a decision (new `strandedSubmodules` field). Removal keeps a worktree whose submodules hold uncommitted files, stashes, or commits that no remote-tracking ref holds. The new outcome is `judgment/submodule-local-work`. A submodule the driver cannot read or query blocks updates and removal, so it is never assumed empty. The report schema moves to version 6. The mode table now lists every write a default audit makes, and a condition sends agents to `--no-fetch --no-remove` when submodules must stay put. Deferred from #77 review round 4. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
docs(audit-git-checkouts): name the submodule writes a default audit makes
All checks were successful
commit-msg / commitlint (pull_request) Successful in 23s
Node tests / node:test (pull_request) Successful in 45s
Review / Review (pull_request_target) Successful in 11m25s
3c07d51c9f
The mode table listed four writes but omitted submodule sync after a
fast-forward and first-party selector moves, which leave unstaged Gitlink
changes even without a fast-forward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Review 01M3BJ3S1CY1F1S8BW4PD0P9FC — head 0cdf0815fc1f001c6c097c960f3b7803512d4081

Review — j4k-oss/agent-skills @ d581670616

Scope: diff against base tree d1e95321d499
Status: dispatched — coverage complete (3/3 slots terminal)
Facts: current review-wide projection

Computed under:

{
  "abandonment": "abandonment-v1",
  "anchor_recipe": 1,
  "batch_policy": "batch-v1",
  "coverage": "coverage-v3",
  "dispatch_policy": "dispatch-v2",
  "grounder_version": 1,
  "grounding_read_rule": "grounding-read-v1",
  "promotion_policy": "promotion-v1",
  "report": "report-v3",
  "tally": "tally-v1",
  "triage_settle": "triage-settle-v2"
}

Findings (15)

medium — "moves branch- or tag-tracked submodules when nothing fast-forwards" understates when selector moves run: they also run right after a successful fast-forward

  • claim: 01M3BJAY8E33N13G2MRZKFY5X3
  • anchor: skills/audit-git-checkouts/SKILL.md (snippet)
In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and `update = none` stops only that move.
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the new bullet under "Choose the mode" in skills/audit-git-checkouts/SKILL.md, the tracking_update_eligible gate in scripts/audit-checkouts.sh, and the matching paragraph in references/checkout-updates.md.

What the subject says: the bullet tells a user whose submodules must stay put to pass --no-fetch --no-remove, and describes what the default mode would otherwise do: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards".

What the driver does: in audit_worktree, the selector move is gated on

&& { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } \

which is true both when no fast-forward was attempted and when one was attempted and succeeded. synchronize_first_party_tracking_submodules then runs and checks each branch- or tag-selected first-party submodule out detached at the selector tip. So a checkout that does fast-forward gets its recorded Gitlinks checked out and then its first-party selector submodules moved off those Gitlinks in the same run.

What goes wrong: "when nothing fast-forwards" reads as a restriction -- the selector move is the fallback for checkouts that do not advance -- and a literal reader concludes that a checkout which is behind origin/<default> will only have its submodules returned to the recorded commits, never floated to a branch tip. That is the opposite of the actual sequence, and it is the reading that matters here, because the bullet exists to help the user decide whether to reach for the stricter mode. references/checkout-updates.md states the behavior without the restriction ("On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached"), so SKILL.md and its own reference now disagree.

Correction: drop the clause, leaving "In first-party checkouts it also moves branch- or tag-tracked submodules to their selector's tip, and update = none stops only that move." This keeps the two facts the bullet needs -- selector floating is first-party only, and update = none opts out of floating but not out of the Gitlink checkout -- and removes a condition that is not a condition.

What would establish or refute it: the { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } clause in the eligibility chain is decisive; it admits fast_forward_ok = true. The new test "a configured tag follows origin unless the local tag holds a commit no other ref holds" exercises the no-fast-forward path only, so no test in the subject pins the post-fast-forward ordering, but the gate text does not depend on one.

  • claim: 01M3BJBFWMJRZTY6TQRHTN69G0
  • anchor: skills/audit-git-checkouts/references/checkout-updates.md (snippet)
When any populated submodule, at any depth, found from the Gitlinks rather than `.gitmodules`, is checked out at a commit that its superproject does not record and no ref holds, the driver skips the fast-forward and every selector move for that checkout, and the report lists it under "Needs your decision" with the submodule's path.
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the added final sentence of "What the driver already updates" in skills/audit-git-checkouts/references/checkout-updates.md, and the comment the same change puts above list_submodules_with_local_work in scripts/audit-checkouts.sh.

What the subject says: one sentence carries four separate facts -- which submodules are examined, how they are discovered, what disqualifies one, and the two consequences (no fast-forward, no selector move, plus a report row) -- with two qualifiers ("at any depth", "found from the Gitlinks rather than .gitmodules") wedged between the subject and its verb, so "submodule ... is checked out" is split by nineteen words.

What goes wrong: the writing standard asks for the action and its object first, with conditions attached to the action they govern, and for detail spent where a plausible mistake would derail the task. Here the discovery mechanism is stated but its payload is not. The script's own comment spells out why discovery from Gitlinks matters -- "Submodules come from the Gitlinks in HEAD and the index, not from .gitmodules, so neither a deleted .gitmodules nor an ignore setting hides one" -- and the change adds two tests for exactly that ("a deleted .gitmodules does not hide a submodule commit no ref holds", "an ignore = all submodule commit no ref holds blocks the fast-forward"). The reference keeps the mechanism and drops the "so" clause, which inverts the value: an agent reading only this reference learns an implementation detail it cannot act on, and does not learn the actionable fact that ignore = all and a removed .gitmodules do not suppress the block. That is the fact a reader hunting for why a checkout went unupdated needs.

Correction: split into two sentences and move the aside's payload into the second, for example: "The driver skips the fast-forward and every selector move for a checkout holding a populated submodule, at any depth, that is checked out at a commit its superproject does not record and no ref holds; the report lists the checkout under "Needs your decision" with the submodule's path. Submodules are found from the Gitlinks in HEAD and the index, so neither a removed .gitmodules nor an ignore setting hides one." Nothing is lost: both consequences, the depth qualifier, and the discovery source survive.

What would establish or refute it: the claim rests on the sentence as written against the script comment and the two tests named above, all in this change; it would be refuted if the reference documented the .gitmodules/ignore consequence elsewhere. Grepping references/checkout-updates.md for "ignore" returns no other mention.

medium — "nested submodules follow their recorded commits" names a checkout the selector path never performs, and reads as a guarantee

  • claim: 01M3BJCEANJFWKDH83C7BJ4JFV
  • anchor: skills/audit-git-checkouts/references/checkout-updates.md (snippet)
Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits.
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the first-party bullet under "Submodule selectors" in skills/audit-git-checkouts/references/checkout-updates.md, and the two functions that move submodules in scripts/audit-checkouts.sh: synchronize_submodules (post-fast-forward) and synchronize_first_party_tracking_submodules (selector moves).

What the subject says: inside the bullet describing the selector move -- "the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged" -- the new sentence adds "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move; nested submodules follow their recorded commits."

What the driver does on that path: synchronize_first_party_tracking_submodules populates a submodule only when it is missing, and non-recursively --

if [ ! -e "$submodule_path/.git" ] \
  && ! git -C "$checkout_path" submodule update --init --checkout -- "$configured_path" ...

-- then runs git -C "$submodule_path" fetch ... and git -C "$submodule_path" checkout --detach "$target_sha". Neither command touches the submodule's own submodules. Only the post-fast-forward synchronize_submodules uses --recursive: git -C "$checkout_path" submodule update --init --recursive --checkout. So after a selector move -- which is exactly the paragraph's subject, and which runs on checkouts where nothing fast-forwarded as well as after one -- a nested submodule is left at whatever commit it already had, while the moved parent now records a different one. It follows neither the old nor the new recorded commit.

What goes wrong: the clause is placed as the complement of "Only ... move", so a literal reader takes it as a statement of what the driver did: nested submodules are not floated to a selector, they are at their recorded commits. An agent acting on this reference -- the one SKILL.md sends it to for "a submodule selector problem" -- will report a checkout as reconciled without checking the nested level, and will be wrong precisely when the parent just moved. The second, charitable reading (this is policy: nested submodules are governed by Gitlinks, not selectors) is also available, and the sentence gives the reader no way to choose, which is itself the defect the writing standard's "make claims verifiable" rule targets.

Correction: say what is and is not done, for example "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move. A selector never floats a nested submodule, and a selector move does not re-check-out the moved submodule's own submodules; after one, verify the nested level against the new Gitlinks by hand." That preserves the useful boundary -- selectors are top-level and first-party only -- and stops the sentence from promising a checkout that did not happen.

Proof gap: I read the code rather than running the driver. The conclusion assumes git checkout --detach does not recurse, which holds unless submodule.recurse is set; I found no submodule.recurse assignment anywhere in scripts/audit-checkouts.sh. No test in the change asserts nested submodule state after a selector move -- the nested fixture is used only by "a nested submodule commit no ref holds blocks the fast-forward", which asserts the move did not happen -- so a test that pins post-selector-move nested state would settle it either way.

medium — --help still advertises "schema 5" after the same change bumped the report to schemaVersion 6

  • claim: 01M3BJAD5HST20NAJ8TXEPNNJR
  • anchor: skills/audit-git-checkouts/scripts/audit-checkouts.sh (snippet)
  echo "Audit every Git checkout below root. Writes one JSON report (schema 5) to stdout"
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • duplicates: 01M3BJXKD6FR4GJBVEJ0FVG22D (general-bug)
  • disposition: none

What I examined: the usage() block at the top of skills/audit-git-checkouts/scripts/audit-checkouts.sh, the report-assembly jq near the end of the same file, and skills/audit-git-checkouts/scripts/render-audit-report.ts.

What the subject says: the help line reads Writes one JSON report (schema 5) to stdout. The same change sets schemaVersion: 6, in this script's report jq, sets const SCHEMA_VERSION = 6; in the renderer, and updates the renderer test to expect expected report schemaVersion 6, got 4. The help text was not updated alongside them; it is the only place in the change that still names 5.

What goes wrong: --help is the authority a reader consults before first use, and this skill's SKILL.md instructs exactly that ("Run scripts/audit-checkouts.sh --help from this skill's directory before first use"). The writing standard names the environment -- task-runner scripts, configuration, and --help -- as a source of truth, and prefers a cheap authoritative lookup to a copied fact. A copied fact that has gone stale is worse than either: a reader who trusts this line will believe a schema-5 consumer can read the output, when parseReport in the renderer rejects anything but 6 (expected report schemaVersion 6, got 5).

Correction: change (schema 5) to (schema 6). That preserves the useful information -- the output is a versioned JSON report whose version the reader can check -- and makes the version match what the script emits.

What would establish or refute it: the parenthetical would have to name something other than the report's schemaVersion field. Grepping the script, the only version literal in the emitted report is schemaVersion: 6, and the renderer compares against exactly that field, so the parenthetical has no other referent.

medium — The update-mode submodule walk discards its own diagnostics, so a failed walk blocks every fast-forward with an unactionable "submodule check failed" row

  • claim: 01M3BJVR540DX0TTTF5985H5RH
  • anchor: skills/audit-git-checkouts/scripts/audit-checkouts.sh (snippet)
    if stranded_output=$(list_submodules_with_local_work "$worktree_path" update 2>/dev/null); then
  • lens: general-bug · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: list_submodules_with_local_work and submodule_holds_local_work (scripts/audit-checkouts.sh, ~lines 483-550), the call site in audit_worktree (line 1544), the two gates that consume stranded_submodules (the [ "$stranded_submodules" = '[]' ] conjuncts on the fast-forward and on tracking_update_eligible), and stepFailure in scripts/render-audit-report.ts.

What the code does: list_submodules_with_local_work is the only thing that ever names a submodule it cannot read. On an unreadable submodule it writes cannot read submodule <path> or cannot inspect submodule <path> to stderr and returns 1. The update-mode call redirects that stderr to /dev/null, so the only surviving signal is stranded_submodules=null. Nothing re-derives it: the removal-mode call (line 1052) does append its stderr to $gate_error_path, but that call only runs for in-root linked worktrees that reach the removal gates, so a primary or default-branch checkout never gets one.

What goes wrong: stranded_submodules=null fails both [ "$stranded_submodules" = '[]' ] conjuncts, so the checkout gets no fast-forward and no selector move. The renderer converts the null into exactly one string — "submodule check failed; checkout not updated" — with no submodule path and no error text, and the JSON record carries nothing more (strandedSubmodules is just null). A default checkout with one broken submodule therefore silently stops being updated on every later run, and the owner has no way to learn which submodule or why.

The trigger is not exotic: git -C "$submodule_path" rev-parse --verify --quiet HEAD fails whenever a populated submodule has a dangling .git gitdir pointer, a stale core.worktree, or an unborn HEAD (a git init-ed or empty-remote submodule). The change's own test "a merged worktree is kept while its submodules hold work that exists only there" builds exactly this state by pointing a submodule's core.worktree at a missing directory and asserts the shell reports cannot read submodule dependency — but only on the removal path. The update path throws the same message away.

Evidence: static trace of the shell and the renderer, plus direct runs of the walker against fixtures I built here (bash -c 'source audit-checkouts.sh; list_submodules_with_local_work <repo> update'), which returned rc=0 with empty output for repos with no gitlinks and rc=1 with the cannot read submodule line on stderr for an unreadable one. jq is not installed in this sandbox, so I could not run the whole driver and observe the rendered markdown end to end; the renderer string is quoted directly from scripts/render-audit-report.ts.

Safe correction: redirect that stderr to a file the way the removal call does, and carry its first line into the report (for example a strandedSubmodulesError field the renderer appends to "submodule check failed").

What would refute it: another consumer of the walker's stderr for checkouts that are not removal candidates, or a strandedSubmodules shape that already carries the failing path.

  • claim: 01M3BJX1D6ARVSHY1Y8V11MZT2
  • anchor: skills/audit-git-checkouts/scripts/audit-checkouts.sh (snippet)
  if [ -e "$worktree_path/.gitmodules" ]; then
    worktree_remove_args+=(--force)
  fi
  • lens: general-bug · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: git worktree remove's submodule refusal in git 2.47.3, the --force decision in decide_removal_outcome (scripts/audit-checkouts.sh lines 1101-1104), the new Gitlink-based walker list_submodules_with_local_work (line 494), and the paragraph this change rewrote in references/removal-gates.md: "Worktrees containing .gitmodules are removed with --force, because Git otherwise refuses any worktree with submodules".

What the code does: the driver adds --force only when a .gitmodules file exists in the worktree:

worktree_remove_args=(worktree remove)
if [ -e "$worktree_path/.gitmodules" ]; then
  worktree_remove_args+=(--force)
fi

Git's refusal is not keyed on .gitmodules. It scans the worktree's index for Gitlink entries and refuses if any of them resolves to a real git dir — the same basis the new walker uses ("Submodules come from the Gitlinks in HEAD and the index, not from .gitmodules, so neither a deleted .gitmodules nor an ignore setting hides one"). So the presence test and the refusal disagree exactly where this change says they must not.

Reproduction I ran (git 2.47.3, this sandbox):

git init -b main sub; (commit one file)      # S = its HEAD
git init -b main r; (commit g)
git -C r update-index --add --cacheinfo 160000,$S,dep
git -C r commit -m "gitlink without .gitmodules"
git -C r branch feature
git -C r worktree add ../wt feature
git clone sub wt/dep                          # populate the submodule
git -C wt status --porcelain                  # empty: the worktree is clean
git -C r worktree remove ../wt
  -> fatal: working trees containing submodules cannot be moved or removed   (exit 128)

With the same worktree left unpopulated (dep/ an empty directory) the removal succeeds, which confirms the refusal keys on a populated Gitlink, not on .gitmodules.

What goes wrong: a linked worktree that is clean, proven merged, unlocked, and passes the new submodule gate — but whose committed tree has a populated Gitlink and no .gitmodules file — is invoked without --force, Git refuses, and the outcome is operational/removal-failed. The report then tells the owner to "Fix the failing query, remote, registration, or filesystem, then rerun", which will never clear it: the next run repeats the same call. A repository reaches this state whenever a Gitlink was committed without a .gitmodules entry (git add <nested-clone> produces exactly that), or a commit removed .gitmodules while leaving the Gitlink in the index.

No data loss: removal simply never happens, so severity is degraded behaviour rather than destruction.

Safe correction: decide --force from Gitlinks instead of the file — e.g. reuse the walker's own source, git -C "$worktree_path" ls-files --stage -z | ... '$1 ~ /^160000 /', and add --force when any such path has a resolvable git dir. The removal-gates.md sentence should be corrected at the same time, since "Worktrees containing .gitmodules" is not the set Git refuses.

Proof gap: I reproduced Git's refusal and the populated/unpopulated distinction directly, but I could not run decide_removal_outcome end to end (jq is not installed in this sandbox), so the operational/removal-failed outcome is traced from the code rather than observed.

medium — The new submodule removal-gate test stages uncommitted submodule work but only asserts the status-query-failure path, so the uncommitted-files and stash gates are unprotected

  • claim: 01M3BK8QQK8CCEE9262AAYWE8A
  • anchor: skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs (snippet)
  // Nor must a submodule whose status query fails, even with uncommitted work the superproject cannot see.
  writeFileSync(join(dependencyPath, "README.md"), "uncommitted\n");
  const dependencyIndex = join(dependencyGitDir, "index");
  const healthyIndex = readFileSync(dependencyIndex);
  writeFileSync(dependencyIndex, "not an index");
  • lens: test-trimming · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the new test a merged worktree is kept while its submodules hold work that exists only there in skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs, the new submodule_holds_local_work helper it drives in skills/audit-git-checkouts/scripts/audit-checkouts.sh, and the contract the change writes into references/removal-gates.md.

What the subject says: removal-gates.md enumerates four independent keeping conditions -- "A submodule keeps the worktree (judgment/submodule-local-work, paths in removal.error) when it has uncommitted files, a stash, a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds." The removal branch of the helper implements them in order:

  output=$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head" refs/remotes) || return 2
  [ -z "$output" ] && return 0
  output=$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all) || return 2
  [ -n "$output" ] && return 0
  git -C "$submodule_path" rev-parse --verify --quiet refs/stash >/dev/null && return 0
  output=$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes) || return 2
  [ -n "$output" ] && return 0

The test walks the submodule through five states and asserts the outcome each time: HEAD held by no remote-tracking ref, a wip tag on an unreachable commit, a recorded-but-unheld Gitlink, an unreadable submodule, and a submodule whose status query fails. The anchored stage is the only one that puts uncommitted content in the submodule -- and it simultaneously corrupts $GIT_DIR/index with writeFileSync(dependencyIndex, "not an index"), so git status exits non-zero and the helper returns 2. The assertions that follow are operational/gate-check-failed and /cannot inspect submodule dependency/, i.e. the error path, not the keeping path. Two lines later the index is restored and git(dependencyPath, "checkout", "--quiet", "--", "README.md") discards the uncommitted file, so the final assert.equal(removed.removal.outcome, "removed") stage runs against a clean submodule. No stage anywhere in the suite creates a stash in a submodule (grep -n 'stash' audit-checkouts.test.mjs finds only superproject stash tests unrelated to this gate).

What goes wrong: the two middle gates are asserted by nothing, while the test's title and the staged "uncommitted\n" write read as if they are covered. I confirmed this by mutation, running the whole file (node --test skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs, baseline 64 tests / 63 pass / 0 fail):

  • Deleting only [ -n "$output" ] && return 0 after the status --porcelain call (keeping the call so the || return 2 error path is unchanged): 64 tests, 63 pass, 0 fail.
  • Deleting the whole rev-parse --verify --quiet refs/stash ... && return 0 line: 64 tests, 63 pass, 0 fail.

So a regression that lets audit-checkouts.sh --remove delete a linked worktree whose submodule holds uncommitted edits, untracked files, or a stash -- work that git worktree remove --force destroys along with the submodule's Git directory, and which the superproject cannot even see because the fixture's .gitmodules sets ignore = all -- ships green. For contrast, the gates the test does assert are protected: removing the fast-forward stranded gate fails 4 tests, removing the local-tag self-exclusion fails 1.

Suggested repair (not deletion -- the test protects real behaviour): add two stages to this same test, before the index-corruption stage, while HEAD is held by refs/remotes/origin/main and no local branch or tag exists. First write an uncommitted README.md, writeResult(), and assert judgment/submodule-local-work with removal.error.trim() === "dependency"; then restore the file, git stash a change inside the submodule, and assert the same outcome, dropping the stash afterwards. Both stages reuse the existing fixture and runMaybeRemove helper, and each kills one of the two surviving mutants above.

What would refute the claim: another test that drives submodule_holds_local_work (or maybe_remove_worktree) with a dirty-but-readable submodule or a submodule stash and asserts a non-removal outcome. I found none, and the two mutation runs above are the decisive evidence that none exists.

medium — stepFailure returns "submodule check failed; checkout not updated" for every worktree, hiding a linked worktree's real removal outcome and dropping it from all other report sections

  • claim: 01M3BJWCTMM2QXQWEPKZA7TSHJ
  • anchor: skills/audit-git-checkouts/scripts/render-audit-report.ts (snippet)
  if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";
  • lens: general-bug · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: stepFailure and the per-worktree loop in formatAuditReport (scripts/render-audit-report.ts), and the shell that produces the fields it reads (audit_worktree line 1544 and decide_removal_outcome lines 1051-1062 plus the case "$outcome" at line 1153 in scripts/audit-checkouts.sh).

What the code does: the new first line of stepFailure is if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";. It runs ahead of every other branch in that function, and formatAuditReport calls stepFailure for every in-root worktree, not just primaries and default-branch checkouts:

const failure = stepFailure(worktree, report.fetched);
if (failure !== null) { failures.push([path, failure]); continue; }

What goes wrong, for a linked worktree on a feature branch:

  1. The message is wrong on its face. A linked non-default worktree is never a fast-forward or selector-move candidate — stranded_submodules gates only the fast_forward_mode and tracking_update_eligible conditions, both of which additionally require branch.current == default_branch. "checkout not updated" tells the owner an update was withheld when none was ever pending.

  2. It hides the specific diagnosis the shell did capture. When a submodule is unreadable, both walker calls fail the same way, so decide_removal_outcome returns operational/gate-check-failed with cannot read submodule <path> in removal.error (the change's own test asserts exactly that: assert.match(unreadable.removal.error, /cannot read submodule dependency/)). Because the new check precedes if (outcome.startsWith("operational/")), the row reads submodule check failed; checkout not updated instead of removal check failed (gate-check-failed): cannot read submodule dependency. Before this change the specific message was what the report printed, so this is a straight loss of the actionable text.

  3. The continue drops the worktree from every other section. It never reaches keptReason, so a merged worktree also blocked by a lock or by precious ignored files loses that reason, and it never reaches the activity branches, so it vanishes from "Possibly abandoned", "Active work", and "Activity unknown" as well. The worktree count still includes it, so the Summary and the tables disagree about it.

Failure scenario, concretely: a linked worktree ~/dev/app-x on branch x whose populated submodule dep has a dangling .git gitdir pointer. The driver records strandedSubmodules: null and removal.outcome: "operational/gate-check-failed" with removal.error: "cannot read submodule dep\n". The report shows a single row | app-x | submodule check failed; checkout not updated |, and app-x appears in no other table — no path to the broken submodule, and no hint that it was a removal candidate at all.

Evidence: static trace of scripts/render-audit-report.ts against the JSON shape written by audit_worktree; jq is absent from this sandbox so I could not run the driver and render a real report, and the new render tests only cover the default-branch case (strandedSubmodules: null on a worktree they mark as the repository's checkout) — no test exercises a linked worktree with a null value.

Safe correction: move the null check after the removal-outcome branches, and scope its wording to checkouts that were actually update candidates (or drop the ", checkout not updated" clause and append the captured submodule path instead).

medium — The stranded-submodule row overrides primaryDecision for every primary checkout, so detached and off-default primaries lose their real reason and are compared against the wrong line

  • claim: 01M3BJZHXK440NSNFW2AJ720B6
  • anchor: skills/audit-git-checkouts/scripts/render-audit-report.ts (snippet)
      if ((isMain || onDefault) && stranded.length > 0) {
        const why = `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated`;
        decisions.push([path, branch, formatAheadBehind(worktree.defaultComparison), why]);
        continue;
      }
  • lens: general-bug · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the new block in formatAuditReport (scripts/render-audit-report.ts, the if ((isMain || onDefault) && stranded.length > 0) branch), primaryDecision in the same file (cases current-pinned-reference, pinned-reference-needs-attention, and the default: arm), and the two gates in scripts/audit-checkouts.sh that stranded_submodules actually controls (fast_forward_mode, lines 1553-1565, and tracking_update_eligible, lines 1599-1614).

What the code does: the new branch fires on isMain || onDefault, i.e. on any primary checkout, regardless of what that primary is checked out at, and it continues so primaryDecision never runs. It renders formatAheadBehind(worktree.defaultComparison) in the Ahead/behind cell and the fixed phrase ... ; not updated in Why.

What the shell actually gates: both conjuncts guarded by [ "$stranded_submodules" = '[]' ] also require [ "$(jq -r '.branch.isDetached == false' "$status_path")" = true ] and [ "$(jq -r '.branch.current // empty' "$status_path")" = "$default_branch" ]. So for a primary checkout that is detached, or on a branch other than the default, the stranded list withheld nothing — that checkout was never a fast-forward or selector-move candidate in the first place.

What goes wrong, for a primary checkout that is a pinned reference (detached at a tag or commit) and has a stranded submodule:

  • The Why cell claims "not updated", inventing a suppressed update that was never possible. The correct decision text for that checkout is primaryDecision's pinned-reference-needs-attention / current-pinned-reference wording ("detached checkout is behind or off ", "detached checkout with N uncommitted files"), which the continue discards.
  • The Ahead/behind cell uses defaultComparison, while every other code path for a pinned reference deliberately uses checkoutComparison — the two primaryDecision cases above are explicit about it. The reader is shown the distance from origin/<default>, which is not the line a pinned checkout tracks, labelled as though it were.
  • A pinned reference that is genuinely behind its own line, or a primary checkout sitting on the wrong branch (default: arm: "primary checkout is on X, not main"), loses that actionable reason entirely and gets a submodule note instead.

Failure scenario: a primary checkout vendor/tool detached at tag v3.1 with a populated submodule dep on a local commit no ref holds, and upstream now at v4.0. Before the change the row read | vendor/tool | (detached) | +0/-N | detached checkout is behind or off refs/tags/v4.0 |. Now it reads | vendor/tool | (detached) | <defaultComparison> | submodule on a commit no ref holds: dep; not updated | — the behind-its-line signal is gone and the comparison shown is against a different target.

Safe correction: restrict the branch to the checkouts the shell gates — require onDefault (not isMain) — or, if primaries should still surface stranded submodules, append the submodule note to primaryDecision's result instead of replacing it, and keep the comparison primaryDecision chose for that classification.

Evidence and proof gap: static trace of the renderer against the gate conditions in the shell. jq is not installed in this sandbox, so I could not run the driver and diff two rendered reports; the two new render tests cover only an onDefault worktree with deferredOnly: true and a null-valued one, so neither exercises a detached or off-default primary.

low — The mode table's "What changes" cell now restates the submodule behavior that the bullet three lines below and the reference already give

  • claim: 01M3BJD43THCCSVM63RWPDHTDZ
  • anchor: skills/audit-git-checkouts/SKILL.md (snippet)
Fetch and prune `origin`; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged;
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the "Choose the mode" table and the stricter-mode bullet list directly under it in skills/audit-git-checkouts/SKILL.md, plus the first-party bullet in references/checkout-updates.md.

What the subject says: the default-mode cell grew from "fast-forward eligible default checkouts" to two added clauses -- "then initialize their submodules and check them out at the recorded commits" and "in first-party default checkouts, check each submodule listed in the checkout's own .gitmodules out at its configured branch or tag, leaving the Gitlink change unstaged". Three lines later, the new bullet says the same two things again: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules". The reference says them a third time: "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move" and "checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit".

What goes wrong: the writing standard gives each instruction one home, says not to restate what an earlier sentence already says, and says to cut most aggressively from the content loaded most often -- SKILL.md body text loads on every invocation, the reference only when the agent opens it. Three costs follow. The cell is now a 60-word chain of five semicolon-joined clauses in a table whose job is letting a reader pick a mode at a glance, so the column no longer scans. The two copies are not word-for-word, so a reader must reconcile "initialize their submodules and check them out at the recorded commits" with "checks every submodule, third-party ones included, out at its recorded commit" and decide whether the third-party scope is a difference or a restatement. And the .gitmodules-listing and unstaged-Gitlink details are decision-time facts for someone already reconciling submodules, not mode-selection facts for someone choosing flags.

Correction: return the cell to the granularity of its neighbours -- "Fetch and prune origin; fast-forward eligible default checkouts and move their submodules (see below); prune stale worktree registrations; remove proven-merged linked worktrees inside the root." -- and let the bullet below carry the recorded-commit and first-party-selector detail it already carries. Nothing is lost: every fact stays in the file, once, where the reader needs it.

What would establish or refute it: the two passages are quoted above from the same file, eleven lines apart; the duplication is on the page. It would be refuted if the table cell were the only statement of either fact, which the quoted bullet shows it is not.

low — The reference's failure list omits the new "submodule check failed; checkout not updated" outcome and sends its reader to repair a state the driver never touched

  • claim: 01M3BJFJHNJM26BM2SDNQ4X9VX
  • anchor: skills/audit-git-checkouts/references/checkout-updates.md (snippet)
A fast-forward, submodule sync, or selector update can fail after an earlier step succeeded. Inspect the actual HEAD, index, and submodule state before repairing; never describe a failed multi-step update as atomic, and never force a refused merge.
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the "What the driver already updates" section of skills/audit-git-checkouts/references/checkout-updates.md, the stepFailure function in scripts/render-audit-report.ts, and the stranded_submodules handling in audit_worktree in scripts/audit-checkouts.sh.

What the change adds: the driver now runs list_submodules_with_local_work "$worktree_path" update before deciding eligibility, and when that walk fails it sets stranded_submodules=null. Both eligibility gates then test [ "$stranded_submodules" = '[]' ], so a null blocks the fast-forward and the selector move alike. The renderer turns that null into a new failure row: if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";.

What the prose says: the reference names four ways a checkout goes unupdated -- not fresh, local commits, not behind, or a dirty tree that is not deferred guidance or first-party selector maintenance -- plus the new stranded-submodule rule, and then says "A fast-forward, submodule sync, or selector update can fail after an earlier step succeeded. Inspect the actual HEAD, index, and submodule state before repairing". Those three names match the other three stepFailure strings exactly ("fast-forward failed", "submodule sync failed after fast-forward", "submodule selector update failed"). The fourth string has no entry anywhere in the reference; grepping it for "check failed" returns nothing.

What goes wrong: SKILL.md routes "a default checkout that was not fast-forwarded" to this reference, so this is where an agent looks after reading "submodule check failed; checkout not updated" in report.md. It finds a closed enumeration of three failures that does not include theirs, and the one instruction that seems to apply -- inspect HEAD, index, and submodule state "before repairing", on the premise that an earlier step succeeded -- is wrong for this case: nothing was attempted, so there is no half-applied update to repair. The remedy is the opposite kind of action, making the unreadable submodule readable (the walk emits cannot read submodule <path> or cannot inspect submodule <path>) and rerunning. Sending an agent to reconcile HEAD and index on an untouched checkout is the mistake the section exists to prevent.

Correction: add one sentence beside the stranded-submodule rule -- "When the submodule walk itself fails, the driver also skips the fast-forward and every selector move, changes nothing, and the report says the submodule check failed; the driver's stderr names the unreadable submodule. Make it readable and rerun." That preserves the existing paragraph and closes the enumeration against the fourth outcome.

What would establish or refute it: the claim rests on strandedSubmodules === null producing a distinct report string with no reference entry, and on the null path skipping both updates. It would be refuted if some other reference documented the string; references/removal-gates.md documents only the removal-side operational/... outcomes, and its judgment/submodule-local-work row covers removal, not updates.

low — "rerun once ... they authorize discarding it" sends the agent back into the same gate: authorization alone does not change what the walk sees

  • claim: 01M3BJGHNSVNR7A66RVMKR11BX
  • anchor: skills/audit-git-checkouts/references/removal-gates.md (snippet)
The listed submodules hold work only this worktree has. Show the owner what each holds; rerun once it is pushed or they authorize discarding it.
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the new judgment/submodule-local-work row in the outcome table of skills/audit-git-checkouts/references/removal-gates.md, the neighbouring rows in that table, and submodule_holds_local_work plus the gate that calls it in scripts/audit-checkouts.sh.

What the subject says: "Show the owner what each holds; rerun once it is pushed or they authorize discarding it."

What goes wrong: the two branches of that disjunction are not the same kind of thing. "once it is pushed" names a state change the gate can see -- pushing updates refs/remotes/origin/<branch>, and submodule_holds_local_work then finds the HEAD contained by for-each-ref --contains "$head" refs/remotes and returns 1. "once ... they authorize discarding it" names only permission. Nothing in the submodule changes when the owner says yes, so the rerun re-walks the same uncommitted files, the same stash, and the same unpushed commits, emits judgment/submodule-local-work again, and the worktree is kept a second time. An agent following the row literally loops, or reports to the user that the driver refuses to honour the authorization they just gave.

The adjacent rows get this right by naming the act, not the permission: judgment/precious-ignored-files says "Leave preciousPaths in place until the owner disposes of them or authorizes their deletion, then rerun" -- disposal happens first -- and judgment/hidden-index-flags says "Have the owner clear the flags they set, prove the revealed tree clean, then rerun."

Correction: make the second branch a state change too, for example "rerun once the work is pushed; if the owner authorizes discarding it instead, discard it in the submodule first, then rerun." The row's useful content -- show the owner what each submodule holds, and never discard without their say-so, which is SKILL.md's "Treat user work as untouchable" -- is preserved; only the trigger for the rerun becomes something the gate can observe.

What would establish or refute it: submodule_holds_local_work reads only the submodule's working tree, stash, refs and remote-tracking refs; it takes no authorization input, and no caller passes one. It would be refuted if some flag or environment variable let a rerun bypass the gate -- the change adds none, and --no-remove only makes the driver keep more worktrees, not fewer.

low — The new help text says a ref "keeps" a commit where every other file says "holds", and "work no remote-tracking ref keeps is" garden-paths the reader

  • claim: 01M3BJDYR88CZ3HMK9ZDZWETFH
  • anchor: skills/audit-git-checkouts/scripts/audit-checkouts.sh (snippet)
  echo "  selector tag is replaced only when the fetched tag or another ref keeps its commit; a"
  echo "  worktree whose submodules hold work no remote-tracking ref keeps is not removed."
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the three help lines the change adds to usage() in skills/audit-git-checkouts/scripts/audit-checkouts.sh, and every other place in the skill that names the same relation: SKILL.md, references/removal-gates.md, references/checkout-updates.md, the new comment above list_submodules_with_local_work, and the report strings in scripts/render-audit-report.ts.

What the subject says: the help text uses "keeps" twice for "some ref contains this commit" -- "a local selector tag is replaced only when the fetched tag or another ref keeps its commit" and "a worktree whose submodules hold work no remote-tracking ref keeps is not removed".

Everywhere else the skill calls that relation "holds": SKILL.md "commits no other ref holds"; removal-gates.md "a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds"; checkout-updates.md "a commit that its superproject does not record and no ref holds" and, for the identical tag rule this help line restates, "another ref holds it"; the script's own comment "nor held by any ref in the submodule"; render-audit-report.ts "submodule on a commit no ref holds". The line immediately above even uses the right word -- "neither recorded nor held by a ref" -- so the two spellings sit three lines apart.

What goes wrong: the writing standard asks for one term per concept, and the collision here is not hypothetical. These same documents already use "keep" for a different relation: "A Gitlink names a commit without keeping it" and "A local tag ... is kept as it is" (retention), and "A submodule keeps the worktree" (blocks removal). A reader who meets "another ref keeps its commit" must decide which of those three senses applies before they can read the rule. The second sentence compounds it: "work no remote-tracking ref keeps is not removed" puts a reduced relative clause between subject and verb and lands "keeps is" adjacent, so the reader parses "keeps" as the main verb and has to restart. --help is the first thing SKILL.md tells a reader to run, which is the worst place for a sentence that needs a second pass.

Correction: use "holds" in both, and give the second one a verb it cannot be mistaken for: "... a local selector tag is replaced only when the fetched tag or another ref holds its commit; a worktree is not removed while a submodule holds work that no remote-tracking ref holds." The rules and their scope are unchanged; only the term and the clause order move.

What would establish or refute it: the quoted occurrences are from the subject tree as listed above. It would be refuted if "keeps" named a distinct relation from "holds" here -- but the tag clause is the same rule references/checkout-updates.md states with "holds", and both compile down to the same for-each-ref --contains test in update_submodule_tag and submodule_holds_local_work.

low — A git ls-files failure in list_submodules_with_local_work cannot be detected, so the submodule safety gate fails open instead of closed

  • claim: 01M3BJYBGZFFQGBD05BS5YHCYM
  • anchor: skills/audit-git-checkouts/scripts/audit-checkouts.sh (snippet)
  gitlink_paths=$({
    git -C "$superproject_path" ls-files --stage -z
    git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true
  } | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1
  • lens: general-bug · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: list_submodules_with_local_work (scripts/audit-checkouts.sh lines 494-525), its own contract comment two lines below ("A submodule the walk cannot read may hold anything; fail rather than report it empty"), set -o pipefail at line 3, and both call sites — the removal gate at line 1052 and the update check at line 1544.

What the code does: the walker's ref-discovery step is

gitlink_paths=$({
  git -C "$superproject_path" ls-files --stage -z
  git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true
} | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1

pipefail only inspects the exit status of each pipeline element, and the first element is the brace group. A brace group's status is that of its last command, which here is git ls-tree ... || true — always 0. So a git ls-files --stage -z failure cannot reach the || return 1: the union silently loses whatever the index would have contributed, and the walker reports success.

Observed, in this sandbox (git 2.47.3), against a superproject with one Gitlink and a deliberately truncated .git/index:

$ { git ls-files --stage -z; git ls-tree -r -z --full-tree HEAD 2>/dev/null || true; } \
    | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u; echo "pipe rc=$?"
fatal: .git/index: index file smaller than expected
dep
pipe rc=0

and calling the function itself returned rc=0 with the same fatal: on stderr — no return 1.

What goes wrong: the walker is a safety gate whose stated design is to fail closed, and this path fails open. When ls-files is the only source that would have named a Gitlink — a Gitlink present in the index but not in HEAD — a failed index read makes list_submodules_with_local_work print nothing and return 0. In removal mode that is indistinguishable from "no submodule holds work", so the gate passes and git worktree remove --force proceeds to delete the worktree's submodule git dirs; in update mode it yields strandedSubmodules: [], which un-gates the fast-forward and the selector move.

Honest limit on reachability, which is why I am filing this low rather than high: the trigger I could construct (a corrupt index) also breaks the git status --porcelain re-check that immediately follows the gate, so that specific path ends in operational/gate-check-failed rather than in a deletion, and I could not construct a case where ls-files --stage -z fails while status succeeds. I am claiming the masked failure as a defect in a gate whose own comment promises the opposite, not a demonstrated data-loss path.

Safe correction: capture the two listings separately and check each, e.g. index_paths=$(git -C "$superproject_path" ls-files --stage -z) || return 1 and then union the already-validated text, so neither source's failure can read as "no submodules".

low — The tag-selector test never exercises the "another ref holds it" half of update_submodule_tag, so that allowance can be deleted with the suite green

  • claim: 01M3BKCTPMEA51K2Z53GH578NA
  • anchor: skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs (snippet)
  const refused = audit();
  assert.equal(refused.trackingUpdate.attempted, true);
  assert.equal(refused.trackingUpdate.ok, false);
  assert.match(refused.trackingUpdate.error, /local tag v1 .* no other ref holds/);
  • lens: test-trimming · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

What I examined: the new test a configured tag follows origin unless the local tag holds a commit no other ref holds in skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs, the new update_submodule_tag helper it drives in skills/audit-git-checkouts/scripts/audit-checkouts.sh, and the contract stated in references/checkout-updates.md.

What the subject says: checkout-updates.md states the rule as a disjunction -- "A local tag of the configured name on the same commit is kept as it is. One on another commit is replaced only when the fetched tag's commit contains that commit or another ref holds it." The helper implements exactly that, with two independent escape hatches:

  if ! git -C "$submodule_path" merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null \
    && [ -z "$(git -C "$submodule_path" for-each-ref --format='%(refname)' --contains "$local_commit" 2>/dev/null | grep -v -x -F "refs/tags/$tag")" ]; then
    echo "local tag $tag in $submodule_path holds commit $local_commit that no other ref holds; not replacing it with origin's $fetched_commit" >>"$error_path"
    return 1
  fi

What the test does: its two "followed" stages re-tag v1 in the upstream submodule at a descendant of the local tag's commit, so merge-base --is-ancestor succeeds and the first hatch alone permits the replacement. The anchored "refused" stage builds the opposite case -- commitDetached(dependencyPath) creates localCommit as a child of thirdCommit, then git tag --force v1 moves the local tag onto it, so localCommit is not an ancestor of the fetched commit and refs/tags/v1 is the only ref that holds it. Both branches of the && are false, and the refusal is asserted. No stage ever reaches the state where merge-base --is-ancestor fails but another ref does hold $local_commit -- the only state in which the second hatch decides the outcome.

What goes wrong: the for-each-ref --contains clause -- half of the documented predicate, and the half the test's own title names -- is asserted by nothing. I confirmed this by mutation. Baseline: node --test skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs gives 64 tests / 63 pass / 0 fail. Deleting the entire second clause, so the condition reduces to if ! git ... merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null; then, still gives 64 tests / 63 pass / 0 fail. Under that mutant the driver refuses every selector update whose local tag commit is not an ancestor of the fetched tag, even when a branch or another tag still holds it -- so a routine re-tag onto a sibling line reports trackingUpdate.ok: false with "no other ref holds", the submodule stops floating, and the report shows a failure the owner cannot act on. That regression ships green. For contrast, the self-exclusion grep -v -x -F "refs/tags/$tag" inside that same clause is protected: deleting it fails this test (1 failure), because without it the local tag itself would count as "another ref".

Suggested repair (not deletion -- this test protects the refusal correctly): extend it with one more stage that distinguishes the hatches. After the existing refused stage, point a second ref at localCommit inside the submodule -- git(dependencyPath, "branch", "keep", localCommit) -- and audit() again, asserting trackingUpdate.ok === true and git rev-parse v1^{commit} === thirdCommit (the tag now follows origin because refs/heads/keep keeps the local commit). That stage reuses the existing fixture and kills the surviving mutant above.

What would refute the claim: another test that makes merge-base --is-ancestor fail while a non-refs/tags/<tag> ref holds the local tag's commit and asserts the tag is replaced. I found none in either changed test file, and the mutation run above is the decisive evidence that none exists.

Other claims

  • grounding-pending (0)
  • ungrounded (0)
  • rejected (1)
    • 01M3BJF1C8Y7SDRTS25DV3DSQQ low — Test comment "Nor must a submodule whose status query fails" is a predicate-less fragment whose negation states the opposite of the assertion below it
  • duplicate-of (1)
    • 01M3BJXKD6FR4GJBVEJ0FVG22D low — --help still advertises "schema 5" after the report was bumped to schemaVersion 6 → 01M3BJAD5HST20NAJ8TXEPNNJR
  • unadjudicated (0)

Coverage

Coverage pass: 01M3BJ3S4H2RMJ1PD1ZWC9K7FR
Accounting: complete
Slot health: healthy

lens part arm unit status runs loss
general-bug whole default claims-emitted 1 no
writing-quality whole default claims-emitted 1 no
test-trimming whole default claims-emitted 1 no
<!-- review:summary --> **Review** `01M3BJ3S1CY1F1S8BW4PD0P9FC` — head `0cdf0815fc1f001c6c097c960f3b7803512d4081` # Review — j4k-oss/agent-skills @ d5816706164f Scope: diff against base tree `d1e95321d499` Status: dispatched — coverage complete (3/3 slots terminal) Facts: current review-wide projection Computed under: ```json { "abandonment": "abandonment-v1", "anchor_recipe": 1, "batch_policy": "batch-v1", "coverage": "coverage-v3", "dispatch_policy": "dispatch-v2", "grounder_version": 1, "grounding_read_rule": "grounding-read-v1", "promotion_policy": "promotion-v1", "report": "report-v3", "tally": "tally-v1", "triage_settle": "triage-settle-v2" } ``` ## Findings (15) ### medium — "moves branch- or tag-tracked submodules when nothing fast-forwards" understates when selector moves run: they also run right after a successful fast-forward - claim: `01M3BJAY8E33N13G2MRZKFY5X3` - anchor: `skills/audit-git-checkouts/SKILL.md` (snippet) ``` In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and `update = none` stops only that move. ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the new bullet under "Choose the mode" in `skills/audit-git-checkouts/SKILL.md`, the `tracking_update_eligible` gate in `scripts/audit-checkouts.sh`, and the matching paragraph in `references/checkout-updates.md`. > > What the subject says: the bullet tells a user whose submodules must stay put to pass `--no-fetch --no-remove`, and describes what the default mode would otherwise do: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards". > > What the driver does: in `audit_worktree`, the selector move is gated on > > && { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } \ > > which is true both when no fast-forward was attempted and when one was attempted and succeeded. `synchronize_first_party_tracking_submodules` then runs and checks each branch- or tag-selected first-party submodule out detached at the selector tip. So a checkout that does fast-forward gets its recorded Gitlinks checked out and then its first-party selector submodules moved off those Gitlinks in the same run. > > What goes wrong: "when nothing fast-forwards" reads as a restriction -- the selector move is the fallback for checkouts that do not advance -- and a literal reader concludes that a checkout which is behind `origin/<default>` will only have its submodules returned to the recorded commits, never floated to a branch tip. That is the opposite of the actual sequence, and it is the reading that matters here, because the bullet exists to help the user decide whether to reach for the stricter mode. `references/checkout-updates.md` states the behavior without the restriction ("On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached"), so SKILL.md and its own reference now disagree. > > Correction: drop the clause, leaving "In first-party checkouts it also moves branch- or tag-tracked submodules to their selector's tip, and `update = none` stops only that move." This keeps the two facts the bullet needs -- selector floating is first-party only, and `update = none` opts out of floating but not out of the Gitlink checkout -- and removes a condition that is not a condition. > > What would establish or refute it: the `{ [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; }` clause in the eligibility chain is decisive; it admits `fast_forward_ok = true`. The new test "a configured tag follows origin unless the local tag holds a commit no other ref holds" exercises the no-fast-forward path only, so no test in the subject pins the post-fast-forward ordering, but the gate text does not depend on one. ### medium — The stranded-submodule rule is one 57-word sentence whose "found from the Gitlinks rather than `.gitmodules`" aside drops the consequence that makes it worth stating - claim: `01M3BJBFWMJRZTY6TQRHTN69G0` - anchor: `skills/audit-git-checkouts/references/checkout-updates.md` (snippet) ``` When any populated submodule, at any depth, found from the Gitlinks rather than `.gitmodules`, is checked out at a commit that its superproject does not record and no ref holds, the driver skips the fast-forward and every selector move for that checkout, and the report lists it under "Needs your decision" with the submodule's path. ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the added final sentence of "What the driver already updates" in `skills/audit-git-checkouts/references/checkout-updates.md`, and the comment the same change puts above `list_submodules_with_local_work` in `scripts/audit-checkouts.sh`. > > What the subject says: one sentence carries four separate facts -- which submodules are examined, how they are discovered, what disqualifies one, and the two consequences (no fast-forward, no selector move, plus a report row) -- with two qualifiers ("at any depth", "found from the Gitlinks rather than `.gitmodules`") wedged between the subject and its verb, so "submodule ... is checked out" is split by nineteen words. > > What goes wrong: the writing standard asks for the action and its object first, with conditions attached to the action they govern, and for detail spent where a plausible mistake would derail the task. Here the discovery mechanism is stated but its payload is not. The script's own comment spells out why discovery from Gitlinks matters -- "Submodules come from the Gitlinks in HEAD and the index, not from .gitmodules, so neither a deleted .gitmodules nor an `ignore` setting hides one" -- and the change adds two tests for exactly that ("a deleted .gitmodules does not hide a submodule commit no ref holds", "an ignore = all submodule commit no ref holds blocks the fast-forward"). The reference keeps the mechanism and drops the "so" clause, which inverts the value: an agent reading only this reference learns an implementation detail it cannot act on, and does not learn the actionable fact that `ignore = all` and a removed `.gitmodules` do not suppress the block. That is the fact a reader hunting for why a checkout went unupdated needs. > > Correction: split into two sentences and move the aside's payload into the second, for example: "The driver skips the fast-forward and every selector move for a checkout holding a populated submodule, at any depth, that is checked out at a commit its superproject does not record and no ref holds; the report lists the checkout under \"Needs your decision\" with the submodule's path. Submodules are found from the Gitlinks in HEAD and the index, so neither a removed `.gitmodules` nor an `ignore` setting hides one." Nothing is lost: both consequences, the depth qualifier, and the discovery source survive. > > What would establish or refute it: the claim rests on the sentence as written against the script comment and the two tests named above, all in this change; it would be refuted if the reference documented the `.gitmodules`/`ignore` consequence elsewhere. Grepping `references/checkout-updates.md` for "ignore" returns no other mention. ### medium — "nested submodules follow their recorded commits" names a checkout the selector path never performs, and reads as a guarantee - claim: `01M3BJCEANJFWKDH83C7BJ4JFV` - anchor: `skills/audit-git-checkouts/references/checkout-updates.md` (snippet) ``` Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits. ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the first-party bullet under "Submodule selectors" in `skills/audit-git-checkouts/references/checkout-updates.md`, and the two functions that move submodules in `scripts/audit-checkouts.sh`: `synchronize_submodules` (post-fast-forward) and `synchronize_first_party_tracking_submodules` (selector moves). > > What the subject says: inside the bullet describing the selector move -- "the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged" -- the new sentence adds "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits." > > What the driver does on that path: `synchronize_first_party_tracking_submodules` populates a submodule only when it is missing, and non-recursively -- > > if [ ! -e "$submodule_path/.git" ] \ > && ! git -C "$checkout_path" submodule update --init --checkout -- "$configured_path" ... > > -- then runs `git -C "$submodule_path" fetch ...` and `git -C "$submodule_path" checkout --detach "$target_sha"`. Neither command touches the submodule's own submodules. Only the post-fast-forward `synchronize_submodules` uses `--recursive`: `git -C "$checkout_path" submodule update --init --recursive --checkout`. So after a selector move -- which is exactly the paragraph's subject, and which runs on checkouts where nothing fast-forwarded as well as after one -- a nested submodule is left at whatever commit it already had, while the moved parent now records a different one. It follows neither the old nor the new recorded commit. > > What goes wrong: the clause is placed as the complement of "Only ... move", so a literal reader takes it as a statement of what the driver did: nested submodules are not floated to a selector, they are at their recorded commits. An agent acting on this reference -- the one SKILL.md sends it to for "a submodule selector problem" -- will report a checkout as reconciled without checking the nested level, and will be wrong precisely when the parent just moved. The second, charitable reading (this is policy: nested submodules are governed by Gitlinks, not selectors) is also available, and the sentence gives the reader no way to choose, which is itself the defect the writing standard's "make claims verifiable" rule targets. > > Correction: say what is and is not done, for example "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move. A selector never floats a nested submodule, and a selector move does not re-check-out the moved submodule's own submodules; after one, verify the nested level against the new Gitlinks by hand." That preserves the useful boundary -- selectors are top-level and first-party only -- and stops the sentence from promising a checkout that did not happen. > > Proof gap: I read the code rather than running the driver. The conclusion assumes `git checkout --detach` does not recurse, which holds unless `submodule.recurse` is set; I found no `submodule.recurse` assignment anywhere in `scripts/audit-checkouts.sh`. No test in the change asserts nested submodule state after a selector move -- the nested fixture is used only by "a nested submodule commit no ref holds blocks the fast-forward", which asserts the move did not happen -- so a test that pins post-selector-move nested state would settle it either way. ### medium — `--help` still advertises "schema 5" after the same change bumped the report to schemaVersion 6 - claim: `01M3BJAD5HST20NAJ8TXEPNNJR` - anchor: `skills/audit-git-checkouts/scripts/audit-checkouts.sh` (snippet) ``` echo "Audit every Git checkout below root. Writes one JSON report (schema 5) to stdout" ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - duplicates: `01M3BJXKD6FR4GJBVEJ0FVG22D` (general-bug) - disposition: none > What I examined: the `usage()` block at the top of `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, the report-assembly `jq` near the end of the same file, and `skills/audit-git-checkouts/scripts/render-audit-report.ts`. > > What the subject says: the help line reads `Writes one JSON report (schema 5) to stdout`. The same change sets `schemaVersion: 6,` in this script's report `jq`, sets `const SCHEMA_VERSION = 6;` in the renderer, and updates the renderer test to expect `expected report schemaVersion 6, got 4`. The help text was not updated alongside them; it is the only place in the change that still names 5. > > What goes wrong: `--help` is the authority a reader consults before first use, and this skill's SKILL.md instructs exactly that ("Run `scripts/audit-checkouts.sh --help` from this skill's directory before first use"). The writing standard names the environment -- task-runner scripts, configuration, and `--help` -- as a source of truth, and prefers a cheap authoritative lookup to a copied fact. A copied fact that has gone stale is worse than either: a reader who trusts this line will believe a schema-5 consumer can read the output, when `parseReport` in the renderer rejects anything but 6 (`expected report schemaVersion 6, got 5`). > > Correction: change `(schema 5)` to `(schema 6)`. That preserves the useful information -- the output is a versioned JSON report whose version the reader can check -- and makes the version match what the script emits. > > What would establish or refute it: the parenthetical would have to name something other than the report's `schemaVersion` field. Grepping the script, the only version literal in the emitted report is `schemaVersion: 6`, and the renderer compares against exactly that field, so the parenthetical has no other referent. ### medium — The update-mode submodule walk discards its own diagnostics, so a failed walk blocks every fast-forward with an unactionable "submodule check failed" row - claim: `01M3BJVR540DX0TTTF5985H5RH` - anchor: `skills/audit-git-checkouts/scripts/audit-checkouts.sh` (snippet) ``` if stranded_output=$(list_submodules_with_local_work "$worktree_path" update 2>/dev/null); then ``` - lens: general-bug · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: `list_submodules_with_local_work` and `submodule_holds_local_work` (scripts/audit-checkouts.sh, ~lines 483-550), the call site in `audit_worktree` (line 1544), the two gates that consume `stranded_submodules` (the `[ "$stranded_submodules" = '[]' ]` conjuncts on the fast-forward and on `tracking_update_eligible`), and `stepFailure` in scripts/render-audit-report.ts. > > What the code does: `list_submodules_with_local_work` is the only thing that ever names a submodule it cannot read. On an unreadable submodule it writes `cannot read submodule <path>` or `cannot inspect submodule <path>` to stderr and returns 1. The `update`-mode call redirects that stderr to `/dev/null`, so the only surviving signal is `stranded_submodules=null`. Nothing re-derives it: the removal-mode call (line 1052) does append its stderr to `$gate_error_path`, but that call only runs for in-root linked worktrees that reach the removal gates, so a primary or default-branch checkout never gets one. > > What goes wrong: `stranded_submodules=null` fails both `[ "$stranded_submodules" = '[]' ]` conjuncts, so the checkout gets no fast-forward and no selector move. The renderer converts the null into exactly one string — `"submodule check failed; checkout not updated"` — with no submodule path and no error text, and the JSON record carries nothing more (`strandedSubmodules` is just `null`). A default checkout with one broken submodule therefore silently stops being updated on every later run, and the owner has no way to learn which submodule or why. > > The trigger is not exotic: `git -C "$submodule_path" rev-parse --verify --quiet HEAD` fails whenever a populated submodule has a dangling `.git` gitdir pointer, a stale `core.worktree`, or an unborn HEAD (a `git init`-ed or empty-remote submodule). The change's own test "a merged worktree is kept while its submodules hold work that exists only there" builds exactly this state by pointing a submodule's `core.worktree` at a missing directory and asserts the shell reports `cannot read submodule dependency` — but only on the removal path. The update path throws the same message away. > > Evidence: static trace of the shell and the renderer, plus direct runs of the walker against fixtures I built here (`bash -c 'source audit-checkouts.sh; list_submodules_with_local_work <repo> update'`), which returned rc=0 with empty output for repos with no gitlinks and rc=1 with the `cannot read submodule` line on stderr for an unreadable one. `jq` is not installed in this sandbox, so I could not run the whole driver and observe the rendered markdown end to end; the renderer string is quoted directly from scripts/render-audit-report.ts. > > Safe correction: redirect that stderr to a file the way the removal call does, and carry its first line into the report (for example a `strandedSubmodulesError` field the renderer appends to "submodule check failed"). > > What would refute it: another consumer of the walker's stderr for checkouts that are not removal candidates, or a `strandedSubmodules` shape that already carries the failing path. ### medium — git worktree remove --force is gated on a .gitmodules file, but Git refuses on populated Gitlinks, so a merged worktree with a Gitlink and no .gitmodules can never be removed - claim: `01M3BJX1D6ARVSHY1Y8V11MZT2` - anchor: `skills/audit-git-checkouts/scripts/audit-checkouts.sh` (snippet) ``` if [ -e "$worktree_path/.gitmodules" ]; then worktree_remove_args+=(--force) fi ``` - lens: general-bug · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: `git worktree remove`'s submodule refusal in git 2.47.3, the `--force` decision in `decide_removal_outcome` (scripts/audit-checkouts.sh lines 1101-1104), the new Gitlink-based walker `list_submodules_with_local_work` (line 494), and the paragraph this change rewrote in references/removal-gates.md: "Worktrees containing `.gitmodules` are removed with `--force`, because Git otherwise refuses any worktree with submodules". > > What the code does: the driver adds `--force` only when a `.gitmodules` file exists in the worktree: > > ``` > worktree_remove_args=(worktree remove) > if [ -e "$worktree_path/.gitmodules" ]; then > worktree_remove_args+=(--force) > fi > ``` > > Git's refusal is not keyed on `.gitmodules`. It scans the worktree's index for Gitlink entries and refuses if any of them resolves to a real git dir — the same basis the new walker uses ("Submodules come from the Gitlinks in HEAD and the index, not from .gitmodules, so neither a deleted .gitmodules nor an `ignore` setting hides one"). So the presence test and the refusal disagree exactly where this change says they must not. > > Reproduction I ran (git 2.47.3, this sandbox): > > ``` > git init -b main sub; (commit one file) # S = its HEAD > git init -b main r; (commit g) > git -C r update-index --add --cacheinfo 160000,$S,dep > git -C r commit -m "gitlink without .gitmodules" > git -C r branch feature > git -C r worktree add ../wt feature > git clone sub wt/dep # populate the submodule > git -C wt status --porcelain # empty: the worktree is clean > git -C r worktree remove ../wt > -> fatal: working trees containing submodules cannot be moved or removed (exit 128) > ``` > > With the same worktree left unpopulated (`dep/` an empty directory) the removal succeeds, which confirms the refusal keys on a *populated* Gitlink, not on `.gitmodules`. > > What goes wrong: a linked worktree that is clean, proven merged, unlocked, and passes the new submodule gate — but whose committed tree has a populated Gitlink and no `.gitmodules` file — is invoked without `--force`, Git refuses, and the outcome is `operational/removal-failed`. The report then tells the owner to "Fix the failing query, remote, registration, or filesystem, then rerun", which will never clear it: the next run repeats the same call. A repository reaches this state whenever a Gitlink was committed without a `.gitmodules` entry (`git add <nested-clone>` produces exactly that), or a commit removed `.gitmodules` while leaving the Gitlink in the index. > > No data loss: removal simply never happens, so severity is degraded behaviour rather than destruction. > > Safe correction: decide `--force` from Gitlinks instead of the file — e.g. reuse the walker's own source, `git -C "$worktree_path" ls-files --stage -z | ... '$1 ~ /^160000 /'`, and add `--force` when any such path has a resolvable git dir. The removal-gates.md sentence should be corrected at the same time, since "Worktrees containing `.gitmodules`" is not the set Git refuses. > > Proof gap: I reproduced Git's refusal and the populated/unpopulated distinction directly, but I could not run `decide_removal_outcome` end to end (`jq` is not installed in this sandbox), so the `operational/removal-failed` outcome is traced from the code rather than observed. ### medium — The new submodule removal-gate test stages uncommitted submodule work but only asserts the status-query-failure path, so the uncommitted-files and stash gates are unprotected - claim: `01M3BK8QQK8CCEE9262AAYWE8A` - anchor: `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs` (snippet) ``` // Nor must a submodule whose status query fails, even with uncommitted work the superproject cannot see. writeFileSync(join(dependencyPath, "README.md"), "uncommitted\n"); const dependencyIndex = join(dependencyGitDir, "index"); const healthyIndex = readFileSync(dependencyIndex); writeFileSync(dependencyIndex, "not an index"); ``` - lens: test-trimming · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the new test `a merged worktree is kept while its submodules hold work that exists only there` in `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs`, the new `submodule_holds_local_work` helper it drives in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, and the contract the change writes into `references/removal-gates.md`. > > What the subject says: `removal-gates.md` enumerates four independent keeping conditions -- "A submodule keeps the worktree (`judgment/submodule-local-work`, paths in `removal.error`) when it has uncommitted files, a stash, a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds." The removal branch of the helper implements them in order: > > ```sh > output=$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head" refs/remotes) || return 2 > [ -z "$output" ] && return 0 > output=$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all) || return 2 > [ -n "$output" ] && return 0 > git -C "$submodule_path" rev-parse --verify --quiet refs/stash >/dev/null && return 0 > output=$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes) || return 2 > [ -n "$output" ] && return 0 > ``` > > The test walks the submodule through five states and asserts the outcome each time: HEAD held by no remote-tracking ref, a `wip` tag on an unreachable commit, a recorded-but-unheld Gitlink, an unreadable submodule, and a submodule whose `status` query fails. The anchored stage is the only one that puts uncommitted content in the submodule -- and it simultaneously corrupts `$GIT_DIR/index` with `writeFileSync(dependencyIndex, "not an index")`, so `git status` exits non-zero and the helper returns 2. The assertions that follow are `operational/gate-check-failed` and `/cannot inspect submodule dependency/`, i.e. the error path, not the keeping path. Two lines later the index is restored and `git(dependencyPath, "checkout", "--quiet", "--", "README.md")` discards the uncommitted file, so the final `assert.equal(removed.removal.outcome, "removed")` stage runs against a clean submodule. No stage anywhere in the suite creates a stash in a submodule (`grep -n 'stash' audit-checkouts.test.mjs` finds only superproject stash tests unrelated to this gate). > > What goes wrong: the two middle gates are asserted by nothing, while the test's title and the staged `"uncommitted\n"` write read as if they are covered. I confirmed this by mutation, running the whole file (`node --test skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs`, baseline 64 tests / 63 pass / 0 fail): > > - Deleting only `[ -n "$output" ] && return 0` after the `status --porcelain` call (keeping the call so the `|| return 2` error path is unchanged): 64 tests, 63 pass, 0 fail. > - Deleting the whole `rev-parse --verify --quiet refs/stash ... && return 0` line: 64 tests, 63 pass, 0 fail. > > So a regression that lets `audit-checkouts.sh --remove` delete a linked worktree whose submodule holds uncommitted edits, untracked files, or a stash -- work that `git worktree remove --force` destroys along with the submodule's Git directory, and which the superproject cannot even see because the fixture's `.gitmodules` sets `ignore = all` -- ships green. For contrast, the gates the test does assert are protected: removing the fast-forward stranded gate fails 4 tests, removing the local-tag self-exclusion fails 1. > > Suggested repair (not deletion -- the test protects real behaviour): add two stages to this same test, before the index-corruption stage, while HEAD is held by `refs/remotes/origin/main` and no local branch or tag exists. First write an uncommitted `README.md`, `writeResult()`, and assert `judgment/submodule-local-work` with `removal.error.trim() === "dependency"`; then restore the file, `git stash` a change inside the submodule, and assert the same outcome, dropping the stash afterwards. Both stages reuse the existing fixture and `runMaybeRemove` helper, and each kills one of the two surviving mutants above. > > What would refute the claim: another test that drives `submodule_holds_local_work` (or `maybe_remove_worktree`) with a dirty-but-readable submodule or a submodule stash and asserts a non-removal outcome. I found none, and the two mutation runs above are the decisive evidence that none exists. ### medium — stepFailure returns "submodule check failed; checkout not updated" for every worktree, hiding a linked worktree's real removal outcome and dropping it from all other report sections - claim: `01M3BJWCTMM2QXQWEPKZA7TSHJ` - anchor: `skills/audit-git-checkouts/scripts/render-audit-report.ts` (snippet) ``` if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated"; ``` - lens: general-bug · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: `stepFailure` and the per-worktree loop in `formatAuditReport` (scripts/render-audit-report.ts), and the shell that produces the fields it reads (`audit_worktree` line 1544 and `decide_removal_outcome` lines 1051-1062 plus the `case "$outcome"` at line 1153 in scripts/audit-checkouts.sh). > > What the code does: the new first line of `stepFailure` is `if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";`. It runs ahead of every other branch in that function, and `formatAuditReport` calls `stepFailure` for *every* in-root worktree, not just primaries and default-branch checkouts: > > ``` > const failure = stepFailure(worktree, report.fetched); > if (failure !== null) { failures.push([path, failure]); continue; } > ``` > > What goes wrong, for a linked worktree on a feature branch: > > 1. The message is wrong on its face. A linked non-default worktree is never a fast-forward or selector-move candidate — `stranded_submodules` gates only the `fast_forward_mode` and `tracking_update_eligible` conditions, both of which additionally require `branch.current == default_branch`. "checkout not updated" tells the owner an update was withheld when none was ever pending. > > 2. It hides the specific diagnosis the shell did capture. When a submodule is unreadable, both walker calls fail the same way, so `decide_removal_outcome` returns `operational/gate-check-failed` with `cannot read submodule <path>` in `removal.error` (the change's own test asserts exactly that: `assert.match(unreadable.removal.error, /cannot read submodule dependency/)`). Because the new check precedes `if (outcome.startsWith("operational/"))`, the row reads `submodule check failed; checkout not updated` instead of `removal check failed (gate-check-failed): cannot read submodule dependency`. Before this change the specific message was what the report printed, so this is a straight loss of the actionable text. > > 3. The `continue` drops the worktree from every other section. It never reaches `keptReason`, so a merged worktree also blocked by a lock or by precious ignored files loses that reason, and it never reaches the activity branches, so it vanishes from "Possibly abandoned", "Active work", and "Activity unknown" as well. The worktree count still includes it, so the Summary and the tables disagree about it. > > Failure scenario, concretely: a linked worktree `~/dev/app-x` on branch `x` whose populated submodule `dep` has a dangling `.git` gitdir pointer. The driver records `strandedSubmodules: null` and `removal.outcome: "operational/gate-check-failed"` with `removal.error: "cannot read submodule dep\n"`. The report shows a single row `| app-x | submodule check failed; checkout not updated |`, and `app-x` appears in no other table — no path to the broken submodule, and no hint that it was a removal candidate at all. > > Evidence: static trace of scripts/render-audit-report.ts against the JSON shape written by `audit_worktree`; `jq` is absent from this sandbox so I could not run the driver and render a real report, and the new render tests only cover the default-branch case (`strandedSubmodules: null` on a worktree they mark as the repository's checkout) — no test exercises a linked worktree with a null value. > > Safe correction: move the null check after the removal-outcome branches, and scope its wording to checkouts that were actually update candidates (or drop the ", checkout not updated" clause and append the captured submodule path instead). ### medium — The stranded-submodule row overrides primaryDecision for every primary checkout, so detached and off-default primaries lose their real reason and are compared against the wrong line - claim: `01M3BJZHXK440NSNFW2AJ720B6` - anchor: `skills/audit-git-checkouts/scripts/render-audit-report.ts` (snippet) ``` if ((isMain || onDefault) && stranded.length > 0) { const why = `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated`; decisions.push([path, branch, formatAheadBehind(worktree.defaultComparison), why]); continue; } ``` - lens: general-bug · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the new block in `formatAuditReport` (scripts/render-audit-report.ts, the `if ((isMain || onDefault) && stranded.length > 0)` branch), `primaryDecision` in the same file (cases `current-pinned-reference`, `pinned-reference-needs-attention`, and the `default:` arm), and the two gates in scripts/audit-checkouts.sh that `stranded_submodules` actually controls (`fast_forward_mode`, lines 1553-1565, and `tracking_update_eligible`, lines 1599-1614). > > What the code does: the new branch fires on `isMain || onDefault`, i.e. on *any* primary checkout, regardless of what that primary is checked out at, and it `continue`s so `primaryDecision` never runs. It renders `formatAheadBehind(worktree.defaultComparison)` in the Ahead/behind cell and the fixed phrase `... ; not updated` in Why. > > What the shell actually gates: both conjuncts guarded by `[ "$stranded_submodules" = '[]' ]` also require `[ "$(jq -r '.branch.isDetached == false' "$status_path")" = true ]` and `[ "$(jq -r '.branch.current // empty' "$status_path")" = "$default_branch" ]`. So for a primary checkout that is detached, or on a branch other than the default, the stranded list withheld nothing — that checkout was never a fast-forward or selector-move candidate in the first place. > > What goes wrong, for a primary checkout that is a pinned reference (detached at a tag or commit) and has a stranded submodule: > > - The Why cell claims "not updated", inventing a suppressed update that was never possible. The correct decision text for that checkout is `primaryDecision`'s `pinned-reference-needs-attention` / `current-pinned-reference` wording ("detached checkout is behind or off <target>", "detached checkout with N uncommitted files"), which the `continue` discards. > - The Ahead/behind cell uses `defaultComparison`, while every other code path for a pinned reference deliberately uses `checkoutComparison` — the two `primaryDecision` cases above are explicit about it. The reader is shown the distance from `origin/<default>`, which is not the line a pinned checkout tracks, labelled as though it were. > - A pinned reference that is genuinely behind its own line, or a primary checkout sitting on the wrong branch (`default:` arm: "primary checkout is on X, not main"), loses that actionable reason entirely and gets a submodule note instead. > > Failure scenario: a primary checkout `vendor/tool` detached at tag `v3.1` with a populated submodule `dep` on a local commit no ref holds, and upstream now at `v4.0`. Before the change the row read `| vendor/tool | (detached) | +0/-N | detached checkout is behind or off refs/tags/v4.0 |`. Now it reads `| vendor/tool | (detached) | <defaultComparison> | submodule on a commit no ref holds: dep; not updated |` — the behind-its-line signal is gone and the comparison shown is against a different target. > > Safe correction: restrict the branch to the checkouts the shell gates — require `onDefault` (not `isMain`) — or, if primaries should still surface stranded submodules, append the submodule note to `primaryDecision`'s result instead of replacing it, and keep the comparison `primaryDecision` chose for that classification. > > Evidence and proof gap: static trace of the renderer against the gate conditions in the shell. `jq` is not installed in this sandbox, so I could not run the driver and diff two rendered reports; the two new render tests cover only an `onDefault` worktree with `deferredOnly: true` and a null-valued one, so neither exercises a detached or off-default primary. ### low — The mode table's "What changes" cell now restates the submodule behavior that the bullet three lines below and the reference already give - claim: `01M3BJD43THCCSVM63RWPDHTDZ` - anchor: `skills/audit-git-checkouts/SKILL.md` (snippet) ``` Fetch and prune `origin`; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged; ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the "Choose the mode" table and the stricter-mode bullet list directly under it in `skills/audit-git-checkouts/SKILL.md`, plus the first-party bullet in `references/checkout-updates.md`. > > What the subject says: the default-mode cell grew from "fast-forward eligible default checkouts" to two added clauses -- "then initialize their submodules and check them out at the recorded commits" and "in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged". Three lines later, the new bullet says the same two things again: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules". The reference says them a third time: "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move" and "checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit". > > What goes wrong: the writing standard gives each instruction one home, says not to restate what an earlier sentence already says, and says to cut most aggressively from the content loaded most often -- SKILL.md body text loads on every invocation, the reference only when the agent opens it. Three costs follow. The cell is now a 60-word chain of five semicolon-joined clauses in a table whose job is letting a reader pick a mode at a glance, so the column no longer scans. The two copies are not word-for-word, so a reader must reconcile "initialize their submodules and check them out at the recorded commits" with "checks every submodule, third-party ones included, out at its recorded commit" and decide whether the third-party scope is a difference or a restatement. And the `.gitmodules`-listing and unstaged-Gitlink details are decision-time facts for someone already reconciling submodules, not mode-selection facts for someone choosing flags. > > Correction: return the cell to the granularity of its neighbours -- "Fetch and prune `origin`; fast-forward eligible default checkouts and move their submodules (see below); prune stale worktree registrations; remove proven-merged linked worktrees inside the root." -- and let the bullet below carry the recorded-commit and first-party-selector detail it already carries. Nothing is lost: every fact stays in the file, once, where the reader needs it. > > What would establish or refute it: the two passages are quoted above from the same file, eleven lines apart; the duplication is on the page. It would be refuted if the table cell were the only statement of either fact, which the quoted bullet shows it is not. ### low — The reference's failure list omits the new "submodule check failed; checkout not updated" outcome and sends its reader to repair a state the driver never touched - claim: `01M3BJFJHNJM26BM2SDNQ4X9VX` - anchor: `skills/audit-git-checkouts/references/checkout-updates.md` (snippet) ``` A fast-forward, submodule sync, or selector update can fail after an earlier step succeeded. Inspect the actual HEAD, index, and submodule state before repairing; never describe a failed multi-step update as atomic, and never force a refused merge. ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the "What the driver already updates" section of `skills/audit-git-checkouts/references/checkout-updates.md`, the `stepFailure` function in `scripts/render-audit-report.ts`, and the `stranded_submodules` handling in `audit_worktree` in `scripts/audit-checkouts.sh`. > > What the change adds: the driver now runs `list_submodules_with_local_work "$worktree_path" update` before deciding eligibility, and when that walk fails it sets `stranded_submodules=null`. Both eligibility gates then test `[ "$stranded_submodules" = '[]' ]`, so a null blocks the fast-forward and the selector move alike. The renderer turns that null into a new failure row: `if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";`. > > What the prose says: the reference names four ways a checkout goes unupdated -- not fresh, local commits, not behind, or a dirty tree that is not deferred guidance or first-party selector maintenance -- plus the new stranded-submodule rule, and then says "A fast-forward, submodule sync, or selector update can fail after an earlier step succeeded. Inspect the actual HEAD, index, and submodule state before repairing". Those three names match the other three `stepFailure` strings exactly ("fast-forward failed", "submodule sync failed after fast-forward", "submodule selector update failed"). The fourth string has no entry anywhere in the reference; grepping it for "check failed" returns nothing. > > What goes wrong: SKILL.md routes "a default checkout that was not fast-forwarded" to this reference, so this is where an agent looks after reading "submodule check failed; checkout not updated" in `report.md`. It finds a closed enumeration of three failures that does not include theirs, and the one instruction that seems to apply -- inspect HEAD, index, and submodule state "before repairing", on the premise that an earlier step succeeded -- is wrong for this case: nothing was attempted, so there is no half-applied update to repair. The remedy is the opposite kind of action, making the unreadable submodule readable (the walk emits `cannot read submodule <path>` or `cannot inspect submodule <path>`) and rerunning. Sending an agent to reconcile HEAD and index on an untouched checkout is the mistake the section exists to prevent. > > Correction: add one sentence beside the stranded-submodule rule -- "When the submodule walk itself fails, the driver also skips the fast-forward and every selector move, changes nothing, and the report says the submodule check failed; the driver's stderr names the unreadable submodule. Make it readable and rerun." That preserves the existing paragraph and closes the enumeration against the fourth outcome. > > What would establish or refute it: the claim rests on `strandedSubmodules === null` producing a distinct report string with no reference entry, and on the null path skipping both updates. It would be refuted if some other reference documented the string; `references/removal-gates.md` documents only the removal-side `operational/...` outcomes, and its `judgment/submodule-local-work` row covers removal, not updates. ### low — "rerun once ... they authorize discarding it" sends the agent back into the same gate: authorization alone does not change what the walk sees - claim: `01M3BJGHNSVNR7A66RVMKR11BX` - anchor: `skills/audit-git-checkouts/references/removal-gates.md` (snippet) ``` The listed submodules hold work only this worktree has. Show the owner what each holds; rerun once it is pushed or they authorize discarding it. ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the new `judgment/submodule-local-work` row in the outcome table of `skills/audit-git-checkouts/references/removal-gates.md`, the neighbouring rows in that table, and `submodule_holds_local_work` plus the gate that calls it in `scripts/audit-checkouts.sh`. > > What the subject says: "Show the owner what each holds; rerun once it is pushed or they authorize discarding it." > > What goes wrong: the two branches of that disjunction are not the same kind of thing. "once it is pushed" names a state change the gate can see -- pushing updates `refs/remotes/origin/<branch>`, and `submodule_holds_local_work` then finds the HEAD contained by `for-each-ref --contains "$head" refs/remotes` and returns 1. "once ... they authorize discarding it" names only permission. Nothing in the submodule changes when the owner says yes, so the rerun re-walks the same uncommitted files, the same stash, and the same unpushed commits, emits `judgment/submodule-local-work` again, and the worktree is kept a second time. An agent following the row literally loops, or reports to the user that the driver refuses to honour the authorization they just gave. > > The adjacent rows get this right by naming the act, not the permission: `judgment/precious-ignored-files` says "Leave `preciousPaths` in place until the owner disposes of them or authorizes their deletion, then rerun" -- disposal happens first -- and `judgment/hidden-index-flags` says "Have the owner clear the flags they set, prove the revealed tree clean, then rerun." > > Correction: make the second branch a state change too, for example "rerun once the work is pushed; if the owner authorizes discarding it instead, discard it in the submodule first, then rerun." The row's useful content -- show the owner what each submodule holds, and never discard without their say-so, which is SKILL.md's "Treat user work as untouchable" -- is preserved; only the trigger for the rerun becomes something the gate can observe. > > What would establish or refute it: `submodule_holds_local_work` reads only the submodule's working tree, stash, refs and remote-tracking refs; it takes no authorization input, and no caller passes one. It would be refuted if some flag or environment variable let a rerun bypass the gate -- the change adds none, and `--no-remove` only makes the driver keep more worktrees, not fewer. ### low — The new help text says a ref "keeps" a commit where every other file says "holds", and "work no remote-tracking ref keeps is" garden-paths the reader - claim: `01M3BJDYR88CZ3HMK9ZDZWETFH` - anchor: `skills/audit-git-checkouts/scripts/audit-checkouts.sh` (snippet) ``` echo " selector tag is replaced only when the fetched tag or another ref keeps its commit; a" echo " worktree whose submodules hold work no remote-tracking ref keeps is not removed." ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the three help lines the change adds to `usage()` in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, and every other place in the skill that names the same relation: `SKILL.md`, `references/removal-gates.md`, `references/checkout-updates.md`, the new comment above `list_submodules_with_local_work`, and the report strings in `scripts/render-audit-report.ts`. > > What the subject says: the help text uses "keeps" twice for "some ref contains this commit" -- "a local selector tag is replaced only when the fetched tag or another ref keeps its commit" and "a worktree whose submodules hold work no remote-tracking ref keeps is not removed". > > Everywhere else the skill calls that relation "holds": SKILL.md "commits no other ref holds"; removal-gates.md "a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds"; checkout-updates.md "a commit that its superproject does not record and no ref holds" and, for the identical tag rule this help line restates, "another ref holds it"; the script's own comment "nor held by any ref in the submodule"; render-audit-report.ts "submodule on a commit no ref holds". The line immediately above even uses the right word -- "neither recorded nor held by a ref" -- so the two spellings sit three lines apart. > > What goes wrong: the writing standard asks for one term per concept, and the collision here is not hypothetical. These same documents already use "keep" for a different relation: "A Gitlink names a commit without keeping it" and "A local tag ... is kept as it is" (retention), and "A submodule keeps the worktree" (blocks removal). A reader who meets "another ref keeps its commit" must decide which of those three senses applies before they can read the rule. The second sentence compounds it: "work no remote-tracking ref keeps is not removed" puts a reduced relative clause between subject and verb and lands "keeps is" adjacent, so the reader parses "keeps" as the main verb and has to restart. `--help` is the first thing SKILL.md tells a reader to run, which is the worst place for a sentence that needs a second pass. > > Correction: use "holds" in both, and give the second one a verb it cannot be mistaken for: "... a local selector tag is replaced only when the fetched tag or another ref holds its commit; a worktree is not removed while a submodule holds work that no remote-tracking ref holds." The rules and their scope are unchanged; only the term and the clause order move. > > What would establish or refute it: the quoted occurrences are from the subject tree as listed above. It would be refuted if "keeps" named a distinct relation from "holds" here -- but the tag clause is the same rule `references/checkout-updates.md` states with "holds", and both compile down to the same `for-each-ref --contains` test in `update_submodule_tag` and `submodule_holds_local_work`. ### low — A git ls-files failure in list_submodules_with_local_work cannot be detected, so the submodule safety gate fails open instead of closed - claim: `01M3BJYBGZFFQGBD05BS5YHCYM` - anchor: `skills/audit-git-checkouts/scripts/audit-checkouts.sh` (snippet) ``` gitlink_paths=$({ git -C "$superproject_path" ls-files --stage -z git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true } | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1 ``` - lens: general-bug · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: `list_submodules_with_local_work` (scripts/audit-checkouts.sh lines 494-525), its own contract comment two lines below ("A submodule the walk cannot read may hold anything; fail rather than report it empty"), `set -o pipefail` at line 3, and both call sites — the removal gate at line 1052 and the update check at line 1544. > > What the code does: the walker's ref-discovery step is > > ``` > gitlink_paths=$({ > git -C "$superproject_path" ls-files --stage -z > git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true > } | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1 > ``` > > `pipefail` only inspects the exit status of each *pipeline element*, and the first element is the brace group. A brace group's status is that of its last command, which here is `git ls-tree ... || true` — always 0. So a `git ls-files --stage -z` failure cannot reach the `|| return 1`: the union silently loses whatever the index would have contributed, and the walker reports success. > > Observed, in this sandbox (git 2.47.3), against a superproject with one Gitlink and a deliberately truncated `.git/index`: > > ``` > $ { git ls-files --stage -z; git ls-tree -r -z --full-tree HEAD 2>/dev/null || true; } \ > | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u; echo "pipe rc=$?" > fatal: .git/index: index file smaller than expected > dep > pipe rc=0 > ``` > > and calling the function itself returned `rc=0` with the same `fatal:` on stderr — no `return 1`. > > What goes wrong: the walker is a safety gate whose stated design is to fail closed, and this path fails open. When `ls-files` is the only source that would have named a Gitlink — a Gitlink present in the index but not in HEAD — a failed index read makes `list_submodules_with_local_work` print nothing and return 0. In `removal` mode that is indistinguishable from "no submodule holds work", so the gate passes and `git worktree remove --force` proceeds to delete the worktree's submodule git dirs; in `update` mode it yields `strandedSubmodules: []`, which un-gates the fast-forward and the selector move. > > Honest limit on reachability, which is why I am filing this low rather than high: the trigger I could construct (a corrupt index) also breaks the `git status --porcelain` re-check that immediately follows the gate, so that specific path ends in `operational/gate-check-failed` rather than in a deletion, and I could not construct a case where `ls-files --stage -z` fails while `status` succeeds. I am claiming the masked failure as a defect in a gate whose own comment promises the opposite, not a demonstrated data-loss path. > > Safe correction: capture the two listings separately and check each, e.g. `index_paths=$(git -C "$superproject_path" ls-files --stage -z) || return 1` and then union the already-validated text, so neither source's failure can read as "no submodules". ### low — The tag-selector test never exercises the "another ref holds it" half of update_submodule_tag, so that allowance can be deleted with the suite green - claim: `01M3BKCTPMEA51K2Z53GH578NA` - anchor: `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs` (snippet) ``` const refused = audit(); assert.equal(refused.trackingUpdate.attempted, true); assert.equal(refused.trackingUpdate.ok, false); assert.match(refused.trackingUpdate.error, /local tag v1 .* no other ref holds/); ``` - lens: test-trimming · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > What I examined: the new test `a configured tag follows origin unless the local tag holds a commit no other ref holds` in `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs`, the new `update_submodule_tag` helper it drives in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, and the contract stated in `references/checkout-updates.md`. > > What the subject says: `checkout-updates.md` states the rule as a disjunction -- "A local tag of the configured name on the same commit is kept as it is. One on another commit is replaced only when the fetched tag's commit contains that commit or another ref holds it." The helper implements exactly that, with two independent escape hatches: > > ```sh > if ! git -C "$submodule_path" merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null \ > && [ -z "$(git -C "$submodule_path" for-each-ref --format='%(refname)' --contains "$local_commit" 2>/dev/null | grep -v -x -F "refs/tags/$tag")" ]; then > echo "local tag $tag in $submodule_path holds commit $local_commit that no other ref holds; not replacing it with origin's $fetched_commit" >>"$error_path" > return 1 > fi > ``` > > What the test does: its two "followed" stages re-tag `v1` in the upstream submodule at a descendant of the local tag's commit, so `merge-base --is-ancestor` succeeds and the first hatch alone permits the replacement. The anchored "refused" stage builds the opposite case -- `commitDetached(dependencyPath)` creates `localCommit` as a *child* of `thirdCommit`, then `git tag --force v1` moves the local tag onto it, so `localCommit` is not an ancestor of the fetched commit and `refs/tags/v1` is the only ref that holds it. Both branches of the `&&` are false, and the refusal is asserted. No stage ever reaches the state where `merge-base --is-ancestor` fails but another ref does hold `$local_commit` -- the only state in which the second hatch decides the outcome. > > What goes wrong: the `for-each-ref --contains` clause -- half of the documented predicate, and the half the test's own title names -- is asserted by nothing. I confirmed this by mutation. Baseline: `node --test skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs` gives 64 tests / 63 pass / 0 fail. Deleting the entire second clause, so the condition reduces to `if ! git ... merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null; then`, still gives 64 tests / 63 pass / 0 fail. Under that mutant the driver refuses every selector update whose local tag commit is not an ancestor of the fetched tag, even when a branch or another tag still holds it -- so a routine re-tag onto a sibling line reports `trackingUpdate.ok: false` with "no other ref holds", the submodule stops floating, and the report shows a failure the owner cannot act on. That regression ships green. For contrast, the self-exclusion `grep -v -x -F "refs/tags/$tag"` inside that same clause *is* protected: deleting it fails this test (1 failure), because without it the local tag itself would count as "another ref". > > Suggested repair (not deletion -- this test protects the refusal correctly): extend it with one more stage that distinguishes the hatches. After the existing refused stage, point a second ref at `localCommit` inside the submodule -- `git(dependencyPath, "branch", "keep", localCommit)` -- and `audit()` again, asserting `trackingUpdate.ok === true` and `git rev-parse v1^{commit} === thirdCommit` (the tag now follows origin because `refs/heads/keep` keeps the local commit). That stage reuses the existing fixture and kills the surviving mutant above. > > What would refute the claim: another test that makes `merge-base --is-ancestor` fail while a non-`refs/tags/<tag>` ref holds the local tag's commit and asserts the tag is replaced. I found none in either changed test file, and the mutation run above is the decisive evidence that none exists. ## Other claims - grounding-pending (0) - ungrounded (0) - rejected (1) - `01M3BJF1C8Y7SDRTS25DV3DSQQ` low — Test comment "Nor must a submodule whose status query fails" is a predicate-less fragment whose negation states the opposite of the assertion below it - duplicate-of (1) - `01M3BJXKD6FR4GJBVEJ0FVG22D` low — --help still advertises "schema 5" after the report was bumped to schemaVersion 6 → `01M3BJAD5HST20NAJ8TXEPNNJR` - unadjudicated (0) ## Coverage Coverage pass: 01M3BJ3S4H2RMJ1PD1ZWC9K7FR Accounting: complete Slot health: healthy | lens | part | arm | unit status | runs | loss | | --- | --- | --- | --- | --- | --- | | general-bug | whole | default | claims-emitted | 1 | no | | writing-quality | whole | default | claims-emitted | 1 | no | | test-trimming | whole | default | claims-emitted | 1 | no |
@ -16,3 +16,3 @@
| Request | Flags | What changes |
| --- | --- | --- |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts and sync their submodules; in first-party default checkouts, move submodules to their configured branch or tag, leaving the Gitlink change unstaged; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |

high — Default selector updates abandon unreferenced submodule commits
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined the new default-mode contract in SKILL.md, synchronize_first_party_tracking_submodules, its audit_worktree caller, is_deferred_guidance_path, and the first-party tracking tests. The helper runs git submodule update --init --checkout and then git checkout --detach at the configured branch or tag. Meanwhile, the caller treats any changed first-party Gitlink with a selector as deferred guidance, so a submodule that is already at a local commit can still be eligible for this automatic move. In a minimal Git 2.47.3 reproduction, I created an unreferenced commit in the initialized submodule and invoked the shipped helper: submodule HEAD changed from 8287a8a29e5d8ace5321893ac3742cd681612933 to the selector target 642a5fbc81dab0c444afe6146fc345ebe3230902, and git for-each-ref --contains 8287a8a29e5d8ace5321893ac3742cd681612933 returned no refs. The commit survives only through reflog/object retention and may later be pruned, contradicting the skill contract that commits no other ref holds are untouchable. A safe correction is to inspect initialized submodules recursively before either synchronization path and refuse/report when HEAD, the worktree, index, ignored content, or nested state contains user work; a full driver run with jq/repoq would refute the caller-path claim if its status representation prevents this eligibility, but the existing fake-repoq tracking test and static path classification both show the changed Gitlink is intentionally admitted.

claim 01M39S02MAT0TTV614JNDM127E of review 01M39RTBF4DNVQ28DF97Z9ZE21

<!-- review:claim:01M39S02MAT0TTV614JNDM127E --> **high** — Default selector updates abandon unreferenced submodule commits lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined the new default-mode contract in SKILL.md, synchronize_first_party_tracking_submodules, its audit_worktree caller, is_deferred_guidance_path, and the first-party tracking tests. The helper runs `git submodule update --init --checkout` and then `git checkout --detach` at the configured branch or tag. Meanwhile, the caller treats any changed first-party Gitlink with a selector as deferred guidance, so a submodule that is already at a local commit can still be eligible for this automatic move. In a minimal Git 2.47.3 reproduction, I created an unreferenced commit in the initialized submodule and invoked the shipped helper: submodule HEAD changed from 8287a8a29e5d8ace5321893ac3742cd681612933 to the selector target 642a5fbc81dab0c444afe6146fc345ebe3230902, and `git for-each-ref --contains 8287a8a29e5d8ace5321893ac3742cd681612933` returned no refs. The commit survives only through reflog/object retention and may later be pruned, contradicting the skill contract that commits no other ref holds are untouchable. A safe correction is to inspect initialized submodules recursively before either synchronization path and refuse/report when HEAD, the worktree, index, ignored content, or nested state contains user work; a full driver run with jq/repoq would refute the caller-path claim if its status representation prevents this eligibility, but the existing fake-repoq tracking test and static path classification both show the changed Gitlink is intentionally admitted. claim `01M39S02MAT0TTV614JNDM127E` of review `01M39RTBF4DNVQ28DF97Z9ZE21`

high — Configured-tag updates overwrite local submodule tags
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined the new default-mode statement, synchronize_first_party_tracking_submodules, write_submodule_metadata, and the clean-checkout eligibility path in audit_worktree. For a configured tag the helper executes git fetch --force origin refs/tags/<tag>:refs/tags/<tag>, so an identically named local tag is overwritten without inspection or confirmation; metadata collection records a mismatch but does not make the entry ineligible. I reproduced this on Git 2.47.3 with a completely clean superproject: local submodule tag v1 initially resolved to unique commit 2f0c6a7012dc17c3a605fef0069278759ad184e0, the remote v1 resolved to 80a6066d7787271c0a04eaf7149b93e83d66dd06, and invoking the shipped helper changed both the tag and submodule HEAD to the remote commit. git for-each-ref --contains then reported zero refs for the local commit. Thus an ordinary audit can silently destroy a user-created tag and leave its commit eligible for eventual pruning even when the checkout was initially clean. A safe correction is to fetch the remote tag into a temporary remote-tracking namespace, compare it with any existing local tag, and report/refuse a collision rather than force-updating the local tag; reproducing with a tag that already matches the remote would refute only the collision case, not this divergent-tag failure.

claim 01M39S1NH64NYPDQ30RW6K760S of review 01M39RTBF4DNVQ28DF97Z9ZE21

<!-- review:claim:01M39S1NH64NYPDQ30RW6K760S --> **high** — Configured-tag updates overwrite local submodule tags lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined the new default-mode statement, synchronize_first_party_tracking_submodules, write_submodule_metadata, and the clean-checkout eligibility path in audit_worktree. For a configured tag the helper executes `git fetch --force origin refs/tags/<tag>:refs/tags/<tag>`, so an identically named local tag is overwritten without inspection or confirmation; metadata collection records a mismatch but does not make the entry ineligible. I reproduced this on Git 2.47.3 with a completely clean superproject: local submodule tag `v1` initially resolved to unique commit 2f0c6a7012dc17c3a605fef0069278759ad184e0, the remote `v1` resolved to 80a6066d7787271c0a04eaf7149b93e83d66dd06, and invoking the shipped helper changed both the tag and submodule HEAD to the remote commit. `git for-each-ref --contains` then reported zero refs for the local commit. Thus an ordinary audit can silently destroy a user-created tag and leave its commit eligible for eventual pruning even when the checkout was initially clean. A safe correction is to fetch the remote tag into a temporary remote-tracking namespace, compare it with any existing local tag, and report/refuse a collision rather than force-updating the local tag; reproducing with a tag that already matches the remote would refute only the collision case, not this divergent-tag failure. claim `01M39S1NH64NYPDQ30RW6K760S` of review `01M39RTBF4DNVQ28DF97Z9ZE21`

medium — The mode table hides submodule initialization behind “sync”
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined the changed default-mode row, the complete skill, references/checkout-updates.md, the audit_worktree call site, synchronize_submodules, and the submodule synchronization test. The row says the driver will “sync their submodules,” but after a successful fast-forward the implementation runs git submodule sync --recursive followed by git submodule update --init --recursive --checkout. That second command can initialize previously absent submodules and checks every committed submodule out at the recorded Gitlink; the call is not made when no fast-forward occurs. Because this is the table that tells an agent what the autonomous default mode changes, “sync” neither discloses the initialization and network work nor distinguishes the Git URL-sync operation from checkout reconciliation. A reader can therefore select the default mode without realizing that an audit may populate an intentionally uninitialized, large, or credential-gated submodule. Replace this clause with wording such as “after a successful fast-forward, initialize committed submodules and check them out at their recorded Gitlinks”; keep the following first-party-selector clause for the separate floating behavior. This preserves the useful promise that submodules follow an updated superproject while making the timing and filesystem effect checkable. The claim would be refuted by a driver path that does not pass --init or by an explicit local definition of “sync” that includes initialization; neither appears in the skill or reference.

claim 01M39RYNHTH5TR2P6BDC96B9N0 of review 01M39RTBF4DNVQ28DF97Z9ZE21

<!-- review:claim:01M39RYNHTH5TR2P6BDC96B9N0 --> **medium** — The mode table hides submodule initialization behind “sync” lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined the changed default-mode row, the complete skill, `references/checkout-updates.md`, the `audit_worktree` call site, `synchronize_submodules`, and the submodule synchronization test. The row says the driver will “sync their submodules,” but after a successful fast-forward the implementation runs `git submodule sync --recursive` followed by `git submodule update --init --recursive --checkout`. That second command can initialize previously absent submodules and checks every committed submodule out at the recorded Gitlink; the call is not made when no fast-forward occurs. Because this is the table that tells an agent what the autonomous default mode changes, “sync” neither discloses the initialization and network work nor distinguishes the Git URL-sync operation from checkout reconciliation. A reader can therefore select the default mode without realizing that an audit may populate an intentionally uninitialized, large, or credential-gated submodule. Replace this clause with wording such as “after a successful fast-forward, initialize committed submodules and check them out at their recorded Gitlinks”; keep the following first-party-selector clause for the separate floating behavior. This preserves the useful promise that submodules follow an updated superproject while making the timing and filesystem effect checkable. The claim would be refuted by a driver path that does not pass `--init` or by an explicit local definition of “sync” that includes initialization; neither appears in the skill or reference. claim `01M39RYNHTH5TR2P6BDC96B9N0` of review `01M39RTBF4DNVQ28DF97Z9ZE21`
Author
Owner

Fixed in 85dfb5b. Reproduced first: a detached submodule commit no ref held went to the reflog. A selector-managed Gitlink change now counts as deferred maintenance only when some ref holds the submodule's checked-out commit, so neither the fast-forward nor the selector move runs; the move also checks this itself. Covered by the new test "a submodule commit no ref holds blocks the fast-forward and the selector move".

<!-- gh-feedback:reply-to:87418 --> Fixed in 85dfb5b. Reproduced first: a detached submodule commit no ref held went to the reflog. A selector-managed Gitlink change now counts as deferred maintenance only when some ref holds the submodule's checked-out commit, so neither the fast-forward nor the selector move runs; the move also checks this itself. Covered by the new test "a submodule commit no ref holds blocks the fast-forward and the selector move".
Author
Owner

Fixed in 85dfb5b. Reproduced first: the forced tag fetch replaced a local v1 holding a unique commit. The tag is now fetched into FETCH_HEAD, and a differing local tag is replaced only when another ref still holds its commit; otherwise the selector update fails and names the tag. An upstream re-tag is still followed. Covered by the new test "a configured tag follows origin unless the local tag holds a commit no other ref holds".

<!-- gh-feedback:reply-to:87419 --> Fixed in 85dfb5b. Reproduced first: the forced tag fetch replaced a local `v1` holding a unique commit. The tag is now fetched into `FETCH_HEAD`, and a differing local tag is replaced only when another ref still holds its commit; otherwise the selector update fails and names the tag. An upstream re-tag is still followed. Covered by the new test "a configured tag follows origin unless the local tag holds a commit no other ref holds".
Author
Owner

Fixed in 85dfb5b. Confirmed synchronize_submodules runs git submodule update --init --recursive --checkout. The table now says the audit initializes submodules and checks them out at the recorded commits after a fast-forward; references/checkout-updates.md and the driver's --help say the same.

<!-- gh-feedback:reply-to:87420 --> Fixed in 85dfb5b. Confirmed `synchronize_submodules` runs `git submodule update --init --recursive --checkout`. The table now says the audit initializes submodules and checks them out at the recorded commits after a fast-forward; `references/checkout-updates.md` and the driver's `--help` say the same.
jercik marked this conversation as resolved
@ -23,3 +23,4 @@
- A repository whose work integrates somewhere other than `origin/<default>`, such as a fork with an `upstream`: `--no-remove`. Removal proves containment against `origin/<default>` only.
- A repository with a custom `origin` fetch refspec: `--no-fetch --no-remove`. The fetch prunes every `refs/remotes/origin/*` ref that no server branch supplies (see [references/checkout-updates.md](references/checkout-updates.md)).
- A first-party checkout whose submodules must stay where they are: `--no-fetch --no-remove`. Selector moves happen even without a fast-forward, and `update = none` does not stop the post-fast-forward sync.

low — “Selector moves” says the configuration changes when the checkout moves
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined this new stricter-mode condition, the complete skill, the “Submodule selectors” reference, synchronize_first_party_tracking_submodules, and the first-party tracking test. The selector does not move: the driver reads the existing branch or tag value, fetches its target, and detaches the submodule checkout at that commit while explicitly verifying that .gitmodules did not change. The reference separately says the driver does not invent selectors, and the test asserts that locally updated metadata remains byte-for-byte intact. Calling this a “selector move” gives the agent the wrong object and conflicts with the immediately preceding table wording that the submodule itself moves to the configured selector. It can make a reader think this mode protects .gitmodules configuration rather than protecting the current submodule checkout. Say instead: “The driver advances selector-managed submodule checkouts even when the superproject does not fast-forward; update = none skips that advance but not the post-fast-forward checkout at recorded Gitlinks.” This preserves both warnings while distinguishing immutable configuration from the working-tree mutation. A trace that writes a new branch or tag value would refute the claim; the implementation instead treats any .gitmodules change during synchronization as an error.

claim 01M39RZCXAEDY17MKCNN4C1BZH of review 01M39RTBF4DNVQ28DF97Z9ZE21

<!-- review:claim:01M39RZCXAEDY17MKCNN4C1BZH --> **low** — “Selector moves” says the configuration changes when the checkout moves lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined this new stricter-mode condition, the complete skill, the “Submodule selectors” reference, `synchronize_first_party_tracking_submodules`, and the first-party tracking test. The selector does not move: the driver reads the existing `branch` or `tag` value, fetches its target, and detaches the submodule checkout at that commit while explicitly verifying that `.gitmodules` did not change. The reference separately says the driver does not invent selectors, and the test asserts that locally updated metadata remains byte-for-byte intact. Calling this a “selector move” gives the agent the wrong object and conflicts with the immediately preceding table wording that the submodule itself moves to the configured selector. It can make a reader think this mode protects `.gitmodules` configuration rather than protecting the current submodule checkout. Say instead: “The driver advances selector-managed submodule checkouts even when the superproject does not fast-forward; `update = none` skips that advance but not the post-fast-forward checkout at recorded Gitlinks.” This preserves both warnings while distinguishing immutable configuration from the working-tree mutation. A trace that writes a new branch or tag value would refute the claim; the implementation instead treats any `.gitmodules` change during synchronization as an error. claim `01M39RZCXAEDY17MKCNN4C1BZH` of review `01M39RTBF4DNVQ28DF97Z9ZE21`
Author
Owner

Fixed in 85dfb5b. Confirmed the selector itself never changes and update = none skips only the selector move. The bullet now reads: the driver moves branch- or tag-tracked submodules even when nothing fast-forwards, and update = none stops only that move, not the checkout at recorded commits after a fast-forward.

<!-- gh-feedback:reply-to:87421 --> Fixed in 85dfb5b. Confirmed the selector itself never changes and `update = none` skips only the selector move. The bullet now reads: the driver moves branch- or tag-tracked submodules even when nothing fast-forwards, and `update = none` stops only that move, not the checkout at recorded commits after a fast-forward.
jercik marked this conversation as resolved
fix(audit-git-checkouts): keep unheld submodule commits and local tags in place
All checks were successful
commit-msg / commitlint (pull_request) Successful in 18s
Node tests / node:test (pull_request) Successful in 53s
Review / Review (pull_request_target) Successful in 18m10s
85dfb5bc34
A default audit moved a selector-tracked submodule off a detached commit no
ref held, and force-fetched a configured tag over a local tag of the same
name. Both left a commit reachable only from the reflog.

A Gitlink change now counts as deferred maintenance only when some ref holds
the submodule's checked-out commit, and the selector move checks the same.
Configured tags are fetched into FETCH_HEAD and replace a differing local tag
only when another ref still holds its commit. The mode table now names the
submodule initialization after a fast-forward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jercik changed title from docs(audit-git-checkouts): name the submodule writes a default audit makes to fix(audit-git-checkouts): audits should not strand submodule commits or local tags that no other ref holds 2026-09-24 13:35:07 +00:00
@ -23,3 +23,4 @@
- A repository whose work integrates somewhere other than `origin/<default>`, such as a fork with an `upstream`: `--no-remove`. Removal proves containment against `origin/<default>` only.
- A repository with a custom `origin` fetch refspec: `--no-fetch --no-remove`. The fetch prunes every `refs/remotes/origin/*` ref that no server branch supplies (see [references/checkout-updates.md](references/checkout-updates.md)).
- A first-party checkout whose submodules must stay where they are: `--no-fetch --no-remove`. The driver moves branch- or tag-tracked submodules even when nothing fast-forwards, and `update = none` stops only that move, not the checkout at recorded commits after a fast-forward.

low — "the checkout at recorded commits" reuses checkout, the skill's word for a working copy, to mean the act of checking out
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The third stricter-mode bullet in SKILL.md's "Choose the mode", the parallel sentence this change introduced in references/checkout-updates.md, and every other use of "checkout" across SKILL.md and the four reference files.

What the subject says. Throughout this skill, checkout is a count noun for a working copy: "default checkouts", "a first-party checkout", "Bring every checkout below a directory up to date", "the primary checkout", "a dirty checkout", "Checkout updates and intended lines". The changed bullet uses it twice in one sentence with two different meanings: "A first-party checkout whose submodules must stay where they are … update = none stops only that move, not the checkout at recorded commits after a fast-forward." The second is a verbal noun for the act of checking a submodule out. references/checkout-updates.md repeats the pattern: "the checkout at recorded Gitlinks after a fast-forward still moves it".

What goes wrong. This skill's own writing standard (/opt/review/skills/writing-for-agents/SKILL.md, "Use Precise Language") requires one term for one concept. "not the checkout at recorded commits" parses first as the established noun with a locative modifier — the checkout that is at recorded commits — which is a plausible reading in a document that has just been discussing checkouts sitting at particular commits ("A submodule checked out at a commit that is neither the recorded Gitlink…"). The reader has to back out and reparse. The cost lands on the one sentence whose job is to warn that --no-fetch --no-remove is required because a submodule will otherwise be moved, i.e. on the skill's mode-choice surface, where a misread means choosing the writing mode over the read-only one.

The pre-change wording, "the post-fast-forward Gitlink sync still checks it out", did not have this collision. The change was worth making for accuracy — the driver runs submodule sync and submodule update --init --recursive --checkout, so "sync" alone was wrong — but the replacement traded one imprecision for another.

Proposed correction. Keep "checkout" as the working-copy noun and use a verb for the action: "…and update = none stops only that move; after a fast-forward the driver still checks the submodule out at the recorded commit." Apply the same to references/checkout-updates.md: "…opts a submodule out of selector floating only; after a fast-forward the driver still checks it out at the recorded Gitlink." This preserves the corrected mechanism (init plus checkout, not a submodule sync) and the warning that update = none is not sufficient protection.

Proof gap. None for the wording itself; this is a reading of the two files against the installed writing standard. The behavior claim the sentence makes is accurate — git submodule update --checkout overrides update = none, and synchronize_submodules passes --checkout.

claim 01M39T9CDN90M09PQTBECZSE3P of review 01M39T0ST0ZAZJTHPM4AJGVRA3

<!-- review:claim:01M39T9CDN90M09PQTBECZSE3P --> **low** — "the checkout at recorded commits" reuses `checkout`, the skill's word for a working copy, to mean the act of checking out lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The third stricter-mode bullet in `SKILL.md`'s "Choose the mode", the parallel sentence this change introduced in `references/checkout-updates.md`, and every other use of "checkout" across `SKILL.md` and the four reference files. > > **What the subject says.** Throughout this skill, *checkout* is a count noun for a working copy: "default checkouts", "a first-party checkout", "Bring every checkout below a directory up to date", "the primary checkout", "a dirty checkout", "Checkout updates and intended lines". The changed bullet uses it twice in one sentence with two different meanings: "A first-party **checkout** whose submodules must stay where they are … `update = none` stops only that move, not **the checkout** at recorded commits after a fast-forward." The second is a verbal noun for the act of checking a submodule out. `references/checkout-updates.md` repeats the pattern: "the checkout at recorded Gitlinks after a fast-forward still moves it". > > **What goes wrong.** This skill's own writing standard (`/opt/review/skills/writing-for-agents/SKILL.md`, "Use Precise Language") requires one term for one concept. "not the checkout at recorded commits" parses first as the established noun with a locative modifier — *the checkout that is at recorded commits* — which is a plausible reading in a document that has just been discussing checkouts sitting at particular commits ("A submodule checked out at a commit that is neither the recorded Gitlink…"). The reader has to back out and reparse. The cost lands on the one sentence whose job is to warn that `--no-fetch --no-remove` is required because a submodule will otherwise be moved, i.e. on the skill's mode-choice surface, where a misread means choosing the writing mode over the read-only one. > > The pre-change wording, "the post-fast-forward Gitlink sync still checks it out", did not have this collision. The change was worth making for accuracy — the driver runs `submodule sync` *and* `submodule update --init --recursive --checkout`, so "sync" alone was wrong — but the replacement traded one imprecision for another. > > **Proposed correction.** Keep "checkout" as the working-copy noun and use a verb for the action: "…and `update = none` stops only that move; after a fast-forward the driver still checks the submodule out at the recorded commit." Apply the same to `references/checkout-updates.md`: "…opts a submodule out of selector floating only; after a fast-forward the driver still checks it out at the recorded Gitlink." This preserves the corrected mechanism (init plus checkout, not a `submodule sync`) and the warning that `update = none` is not sufficient protection. > > **Proof gap.** None for the wording itself; this is a reading of the two files against the installed writing standard. The behavior claim the sentence makes is accurate — `git submodule update --checkout` overrides `update = none`, and `synchronize_submodules` passes `--checkout`. claim `01M39T9CDN90M09PQTBECZSE3P` of review `01M39T0ST0ZAZJTHPM4AJGVRA3`

superseded by review 01M39W1HVW5VE4BHR8WJKRSQ29 for head feb2194b3542c215cc150cbce403698ba16a51c7

<!-- review:superseded:01M39W1HVW5VE4BHR8WJKRSQ29 --> superseded by review `01M39W1HVW5VE4BHR8WJKRSQ29` for head `feb2194b3542c215cc150cbce403698ba16a51c7`
Author
Owner

Fixed in feb2194. Both places now use a verb: "after a fast-forward the driver still checks each submodule out at its recorded commit".

<!-- gh-feedback:reply-to:87487 --> Fixed in feb2194. Both places now use a verb: "after a fast-forward the driver still checks each submodule out at its recorded commit".
jercik marked this conversation as resolved
@ -5,3 +5,3 @@
## What the driver already updates
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth) or first-party `.gitmodules`/Gitlink maintenance, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it synchronizes committed submodules to the recorded Gitlinks.
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth) or first-party `.gitmodules`/Gitlink maintenance whose submodule commit some ref holds, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it initializes committed submodules and checks them out at the recorded Gitlinks.

low — The fast-forward eligibility gate's new qualifier attaches to .gitmodules too and omits the selector requirement it actually depends on
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The changed sentence in references/checkout-updates.md under "What the driver already updates", and the predicate it describes: is_deferred_guidance_path in scripts/audit-checkouts.sh, called per changed path from has_only_nonoverlapping_deferred_guidance.

What the subject says. "A dirty checkout qualifies only when every changed path is deferred guidance (AGENTS.md, .agents/**, at any depth) or first-party .gitmodules/Gitlink maintenance whose submodule commit some ref holds."

What the code does. is_deferred_guidance_path branches by path:

  • AGENTS.md, */AGENTS.md, .agents/*, */.agents/* → qualifies.
  • Any path in a third-party/ checkout → does not qualify.
  • .gitmodules itself → qualifies unconditionally: [ "$changed_path" = .gitmodules ] && return 0, with no held-commit test.
  • A Gitlink path → it looks up submodule.<name>.branch and submodule.<name>.tag in .gitmodules, does [ -n "$branch" ] || [ -n "$tag" ] || return 1, and only then returns submodule_head_is_held.

What goes wrong. Two defects in one clause.

First, the relative clause "whose submodule commit some ref holds" grammatically modifies the whole compound ".gitmodules/Gitlink maintenance", but .gitmodules is a text file with no submodule commit and is not subject to the test. A literal reader — the audience this skill's own writing standard assumes — concludes that editing .gitmodules can be disqualified by a submodule's HEAD, which it cannot.

Second, and more costly, the sentence names the held-commit condition as the only condition on a Gitlink path and omits the selector requirement that precedes it in the code. A first-party submodule with no branch or tag selector disqualifies the checkout outright, even when its HEAD is held by a ref and even when its HEAD equals the recorded Gitlink but the submodule's worktree is dirty. SKILL.md routes "A default checkout that was not fast-forwarded" to this reference; an agent diagnosing that case checks whether the commit is held, finds that it is, and concludes the driver misbehaved — when the real cause is a missing selector, which is a thing the agent can fix (the same file's "Submodule selectors" section tells it how).

Proposed correction. Split the compound and name both conditions, e.g.: "…or first-party .gitmodules edits, or a first-party Gitlink whose submodule carries a branch or tag selector and sits on a commit some ref holds." This preserves the new held-commit fact while making the gate checkable against a real checkout.

Proof gap. Static reading of is_deferred_guidance_path and its caller; I did not run the driver against a selector-less first-party submodule to observe the resulting classification.

claim 01M39T8QXN3T44RXQHE57QGB18 of review 01M39T0ST0ZAZJTHPM4AJGVRA3

<!-- review:claim:01M39T8QXN3T44RXQHE57QGB18 --> **low** — The fast-forward eligibility gate's new qualifier attaches to `.gitmodules` too and omits the selector requirement it actually depends on lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The changed sentence in `references/checkout-updates.md` under "What the driver already updates", and the predicate it describes: `is_deferred_guidance_path` in `scripts/audit-checkouts.sh`, called per changed path from `has_only_nonoverlapping_deferred_guidance`. > > **What the subject says.** "A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth) or first-party `.gitmodules`/Gitlink maintenance whose submodule commit some ref holds." > > **What the code does.** `is_deferred_guidance_path` branches by path: > - `AGENTS.md`, `*/AGENTS.md`, `.agents/*`, `*/.agents/*` → qualifies. > - Any path in a `third-party/` checkout → does not qualify. > - `.gitmodules` itself → qualifies unconditionally: `[ "$changed_path" = .gitmodules ] && return 0`, with no held-commit test. > - A Gitlink path → it looks up `submodule.<name>.branch` and `submodule.<name>.tag` in `.gitmodules`, does `[ -n "$branch" ] || [ -n "$tag" ] || return 1`, and only then returns `submodule_head_is_held`. > > **What goes wrong.** Two defects in one clause. > > First, the relative clause "whose submodule commit some ref holds" grammatically modifies the whole compound "`.gitmodules`/Gitlink maintenance", but `.gitmodules` is a text file with no submodule commit and is not subject to the test. A literal reader — the audience this skill's own writing standard assumes — concludes that editing `.gitmodules` can be disqualified by a submodule's HEAD, which it cannot. > > Second, and more costly, the sentence names the held-commit condition as the only condition on a Gitlink path and omits the selector requirement that precedes it in the code. A first-party submodule with no `branch` or `tag` selector disqualifies the checkout outright, even when its HEAD is held by a ref and even when its HEAD equals the recorded Gitlink but the submodule's worktree is dirty. `SKILL.md` routes "A default checkout that was not fast-forwarded" to this reference; an agent diagnosing that case checks whether the commit is held, finds that it is, and concludes the driver misbehaved — when the real cause is a missing selector, which is a thing the agent can fix (the same file's "Submodule selectors" section tells it how). > > **Proposed correction.** Split the compound and name both conditions, e.g.: "…or first-party `.gitmodules` edits, or a first-party Gitlink whose submodule carries a `branch` or `tag` selector and sits on a commit some ref holds." This preserves the new held-commit fact while making the gate checkable against a real checkout. > > **Proof gap.** Static reading of `is_deferred_guidance_path` and its caller; I did not run the driver against a selector-less first-party submodule to observe the resulting classification. claim `01M39T8QXN3T44RXQHE57QGB18` of review `01M39T0ST0ZAZJTHPM4AJGVRA3`

superseded by review 01M39W1HVW5VE4BHR8WJKRSQ29 for head feb2194b3542c215cc150cbce403698ba16a51c7

<!-- review:superseded:01M39W1HVW5VE4BHR8WJKRSQ29 --> superseded by review `01M39W1HVW5VE4BHR8WJKRSQ29` for head `feb2194b3542c215cc150cbce403698ba16a51c7`
Author
Owner

Fixed in feb2194. The sentence now lists the cases separately: a first-party .gitmodules edit, or a first-party Gitlink change on a submodule with a branch or tag selector. The held-commit rule moved to its own sentence, where it covers submodules at any depth.

<!-- gh-feedback:reply-to:87488 --> Fixed in feb2194. The sentence now lists the cases separately: a first-party `.gitmodules` edit, or a first-party Gitlink change on a submodule with a `branch` or `tag` selector. The held-commit rule moved to its own sentence, where it covers submodules at any depth.
jercik marked this conversation as resolved
@ -18,3 +18,3 @@
- **Under a `third-party/` path component:** `.gitmodules` and Gitlinks belong to upstream. Never add or change selectors, URLs, paths, update policies, or Gitlinks; only synchronize what upstream committed.
- **Everywhere else (first-party):** each submodule needs exactly one selector, `branch = <name>` for a moving line or `tag = <name>` for an exact release. On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit.
- **Everywhere else (first-party):** each submodule needs exactly one selector, `branch = <name>` for a moving line or `tag = <name>` for an exact release. On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit. A submodule checked out at a commit that is neither the recorded Gitlink nor held by any ref stays where it is, and its checkout is reported for a decision. A local tag whose commit no other ref holds is never replaced; the selector update fails and says so.

medium — checkout-updates.md promises a stranded submodule is "reported for a decision", but the driver emits a failure row or no submodule signal at all
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. references/checkout-updates.md (the "Submodule selectors" bullet, added by this change), the new guard submodule_head_is_held and its two call sites in scripts/audit-checkouts.sh, the report renderer scripts/render-audit-report.ts, SKILL.md, and the two tests added in scripts/audit-checkouts.test.mjs.

What the subject says. The reference tells the agent that when a submodule sits on a commit that is neither the recorded Gitlink nor held by any ref, it "stays where it is, and its checkout is reported for a decision." In this skill "a decision" is a named report surface: SKILL.md instructs the agent to "Take 'Needs your decision' first", and the renderer emits exactly two such sections (addSection("Needs your decision: not current", ...) and addSection("Needs your decision: kept worktrees", ...)).

What the driver actually does. The guard has two call sites, and neither produces a submodule decision row:

  1. In synchronize_first_party_tracking_submodules, a failed guard writes submodule <path> is checked out at a commit no ref holds; leaving it in place to the error file and does return 1. That sets trackingUpdate.ok=false, and stepFailure() in the renderer turns it into submodule selector update failed: ... — a failure, not a decision. The reference's own preceding paragraph and SKILL.md ("A failure row means coverage is incomplete for that checkout; never report it as clean") treat failures as something to inspect and repair, which is the opposite handling from a decision item. The return 1 also abandons every .gitmodules entry after the failing one, so sibling submodules are silently left unfloated.
  2. Via is_deferred_guidance_path, which now ends submodule_head_is_held "$checkout_path" "$configured_path"; return. A dirty superproject whose only local change is that Gitlink therefore fails has_only_nonoverlapping_deferred_guidance, so fast_forward_mode is never set and tracking_update_eligible stays false. The new test asserts precisely this: assert.equal(result.fastForward.attempted, false); assert.equal(result.trackingUpdate.attempted, false);. With nothing attempted, stepFailure() returns null and the checkout lands in "Needs your decision: not current" via classify_checkout's default-needs-attention, whose reason is the dirty/behind status — the stranded submodule is never named anywhere in the report.

What goes wrong. An agent that trusts this sentence tells the user "the driver left the submodule alone and flagged it for you" and works the decision queue. In case 1 it must instead report incomplete coverage and check which sibling submodules were left behind; in case 2 no report line mentions the submodule, so the agent cannot explain why an otherwise eligible checkout was not fast-forwarded — which is the exact question SKILL.md routes to this reference ("A default checkout that was not fast-forwarded ... [references/checkout-updates.md]").

Proposed correction. Replace the clause with what the driver produces, e.g.: "A submodule checked out at a commit that is neither the recorded Gitlink nor held by any ref stays where it is; the selector update fails with that submodule's path and the remaining submodules are left unsynchronized. When the stale Gitlink is the checkout's only local change, the fast-forward is not attempted either and no report row names the submodule." This preserves the useful meaning (the commit is never stranded) while telling the agent where the signal actually appears and that coverage is incomplete.

Proof gap. I traced the shell and the renderer statically and read the new tests' assertions; I did not execute the test suite in this sandbox.

claim 01M39T84EZ238357RHHNS50JZ9 of review 01M39T0ST0ZAZJTHPM4AJGVRA3

<!-- review:claim:01M39T84EZ238357RHHNS50JZ9 --> **medium** — checkout-updates.md promises a stranded submodule is "reported for a decision", but the driver emits a failure row or no submodule signal at all lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** `references/checkout-updates.md` (the "Submodule selectors" bullet, added by this change), the new guard `submodule_head_is_held` and its two call sites in `scripts/audit-checkouts.sh`, the report renderer `scripts/render-audit-report.ts`, `SKILL.md`, and the two tests added in `scripts/audit-checkouts.test.mjs`. > > **What the subject says.** The reference tells the agent that when a submodule sits on a commit that is neither the recorded Gitlink nor held by any ref, it "stays where it is, and its checkout is reported for a decision." In this skill "a decision" is a named report surface: `SKILL.md` instructs the agent to "Take 'Needs your decision' first", and the renderer emits exactly two such sections (`addSection("Needs your decision: not current", ...)` and `addSection("Needs your decision: kept worktrees", ...)`). > > **What the driver actually does.** The guard has two call sites, and neither produces a submodule decision row: > > 1. In `synchronize_first_party_tracking_submodules`, a failed guard writes `submodule <path> is checked out at a commit no ref holds; leaving it in place` to the error file and does `return 1`. That sets `trackingUpdate.ok=false`, and `stepFailure()` in the renderer turns it into `submodule selector update failed: ...` — a **failure**, not a decision. The reference's own preceding paragraph and `SKILL.md` ("A failure row means coverage is incomplete for that checkout; never report it as clean") treat failures as something to inspect and repair, which is the opposite handling from a decision item. The `return 1` also abandons every `.gitmodules` entry after the failing one, so sibling submodules are silently left unfloated. > 2. Via `is_deferred_guidance_path`, which now ends `submodule_head_is_held "$checkout_path" "$configured_path"; return`. A dirty superproject whose only local change is that Gitlink therefore fails `has_only_nonoverlapping_deferred_guidance`, so `fast_forward_mode` is never set and `tracking_update_eligible` stays false. The new test asserts precisely this: `assert.equal(result.fastForward.attempted, false); assert.equal(result.trackingUpdate.attempted, false);`. With nothing attempted, `stepFailure()` returns null and the checkout lands in "Needs your decision: not current" via `classify_checkout`'s `default-needs-attention`, whose reason is the dirty/behind status — the stranded submodule is never named anywhere in the report. > > **What goes wrong.** An agent that trusts this sentence tells the user "the driver left the submodule alone and flagged it for you" and works the decision queue. In case 1 it must instead report incomplete coverage and check which sibling submodules were left behind; in case 2 no report line mentions the submodule, so the agent cannot explain why an otherwise eligible checkout was not fast-forwarded — which is the exact question `SKILL.md` routes to this reference ("A default checkout that was not fast-forwarded ... [references/checkout-updates.md]"). > > **Proposed correction.** Replace the clause with what the driver produces, e.g.: "A submodule checked out at a commit that is neither the recorded Gitlink nor held by any ref stays where it is; the selector update fails with that submodule's path and the remaining submodules are left unsynchronized. When the stale Gitlink is the checkout's only local change, the fast-forward is not attempted either and no report row names the submodule." This preserves the useful meaning (the commit is never stranded) while telling the agent where the signal actually appears and that coverage is incomplete. > > **Proof gap.** I traced the shell and the renderer statically and read the new tests' assertions; I did not execute the test suite in this sandbox. claim `01M39T84EZ238357RHHNS50JZ9` of review `01M39T0ST0ZAZJTHPM4AJGVRA3`

superseded by review 01M39W1HVW5VE4BHR8WJKRSQ29 for head feb2194b3542c215cc150cbce403698ba16a51c7

<!-- review:superseded:01M39W1HVW5VE4BHR8WJKRSQ29 --> superseded by review `01M39W1HVW5VE4BHR8WJKRSQ29` for head `feb2194b3542c215cc150cbce403698ba16a51c7`
Author
Owner

Fixed in feb2194. The stranded case is now a real decision row: the worktree record carries strandedSubmodules, and the report lists the checkout under "Needs your decision" as submodule on a commit no ref holds: <path>; not updated. The reference says that, and says a refused tag fails the selector update and names the tag.

<!-- gh-feedback:reply-to:87485 --> Fixed in feb2194. The stranded case is now a real decision row: the worktree record carries `strandedSubmodules`, and the report lists the checkout under "Needs your decision" as `submodule on a commit no ref holds: <path>; not updated`. The reference says that, and says a refused tag fails the selector update and names the tag.
jercik marked this conversation as resolved
Lines 507-508
@ -478,0 +504,5 @@
fetched_object=$(git -C "$submodule_path" rev-parse --verify --quiet FETCH_HEAD 2>>"$error_path") || return 1
fetched_commit=$(git -C "$submodule_path" rev-parse --verify --quiet "FETCH_HEAD^{commit}" 2>>"$error_path") || return 1
local_commit=$(git -C "$submodule_path" rev-parse --verify --quiet "refs/tags/$tag^{commit}" 2>/dev/null || true)
if [ -n "$local_commit" ] && [ "$local_commit" != "$fetched_commit" ] \
&& [ -z "$(git -C "$submodule_path" for-each-ref --format='%(refname)' --contains "$local_commit" 2>/dev/null | grep -v -x -F "refs/tags/$tag")" ]; then

high — A tag selector self-locks after its first advance: update_submodule_tag refuses every later upstream tag move, and the run regresses the submodule to the stale Gitlink
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new update_submodule_tag helper and the rewritten tag fetch in synchronize_first_party_tracking_submodules (git fetch --no-tags origin "refs/tags/$tag" replacing the previous git fetch --force origin "refs/tags/$tag:refs/tags/$tag"), the new test a configured tag follows origin unless the local tag holds a commit no other ref holds, and references/checkout-updates.md. The suite passes as written (node --test audit-checkouts.test.mjs: 56 pass, 1 skip, 0 fail) — the test only performs one upstream tag move, which is exactly one short of the failure.

Mechanism: nothing in the tag path ever refreshes the submodule's refs/remotes/origin/*. The branch path fetches +refs/heads/$branch:refs/remotes/origin/$branch; the tag path fetches only refs/tags/$tag into FETCH_HEAD and then calls update_submodule_tag, which writes refs/tags/$tag by hand. So after the driver's own first advance, refs/tags/$tag is the only local ref that holds the commit it just moved to — the submodule's refs/heads/* and refs/remotes/origin/* are still frozen at clone time. On the next upstream tag move the anchored condition excludes refs/tags/$tag itself from for-each-ref --contains, finds nothing left, and refuses.

Reproduced. First-party personal/super with submodule dependency, .gitmodules carrying tag = v1, initialised via submodule update --init. I drove audit_worktree directly (the harness shape the new tests use) twice, moving the annotated tag upstream before each run:

Run 1 (v1 -> commit2): trackingUpdate.ok = true; dependency HEAD becomes commit2 823445a. Submodule refs afterwards:
refs/heads/main 23e6394
refs/remotes/origin/HEAD 23e6394
refs/remotes/origin/main 23e6394
refs/tags/v1 3a3a84b (annotated, peels to 823445a)

Run 2 (v1 -> commit3): trackingUpdate.ok = false, error local tag v1 in .../dependency holds commit 823445aee0592e61712a39411c428b3de7981682 that no other ref holds; not replacing it with origin's e6e7153d07858c852b3ebd6fd745323e57a784b3.

Two things go wrong. First, the refusal is a false positive: 823445a is not local work at risk of being stranded — it is origin's own previous tag target, present on origin, unreachable locally only because the tag path never fetches a branch. The check cannot distinguish "a commit that exists nowhere but here" from "a commit we simply never fetched a ref for", and for a tag selector the second is the normal state the driver itself creates. Second, the run leaves the submodule worse than it found it: git submodule update --init --checkout runs before the fetch, so after the refusal dependency HEAD is 23e6394, the stale recorded Gitlink — not commit2 where run 1 left it. Every subsequent run repeats this: refuse, and regress the checkout.

Cost: the tag = <name> selector documented in references/checkout-updates.md stops working permanently after its first advance, and each run reports a failure whose stated reason is untrue. The previous git fetch --force refs/tags/$tag:refs/tags/$tag had neither problem.

A safe correction: judge "held" against origin rather than against stale local refs — for example fetch the submodule's branches (or git fetch origin --prune '+refs/heads/*:refs/remotes/origin/*') before the check, or ask origin whether it still has local_commit (git fetch --dry-run / ls-remote containment), or only refuse when local_commit is unreachable from every remote-tracking ref and was not itself written by a previous update_submodule_tag (which could be recorded, e.g. via update-ref -m, or by keeping a refs/audit/previous-tags/<tag> backstop before moving the tag).

Evidence basis: observed, on git 2.47.3, with the commands and outputs above. Proof gap: I could not read the base commit's history beyond the served diff, so I am relying on the diff text for what the previous fetch line was.

claim 01M39TP8DZD5VYQ90N1XTBSVSE of review 01M39T0ST0ZAZJTHPM4AJGVRA3

<!-- review:claim:01M39TP8DZD5VYQ90N1XTBSVSE --> **high** — A tag selector self-locks after its first advance: update_submodule_tag refuses every later upstream tag move, and the run regresses the submodule to the stale Gitlink lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new `update_submodule_tag` helper and the rewritten tag fetch in `synchronize_first_party_tracking_submodules` (`git fetch --no-tags origin "refs/tags/$tag"` replacing the previous `git fetch --force origin "refs/tags/$tag:refs/tags/$tag"`), the new test `a configured tag follows origin unless the local tag holds a commit no other ref holds`, and `references/checkout-updates.md`. The suite passes as written (`node --test audit-checkouts.test.mjs`: 56 pass, 1 skip, 0 fail) — the test only performs one upstream tag move, which is exactly one short of the failure. > > Mechanism: nothing in the tag path ever refreshes the submodule's `refs/remotes/origin/*`. The branch path fetches `+refs/heads/$branch:refs/remotes/origin/$branch`; the tag path fetches only `refs/tags/$tag` into FETCH_HEAD and then calls `update_submodule_tag`, which writes `refs/tags/$tag` by hand. So after the driver's own first advance, `refs/tags/$tag` is the *only* local ref that holds the commit it just moved to — the submodule's `refs/heads/*` and `refs/remotes/origin/*` are still frozen at clone time. On the next upstream tag move the anchored condition excludes `refs/tags/$tag` itself from `for-each-ref --contains`, finds nothing left, and refuses. > > Reproduced. First-party `personal/super` with submodule `dependency`, `.gitmodules` carrying `tag = v1`, initialised via `submodule update --init`. I drove `audit_worktree` directly (the harness shape the new tests use) twice, moving the annotated tag upstream before each run: > > Run 1 (v1 -> commit2): `trackingUpdate.ok = true`; `dependency` HEAD becomes commit2 `823445a`. Submodule refs afterwards: > refs/heads/main 23e6394 > refs/remotes/origin/HEAD 23e6394 > refs/remotes/origin/main 23e6394 > refs/tags/v1 3a3a84b (annotated, peels to 823445a) > > Run 2 (v1 -> commit3): `trackingUpdate.ok = false`, error `local tag v1 in .../dependency holds commit 823445aee0592e61712a39411c428b3de7981682 that no other ref holds; not replacing it with origin's e6e7153d07858c852b3ebd6fd745323e57a784b3`. > > Two things go wrong. First, the refusal is a false positive: `823445a` is not local work at risk of being stranded — it is origin's own previous tag target, present on origin, unreachable locally only because the tag path never fetches a branch. The check cannot distinguish "a commit that exists nowhere but here" from "a commit we simply never fetched a ref for", and for a tag selector the second is the normal state the driver itself creates. Second, the run leaves the submodule worse than it found it: `git submodule update --init --checkout` runs before the fetch, so after the refusal `dependency` HEAD is `23e6394`, the stale recorded Gitlink — not commit2 where run 1 left it. Every subsequent run repeats this: refuse, and regress the checkout. > > Cost: the `tag = <name>` selector documented in `references/checkout-updates.md` stops working permanently after its first advance, and each run reports a failure whose stated reason is untrue. The previous `git fetch --force refs/tags/$tag:refs/tags/$tag` had neither problem. > > A safe correction: judge "held" against origin rather than against stale local refs — for example fetch the submodule's branches (or `git fetch origin --prune '+refs/heads/*:refs/remotes/origin/*'`) before the check, or ask origin whether it still has `local_commit` (`git fetch --dry-run` / `ls-remote` containment), or only refuse when `local_commit` is unreachable from every remote-tracking ref *and* was not itself written by a previous `update_submodule_tag` (which could be recorded, e.g. via `update-ref -m`, or by keeping a `refs/audit/previous-tags/<tag>` backstop before moving the tag). > > Evidence basis: observed, on git 2.47.3, with the commands and outputs above. Proof gap: I could not read the base commit's history beyond the served diff, so I am relying on the diff text for what the previous fetch line was. claim `01M39TP8DZD5VYQ90N1XTBSVSE` of review `01M39T0ST0ZAZJTHPM4AJGVRA3`

superseded by review 01M39W1HVW5VE4BHR8WJKRSQ29 for head feb2194b3542c215cc150cbce403698ba16a51c7

<!-- review:superseded:01M39W1HVW5VE4BHR8WJKRSQ29 --> superseded by review `01M39W1HVW5VE4BHR8WJKRSQ29` for head `feb2194b3542c215cc150cbce403698ba16a51c7`
Author
Owner

Fixed in feb2194. Reproduced the second-advance refusal. A differing local tag is now also replaced when the fetched commit contains it, so an upstream tag that moves forward keeps being followed. A refusal no longer resets the submodule to the old Gitlink (initialized submodules are not re-checked-out first), and it skips only that submodule. The tag test now advances the tag twice, then checks the refusal leaves HEAD in place.

<!-- gh-feedback:reply-to:87484 --> Fixed in feb2194. Reproduced the second-advance refusal. A differing local tag is now also replaced when the fetched commit contains it, so an upstream tag that moves forward keeps being followed. A refusal no longer resets the submodule to the old Gitlink (initialized submodules are not re-checked-out first), and it skips only that submodule. The tag test now advances the tag twice, then checks the refusal leaves HEAD in place.
jercik marked this conversation as resolved
Lines 1215-1218
@ -1123,0 +1212,7 @@
git(upstreamSuperprojectPath, "commit", "--quiet", "-m", "non-overlapping upstream change");
git(superprojectPath, "fetch", "--quiet", upstreamSuperprojectPath, "HEAD:refs/remotes/origin/main");
const result = audit();
assert.equal(result.fastForward.attempted, false);
assert.equal(result.trackingUpdate.attempted, false);
assert.equal(git(dependencyPath, "rev-parse", "HEAD").trim(), localCommit);

medium — The new "blocks the fast-forward and the selector move" test never reaches the selector-move guard; deleting that guard leaves the whole suite green
lens test-trimming · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the two tests added to skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs and their shared createTrackedSubmoduleFixture, together with the two new guards they are meant to cover in audit-checkouts.sh: the submodule_head_is_held call added to is_deferred_guidance_path (eligibility) and the separate call added inside synchronize_first_party_tracking_submodules:

if ! submodule_head_is_held "$checkout_path" "$configured_path"; then
  echo "submodule $configured_path is checked out at a commit no ref holds; leaving it in place" >>"$error_path"
  return 1
fi

What the test does: the fixture leaves the superproject dirty (the dependency Gitlink differs from HEAD once the test commits inside the submodule). In audit_worktree, both the fast-forward gate and tracking_update_eligible go through has_only_nonoverlapping_deferred_guidance -> is_deferred_guidance_path, so the added eligibility check alone makes both assertions in the anchor pass. Because trackingUpdate.attempted is false, synchronize_first_party_tracking_submodules is never invoked in this test, so the guard inside it — the one that actually implements the "selector move" half of the test's name — is never executed by any test.

Evidence (executed, not traced). Environment: node v26.9.0, git 2.47.3, jq 1.7.1.

  • Baseline node --test audit-checkouts.test.mjs: 57 tests, 56 pass, 0 fail.
  • Mutation: delete the four-line guard quoted above from synchronize_first_party_tracking_submodules, changing nothing else. Full suite again 57 tests, 56 pass, 0 fail — including both new tests. The mutation is not detected anywhere.
  • Control: mutating the other new check (replacing submodule_head_is_held "$checkout_path" "$configured_path" / return at the end of is_deferred_guidance_path with return 0) does fail this test (actual: true, expected: false), confirming the anchored assertions only protect the eligibility gate.
  • The deleted guard is reachable and load-bearing. I ran a probe using the same fixture with the selector branch = main\n\tignore = all (so the Gitlink change is hidden from git status/git diff, the tracking update becomes eligible, and no upstream commit is needed since tracking eligibility does not require being behind). Baseline: trackingUpdate.attempted true ok false, error submodule dependency is checked out at a commit no ref holds; leaving it in place, submodule HEAD still the local commit. With the guard deleted: trackingUpdate.attempted true ok true and the submodule is checked out at origin's main, stranding the local commit that no ref holds — exactly the data loss the guard exists to prevent, and no test turns red.

What goes wrong: the test's name promises protection for the selector move, and a reader (or a future refactor of synchronize_first_party_tracking_submodules) will believe that path is covered. It is not: the guard can be removed silently, and the failure it prevents is an unrecoverable commit loss inside a submodule.

Suggested repair, not deletion: keep the existing test as the eligibility check, and extend the fixture so one case reaches the sync-level guard — e.g. a second case built from createTrackedSubmoduleFixture(context, "branch = main\n\tignore = all") that asserts trackingUpdate.attempted === true, trackingUpdate.ok === false, a matching trackingUpdate.error, and that the submodule HEAD is unchanged. That is the assertion set I ran above and it kills the mutation.

What would refute this: a test elsewhere in the repository that exercises synchronize_first_party_tracking_submodules with a submodule HEAD that is neither the recorded Gitlink nor held by a ref. I ran the complete audit-checkouts.test.mjs suite under the mutation and found none; render-audit-report.test.ts only covers report rendering. If the project considers the ignore = all configuration out of scope for this skill, the guard would be unreachable in practice — but then the guard, not the test, is what should change.

claim 01M39TB9X48FTAPRV48DNV18ET of review 01M39T0ST0ZAZJTHPM4AJGVRA3

<!-- review:claim:01M39TB9X48FTAPRV48DNV18ET --> **medium** — The new "blocks the fast-forward and the selector move" test never reaches the selector-move guard; deleting that guard leaves the whole suite green lens `test-trimming` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the two tests added to `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs` and their shared `createTrackedSubmoduleFixture`, together with the two new guards they are meant to cover in `audit-checkouts.sh`: the `submodule_head_is_held` call added to `is_deferred_guidance_path` (eligibility) and the separate call added inside `synchronize_first_party_tracking_submodules`: > > if ! submodule_head_is_held "$checkout_path" "$configured_path"; then > echo "submodule $configured_path is checked out at a commit no ref holds; leaving it in place" >>"$error_path" > return 1 > fi > > What the test does: the fixture leaves the superproject dirty (the `dependency` Gitlink differs from HEAD once the test commits inside the submodule). In `audit_worktree`, both the fast-forward gate and `tracking_update_eligible` go through `has_only_nonoverlapping_deferred_guidance` -> `is_deferred_guidance_path`, so the added eligibility check alone makes both assertions in the anchor pass. Because `trackingUpdate.attempted` is false, `synchronize_first_party_tracking_submodules` is never invoked in this test, so the guard inside it — the one that actually implements the "selector move" half of the test's name — is never executed by any test. > > Evidence (executed, not traced). Environment: node v26.9.0, git 2.47.3, jq 1.7.1. > - Baseline `node --test audit-checkouts.test.mjs`: 57 tests, 56 pass, 0 fail. > - Mutation: delete the four-line guard quoted above from `synchronize_first_party_tracking_submodules`, changing nothing else. Full suite again 57 tests, 56 pass, 0 fail — including both new tests. The mutation is not detected anywhere. > - Control: mutating the *other* new check (replacing `submodule_head_is_held "$checkout_path" "$configured_path"` / `return` at the end of `is_deferred_guidance_path` with `return 0`) does fail this test (`actual: true, expected: false`), confirming the anchored assertions only protect the eligibility gate. > - The deleted guard is reachable and load-bearing. I ran a probe using the same fixture with the selector `branch = main\n\tignore = all` (so the Gitlink change is hidden from `git status`/`git diff`, the tracking update becomes eligible, and no upstream commit is needed since tracking eligibility does not require being behind). Baseline: `trackingUpdate.attempted true ok false`, error `submodule dependency is checked out at a commit no ref holds; leaving it in place`, submodule HEAD still the local commit. With the guard deleted: `trackingUpdate.attempted true ok true` and the submodule is checked out at origin's `main`, stranding the local commit that no ref holds — exactly the data loss the guard exists to prevent, and no test turns red. > > What goes wrong: the test's name promises protection for the selector move, and a reader (or a future refactor of `synchronize_first_party_tracking_submodules`) will believe that path is covered. It is not: the guard can be removed silently, and the failure it prevents is an unrecoverable commit loss inside a submodule. > > Suggested repair, not deletion: keep the existing test as the eligibility check, and extend the fixture so one case reaches the sync-level guard — e.g. a second case built from `createTrackedSubmoduleFixture(context, "branch = main\n\tignore = all")` that asserts `trackingUpdate.attempted === true`, `trackingUpdate.ok === false`, a matching `trackingUpdate.error`, and that the submodule HEAD is unchanged. That is the assertion set I ran above and it kills the mutation. > > What would refute this: a test elsewhere in the repository that exercises `synchronize_first_party_tracking_submodules` with a submodule HEAD that is neither the recorded Gitlink nor held by a ref. I ran the complete `audit-checkouts.test.mjs` suite under the mutation and found none; `render-audit-report.test.ts` only covers report rendering. If the project considers the `ignore = all` configuration out of scope for this skill, the guard would be unreachable in practice — but then the guard, not the test, is what should change. claim `01M39TB9X48FTAPRV48DNV18ET` of review `01M39T0ST0ZAZJTHPM4AJGVRA3`

superseded by review 01M39W1HVW5VE4BHR8WJKRSQ29 for head feb2194b3542c215cc150cbce403698ba16a51c7

<!-- review:superseded:01M39W1HVW5VE4BHR8WJKRSQ29 --> superseded by review `01M39W1HVW5VE4BHR8WJKRSQ29` for head `feb2194b3542c215cc150cbce403698ba16a51c7`
Author
Owner

Fixed in feb2194. The per-submodule guard you mutated is gone; one recursive check now gates both the fast-forward and the selector move. Tests cover a visible Gitlink change, ignore = all, and a nested submodule, and all three fail when the gate is removed.

<!-- gh-feedback:reply-to:87486 --> Fixed in feb2194. The per-submodule guard you mutated is gone; one recursive check now gates both the fast-forward and the selector move. Tests cover a visible Gitlink change, `ignore = all`, and a nested submodule, and all three fail when the gate is removed.
jercik marked this conversation as resolved
fix(audit-git-checkouts): check nested submodules before any submodule move
Some checks failed
commit-msg / commitlint (pull_request) Failing after 15s
Node tests / node:test (pull_request) Successful in 1m12s
Review / Review (pull_request_target) Successful in 10m11s
feb2194b35
The per-submodule guard missed nested submodules and submodules with
`ignore = all`, whose commits the post-fast-forward checkout still stranded.
A checkout with any populated submodule, at any depth, on a commit that its
superproject does not record and no ref holds now gets no fast-forward and no
selector move. The worktree record lists those submodules in
`strandedSubmodules`, and the report shows the checkout as a decision.

A tag selector locked itself after its first advance, because the tag path
fetches no branch that could hold the previous target. A local tag now also
counts as safe to replace when the fetched commit contains it. A refused tag
no longer resets the submodule to the stale Gitlink or stops the remaining
submodules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ -16,3 +16,3 @@
| Request | Flags | What changes |
| --- | --- | --- |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check submodules out at their configured branch or tag, leaving the Gitlink change unstaged; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |

medium — Scope selector updates to direct submodules in the mode table
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I compared the changed mode-table description with synchronize_first_party_tracking_submodules() and synchronize_submodules() in the driver, plus the new nested-submodule test. The table says first-party default checkouts move submodules to their configured branch or tag without limiting depth. The selector function reads only the checkout root .gitmodules and loops over those direct entries; it never descends into a nested submodule to fetch and apply its own selector. The recursive post-fast-forward update instead checks nested submodules out at recorded Gitlinks. A reader auditing a nested dependency can therefore mistake the nested selector for one the driver advanced and report the checkout as up to date when that dependency remains on an older recorded commit. Say explicitly that selector floating applies to direct submodules declared in the checkout .gitmodules, while recursive sync after a fast-forward uses recorded commits. This keeps the useful description of both update paths and meets the writing standard for precise scope. I traced the code statically; I did not run a nested-selector advance reproduction.

claim 01M39W8KJ9N12T4ASA3Z17ZMZT of review 01M39W1HVW5VE4BHR8WJKRSQ29

<!-- review:claim:01M39W8KJ9N12T4ASA3Z17ZMZT --> **medium** — Scope selector updates to direct submodules in the mode table lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I compared the changed mode-table description with synchronize_first_party_tracking_submodules() and synchronize_submodules() in the driver, plus the new nested-submodule test. The table says first-party default checkouts move submodules to their configured branch or tag without limiting depth. The selector function reads only the checkout root .gitmodules and loops over those direct entries; it never descends into a nested submodule to fetch and apply its own selector. The recursive post-fast-forward update instead checks nested submodules out at recorded Gitlinks. A reader auditing a nested dependency can therefore mistake the nested selector for one the driver advanced and report the checkout as up to date when that dependency remains on an older recorded commit. Say explicitly that selector floating applies to direct submodules declared in the checkout .gitmodules, while recursive sync after a fast-forward uses recorded commits. This keeps the useful description of both update paths and meets the writing standard for precise scope. I traced the code statically; I did not run a nested-selector advance reproduction. claim `01M39W8KJ9N12T4ASA3Z17ZMZT` of review `01M39W1HVW5VE4BHR8WJKRSQ29`
Author
Owner

Fixed in 29216b3. The mode table now says selector moves apply to each submodule listed in the checkout's own .gitmodules. checkout-updates.md adds that nested submodules follow their recorded commits.

<!-- gh-feedback:reply-to:87532 --> Fixed in 29216b3. The mode table now says selector moves apply to each submodule listed in the checkout's own `.gitmodules`. `checkout-updates.md` adds that nested submodules follow their recorded commits.
jercik marked this conversation as resolved
@ -23,3 +23,4 @@
- A repository whose work integrates somewhere other than `origin/<default>`, such as a fork with an `upstream`: `--no-remove`. Removal proves containment against `origin/<default>` only.
- A repository with a custom `origin` fetch refspec: `--no-fetch --no-remove`. The fetch prunes every `refs/remotes/origin/*` ref that no server branch supplies (see [references/checkout-updates.md](references/checkout-updates.md)).
- A first-party checkout whose submodules must stay where they are: `--no-fetch --no-remove`. The driver moves branch- or tag-tracked submodules even when nothing fast-forwards, and `update = none` stops only that move; after a fast-forward the driver still checks each submodule out at its recorded commit.

medium — Apply the frozen-submodule warning to third-party checkouts too
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I read the changed strict-mode bullet, the policy split in checkout-updates.md, and audit_worktree() with synchronize_submodules(). The bullet only names first-party checkouts, but audit_worktree() calls the recursive submodule update after an eligible fast-forward without checking is_third_party_checkout(). Third-party checkouts therefore also move initialized submodules to recorded Gitlinks. A reader trying to keep a third-party submodule at its current commit can miss the strict-mode trigger and run the default audit, which changes that checkout. Change the condition to any checkout whose submodules must remain where they are; then explain that first-party selectors add a move even without a fast-forward, whereas post-fast-forward Gitlink sync applies to both policies. This retains the useful mode recommendation while making its scope precise, as the writing standard requires. I traced the code statically and did not run a third-party freeze reproduction.

claim 01M39WA0ADQAJAVPFGY8FZCD1E of review 01M39W1HVW5VE4BHR8WJKRSQ29

<!-- review:claim:01M39WA0ADQAJAVPFGY8FZCD1E --> **medium** — Apply the frozen-submodule warning to third-party checkouts too lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I read the changed strict-mode bullet, the policy split in checkout-updates.md, and audit_worktree() with synchronize_submodules(). The bullet only names first-party checkouts, but audit_worktree() calls the recursive submodule update after an eligible fast-forward without checking is_third_party_checkout(). Third-party checkouts therefore also move initialized submodules to recorded Gitlinks. A reader trying to keep a third-party submodule at its current commit can miss the strict-mode trigger and run the default audit, which changes that checkout. Change the condition to any checkout whose submodules must remain where they are; then explain that first-party selectors add a move even without a fast-forward, whereas post-fast-forward Gitlink sync applies to both policies. This retains the useful mode recommendation while making its scope precise, as the writing standard requires. I traced the code statically and did not run a third-party freeze reproduction. claim `01M39WA0ADQAJAVPFGY8FZCD1E` of review `01M39W1HVW5VE4BHR8WJKRSQ29`
Author
Owner

Fixed in 29216b3. Confirmed that synchronize_submodules runs for third-party checkouts too. The condition now reads "A checkout whose submodules must stay where they are" and says the post-fast-forward checkout covers third-party submodules.

<!-- gh-feedback:reply-to:87533 --> Fixed in 29216b3. Confirmed that `synchronize_submodules` runs for third-party checkouts too. The condition now reads "A checkout whose submodules must stay where they are" and says the post-fast-forward checkout covers third-party submodules.
jercik marked this conversation as resolved
@ -22,8 +22,11 @@ usage() {
echo " git fetch --prune origin; git worktree prune (repository-wide, including outside root);"

critical — Forced worktree removal can delete an unreferenced submodule commit
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined the new list_stranded_submodule_commits result in audit_worktree and the unchanged decide_removal_outcome removal gate. The new strandedSubmodules value blocks only fast-forward and selector updates; decide_removal_outcome never reads it. For a linked non-default worktree, .gitmodules with ignore = all makes git status --porcelain --untracked-files=normal empty even when an initialized submodule is on a detached local commit no ref holds. The gate can then pass superproject containment, run its ignored-file scan, and call git worktree remove --force. I reproduced that Git sequence: status was empty, removal returned 0, and the submodule's object store under .git/worktrees/feature/modules/dep was deleted; the original submodule source repository did not contain the local commit. This is static tracing of the driver plus a direct Git reproduction, not a full driver run (jq is unavailable). Before forced removal, check the initialized submodules live for unique commits and block the worktree if any are found; the audit-time list alone also needs revalidation because the workspace can change between audit and removal.

claim 01M39WD94EGN0R98F393FGJN48 of review 01M39W1HVW5VE4BHR8WJKRSQ29

<!-- review:claim:01M39WD94EGN0R98F393FGJN48 --> **critical** — Forced worktree removal can delete an unreferenced submodule commit lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined the new list_stranded_submodule_commits result in audit_worktree and the unchanged decide_removal_outcome removal gate. The new `strandedSubmodules` value blocks only fast-forward and selector updates; decide_removal_outcome never reads it. For a linked non-default worktree, `.gitmodules` with `ignore = all` makes `git status --porcelain --untracked-files=normal` empty even when an initialized submodule is on a detached local commit no ref holds. The gate can then pass superproject containment, run its ignored-file scan, and call `git worktree remove --force`. I reproduced that Git sequence: status was empty, removal returned 0, and the submodule's object store under `.git/worktrees/feature/modules/dep` was deleted; the original submodule source repository did not contain the local commit. This is static tracing of the driver plus a direct Git reproduction, not a full driver run (jq is unavailable). Before forced removal, check the initialized submodules live for unique commits and block the worktree if any are found; the audit-time list alone also needs revalidation because the workspace can change between audit and removal. claim `01M39WD94EGN0R98F393FGJN48` of review `01M39W1HVW5VE4BHR8WJKRSQ29`
Author
Owner

Fixed in 29216b3. Reproduced with ignore = all and a detached local commit: the removal went through. Right before the final status check, removal now walks every populated submodule, live. It keeps the worktree as judgment/submodule-local-work when a submodule has uncommitted files, a stash, a branch commit no remote-tracking ref holds, or an unrecorded HEAD no remote-tracking ref holds. Only remote-tracking refs count, because the submodule's local refs are deleted with the worktree. removal-gates.md documents the gate and says ignored files inside submodules are still not scanned.

<!-- gh-feedback:reply-to:87529 --> Fixed in 29216b3. Reproduced with `ignore = all` and a detached local commit: the removal went through. Right before the final status check, removal now walks every populated submodule, live. It keeps the worktree as `judgment/submodule-local-work` when a submodule has uncommitted files, a stash, a branch commit no remote-tracking ref holds, or an unrecorded HEAD no remote-tracking ref holds. Only remote-tracking refs count, because the submodule's local refs are deleted with the worktree. `removal-gates.md` documents the gate and says ignored files inside submodules are still not scanned.
jercik marked this conversation as resolved
Lines 506-509
@ -478,0 +503,7 @@
fetched_object=$(git -C "$submodule_path" rev-parse --verify --quiet FETCH_HEAD 2>>"$error_path") || return 1
fetched_commit=$(git -C "$submodule_path" rev-parse --verify --quiet "FETCH_HEAD^{commit}" 2>>"$error_path") || return 1
local_commit=$(git -C "$submodule_path" rev-parse --verify --quiet "refs/tags/$tag^{commit}" 2>/dev/null || true)
if [ -n "$local_commit" ] && [ "$local_commit" != "$fetched_commit" ] \
&& ! git -C "$submodule_path" merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null \
&& [ -z "$(git -C "$submodule_path" for-each-ref --format='%(refname)' --contains "$local_commit" 2>/dev/null | grep -v -x -F "refs/tags/$tag")" ]; then

high — A local tag on a non-commit object is silently overwritten
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined update_submodule_tag and the selector policy in references/checkout-updates.md, which says a differing local tag is replaced only if the fetched commit contains its commit or another ref holds it. The || true makes both a missing tag and an existing tag that cannot peel to a commit produce an empty local_commit; the guard then skips directly to update-ref. In a Git reproduction I made a local annotated v1 tag on a private blob, fetched a commit-tagged remote v1, and called update_submodule_tag. It returned 0, moved refs/tags/v1 to the fetched object, and for-each-ref --points-at found no ref for the old annotated tag object. The old tag and its message become unreachable and can be pruned. Check whether the local tag ref exists separately from whether it peels to a commit; refuse replacement if it exists but cannot peel, or explicitly preserve the object. I directly exercised the function, not the full driver (jq is unavailable).

claim 01M39WAEFSWWWYD9ARFQ9YDDRY of review 01M39W1HVW5VE4BHR8WJKRSQ29

<!-- review:claim:01M39WAEFSWWWYD9ARFQ9YDDRY --> **high** — A local tag on a non-commit object is silently overwritten lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined update_submodule_tag and the selector policy in references/checkout-updates.md, which says a differing local tag is replaced only if the fetched commit contains its commit or another ref holds it. The `|| true` makes both a missing tag and an existing tag that cannot peel to a commit produce an empty local_commit; the guard then skips directly to update-ref. In a Git reproduction I made a local annotated `v1` tag on a private blob, fetched a commit-tagged remote `v1`, and called update_submodule_tag. It returned 0, moved refs/tags/v1 to the fetched object, and `for-each-ref --points-at` found no ref for the old annotated tag object. The old tag and its message become unreachable and can be pruned. Check whether the local tag ref exists separately from whether it peels to a commit; refuse replacement if it exists but cannot peel, or explicitly preserve the object. I directly exercised the function, not the full driver (jq is unavailable). claim `01M39WAEFSWWWYD9ARFQ9YDDRY` of review `01M39W1HVW5VE4BHR8WJKRSQ29`
Author
Owner

Fixed in 29216b3. Reproduced with an annotated local v1 on a blob. update_submodule_tag now checks whether the tag ref exists separately from whether it peels to a commit. A tag that does not peel to a commit is kept and the update fails naming it. A local tag already on the fetched commit is left as it is. New test: "a local selector tag that does not point at a commit is kept".

<!-- gh-feedback:reply-to:87530 --> Fixed in 29216b3. Reproduced with an annotated local `v1` on a blob. `update_submodule_tag` now checks whether the tag ref exists separately from whether it peels to a commit. A tag that does not peel to a commit is kept and the update fails naming it. A local tag already on the fetched commit is left as it is. New test: "a local selector tag that does not point at a commit is kept".
jercik marked this conversation as resolved
Lines 550-554
@ -509,5 +548,7 @@
[ "$update_mode" != none ] || continue
if ! git -C "$checkout_path" submodule update --init --checkout -- "$configured_path" >>"$output_path" 2>>"$error_path"; then
submodule_path="$checkout_path/$configured_path"
if [ ! -e "$submodule_path/.git" ] \
&& ! git -C "$checkout_path" submodule update --init --checkout -- "$configured_path" >>"$output_path" 2>>"$error_path"; then
return 1
fi

medium — An extra .gitmodules entry can move an unrelated nested repository
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined synchronize_first_party_tracking_submodules, write_submodule_metadata, and the default-checkout eligibility gate. The new .git-exists shortcut treats any repository at a configured .gitmodules path as an initialized submodule, without checking that HEAD contains a Gitlink there. write_submodule_metadata also accepts an extra .gitmodules entry with no Gitlink. I reproduced this with a clean superproject that ignores nested/, lists nested in .gitmodules with branch = master, and has an independent Git clone at nested/: git submodule update --init --checkout -- nested rejected the pathspec, but calling synchronize_first_party_tracking_submodules succeeded, fetched the clone's origin, and moved its HEAD from a639f0c to b997b3c. A normal fresh default-checkout audit can therefore detach and move a repository it does not own. Require the configured path to be a Gitlink in the superproject and verify it is the registered submodule before fetching or checking out. The full driver was not run here because jq is unavailable; the changed function and Git behavior were directly exercised.

claim 01M39W9TK28DR3W6EA1VDTDZDZ of review 01M39W1HVW5VE4BHR8WJKRSQ29

<!-- review:claim:01M39W9TK28DR3W6EA1VDTDZDZ --> **medium** — An extra .gitmodules entry can move an unrelated nested repository lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined synchronize_first_party_tracking_submodules, write_submodule_metadata, and the default-checkout eligibility gate. The new .git-exists shortcut treats any repository at a configured .gitmodules path as an initialized submodule, without checking that HEAD contains a Gitlink there. write_submodule_metadata also accepts an extra .gitmodules entry with no Gitlink. I reproduced this with a clean superproject that ignores nested/, lists nested in .gitmodules with branch = master, and has an independent Git clone at nested/: `git submodule update --init --checkout -- nested` rejected the pathspec, but calling synchronize_first_party_tracking_submodules succeeded, fetched the clone's origin, and moved its HEAD from a639f0c to b997b3c. A normal fresh default-checkout audit can therefore detach and move a repository it does not own. Require the configured path to be a Gitlink in the superproject and verify it is the registered submodule before fetching or checking out. The full driver was not run here because jq is unavailable; the changed function and Git behavior were directly exercised. claim `01M39W9TK28DR3W6EA1VDTDZDZ` of review `01M39W1HVW5VE4BHR8WJKRSQ29`
Author
Owner

Fixed in 29216b3. Reproduced: a stray clone at a .gitmodules path with no Gitlink was moved. The selector loop now skips any entry whose path is not a Gitlink in HEAD. New test: "a .gitmodules entry without a Gitlink never moves the repository at its path".

<!-- gh-feedback:reply-to:87534 --> Fixed in 29216b3. Reproduced: a stray clone at a `.gitmodules` path with no Gitlink was moved. The selector loop now skips any entry whose path is not a Gitlink in HEAD. New test: "a .gitmodules entry without a Gitlink never moves the repository at its path".
jercik marked this conversation as resolved
Lines 1463-1464
@ -1414,11 +1460,20 @@ audit_worktree() {
write_submodule_metadata "$worktree_path" "$submodule_metadata_path"
submodule_metadata_ok=$(jq -r '.ok' "$submodule_metadata_path")
if [ "$outside_root" = false ] && [ -f "$worktree_path/.gitmodules" ]; then
if stranded_output=$(list_stranded_submodule_commits "$worktree_path" 2>/dev/null); then

critical — Deleting .gitmodules bypasses the unique submodule commit guard
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I traced audit_worktree's new preflight through has_only_nonoverlapping_deferred_guidance, the fast-forward, and synchronize_submodules. The preflight runs only when the working-tree .gitmodules file exists, although the checked-out HEAD can still contain a Gitlink after the user deletes that file locally. With submodule.dep.ignore=all in the superproject's local config, Git hides a detached submodule HEAD at a unique commit from status and diff; the only visible change is the .gitmodules deletion, which is accepted as deferred guidance. In a local Git reproduction, has_only_nonoverlapping_deferred_guidance returned 0, merge --ff-only succeeded, and submodule sync plus submodule update --init --recursive --checkout returned 0 while moving the submodule from its unreferenced local commit d432aeb to recorded commit 8fe7d8b. The local commit was left only in the submodule reflog, defeating the new preservation gate and allowing later GC to remove user work. Scan submodules based on committed/index Gitlinks even when the working .gitmodules file is missing, and block the update when a unique HEAD is found. I exercised the Bash gate and Git sequence; the complete driver could not run because jq is unavailable.

claim 01M39WCSXYV6VTH081YC1MZXDR of review 01M39W1HVW5VE4BHR8WJKRSQ29

<!-- review:claim:01M39WCSXYV6VTH081YC1MZXDR --> **critical** — Deleting .gitmodules bypasses the unique submodule commit guard lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I traced audit_worktree's new preflight through has_only_nonoverlapping_deferred_guidance, the fast-forward, and synchronize_submodules. The preflight runs only when the working-tree .gitmodules file exists, although the checked-out HEAD can still contain a Gitlink after the user deletes that file locally. With `submodule.dep.ignore=all` in the superproject's local config, Git hides a detached submodule HEAD at a unique commit from status and diff; the only visible change is the .gitmodules deletion, which is accepted as deferred guidance. In a local Git reproduction, has_only_nonoverlapping_deferred_guidance returned 0, merge --ff-only succeeded, and submodule sync plus `submodule update --init --recursive --checkout` returned 0 while moving the submodule from its unreferenced local commit d432aeb to recorded commit 8fe7d8b. The local commit was left only in the submodule reflog, defeating the new preservation gate and allowing later GC to remove user work. Scan submodules based on committed/index Gitlinks even when the working .gitmodules file is missing, and block the update when a unique HEAD is found. I exercised the Bash gate and Git sequence; the complete driver could not run because jq is unavailable. claim `01M39WCSXYV6VTH081YC1MZXDR` of review `01M39W1HVW5VE4BHR8WJKRSQ29`
Author
Owner

Fixed in 29216b3. Reproduced: with .gitmodules deleted and ignore = all in local config, the fast-forward stranded the commit. The walk now reads Gitlinks from HEAD and the index, not .gitmodules, and runs on every checkout. New test: "a deleted .gitmodules does not hide a submodule commit no ref holds".

<!-- gh-feedback:reply-to:87528 --> Fixed in 29216b3. Reproduced: with `.gitmodules` deleted and `ignore = all` in local config, the fast-forward stranded the commit. The walk now reads Gitlinks from HEAD and the index, not `.gitmodules`, and runs on every checkout. New test: "a deleted .gitmodules does not hide a submodule commit no ref holds".
jercik marked this conversation as resolved
Lines 315-317
@ -310,6 +312,12 @@ export function formatAuditReport(report: AuditReport): string {
const isMain = worktree.registration?.isMain === true;
const onDefault = worktree.status?.branch.isDetached === false && worktree.status.branch.current === defaultBranch;
if ((isMain || onDefault) && worktree.strandedSubmodules?.length !== 0) {
const stranded = worktree.strandedSubmodules;
const why = stranded === null ? "submodule check failed" : `submodule on a commit no ref holds: ${listPaths(stranded)}`;

high — Renderer crashes on saved schema-5 reports lacking strandedSubmodules
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined formatAuditReport, parseReport, and the unchanged schema version. The new strandedSubmodules field was added to worktree records without changing SCHEMA_VERSION (still 5). A previously saved, valid schema-5 report has no such field. Optional chaining yields undefined for .length, and undefined !== 0 enters this branch; the non-null path then calls listPaths(undefined), which calls slice and throws. I reproduced this with Node 26 by passing a schema-5 report with one current main worktree and no strandedSubmodules to formatAuditReport: it threw TypeError: Cannot read properties of undefined (reading 'slice'). The skill tells users to keep audit reports, so a saved report cannot be rendered with the updated renderer even though parseReport accepts its schema. Treat an absent field as an empty list or bump the schema and handle older reports explicitly. The reproduction exercised the renderer directly.

claim 01M39WEH2JS8JNKF2XVF3CWAGA of review 01M39W1HVW5VE4BHR8WJKRSQ29

<!-- review:claim:01M39WEH2JS8JNKF2XVF3CWAGA --> **high** — Renderer crashes on saved schema-5 reports lacking strandedSubmodules lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined formatAuditReport, parseReport, and the unchanged schema version. The new strandedSubmodules field was added to worktree records without changing SCHEMA_VERSION (still 5). A previously saved, valid schema-5 report has no such field. Optional chaining yields undefined for `.length`, and `undefined !== 0` enters this branch; the non-null path then calls listPaths(undefined), which calls slice and throws. I reproduced this with Node 26 by passing a schema-5 report with one current main worktree and no strandedSubmodules to formatAuditReport: it threw `TypeError: Cannot read properties of undefined (reading 'slice')`. The skill tells users to keep audit reports, so a saved report cannot be rendered with the updated renderer even though parseReport accepts its schema. Treat an absent field as an empty list or bump the schema and handle older reports explicitly. The reproduction exercised the renderer directly. claim `01M39WEH2JS8JNKF2XVF3CWAGA` of review `01M39W1HVW5VE4BHR8WJKRSQ29`

medium — Report a failed submodule check as a failure, not a user decision
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I read the new renderer branch, stepFailure(), the audit_worktree() check, and the added renderer test. audit_worktree() sets strandedSubmodules to null when its recursive submodule check fails, while stepFailure() does not recognize that state. The new branch then writes "submodule check failed; not updated" under "Needs your decision: not current" and leaves the Failures count at zero. The user cannot decide what to do with an unknown submodule state; the audit has incomplete coverage and needs the check error investigated or a rerun. Put the null case in Failures (and include the check error if available), while keeping a nonempty list of identified submodule paths under Needs your decision. This follows the writing standard requirement for precise, actionable outcome and evidence wording and preserves the useful path-based decision for actual stranded commits. The test explicitly expects the current null wording, but I did not reproduce a failed Git check; the classification follows statically from the null assignment and renderer branch.

claim 01M39W6HVRYAA5F505Z0GTWG2D of review 01M39W1HVW5VE4BHR8WJKRSQ29

<!-- review:claim:01M39W6HVRYAA5F505Z0GTWG2D --> **medium** — Report a failed submodule check as a failure, not a user decision lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I read the new renderer branch, stepFailure(), the audit_worktree() check, and the added renderer test. audit_worktree() sets strandedSubmodules to null when its recursive submodule check fails, while stepFailure() does not recognize that state. The new branch then writes "submodule check failed; not updated" under "Needs your decision: not current" and leaves the Failures count at zero. The user cannot decide what to do with an unknown submodule state; the audit has incomplete coverage and needs the check error investigated or a rerun. Put the null case in Failures (and include the check error if available), while keeping a nonempty list of identified submodule paths under Needs your decision. This follows the writing standard requirement for precise, actionable outcome and evidence wording and preserves the useful path-based decision for actual stranded commits. The test explicitly expects the current null wording, but I did not reproduce a failed Git check; the classification follows statically from the null assignment and renderer branch. claim `01M39W6HVRYAA5F505Z0GTWG2D` of review `01M39W1HVW5VE4BHR8WJKRSQ29`
Author
Owner

Fixed in 29216b3 by bumping the report schema to 6 in the driver and the renderer. A saved version-5 report is now refused with expected report schemaVersion 6, got 5 instead of crashing. That follows the repo's rule against compatibility shims: rerun the audit to get a current report.

<!-- gh-feedback:reply-to:87531 --> Fixed in 29216b3 by bumping the report schema to 6 in the driver and the renderer. A saved version-5 report is now refused with `expected report schemaVersion 6, got 5` instead of crashing. That follows the repo's rule against compatibility shims: rerun the audit to get a current report.
Author
Owner

Fixed in 29216b3. A null strandedSubmodules now renders as the failure submodule check failed; checkout not updated. Only a non-empty list of submodule paths goes under "Needs your decision".

<!-- gh-feedback:reply-to:87535 --> Fixed in 29216b3. A null `strandedSubmodules` now renders as the failure `submodule check failed; checkout not updated`. Only a non-empty list of submodule paths goes under "Needs your decision".
jercik marked this conversation as resolved
Author
Owner

Replying to review summary comment #87417

The round-2 report (review 01M39T0ST0ZAZJTHPM4AJGVRA3, head 85dfb5b) had one finding with no inline thread. Recording its outcome here, because that report has since been rewritten.

  • high: post-fast-forward recursive checkout still strands commits no ref holds: agreed, fixed in feb2194. Reproduced both routes you described: a nested submodule, and ignore = all. The per-path guard is gone. Before any fast-forward or selector move, the driver now walks every populated submodule with git submodule foreach --recursive. If any HEAD is neither the commit its superproject records nor held by a ref, that checkout gets no fast-forward and no selector move, and the report lists it as a decision. The tests cover both routes.
> Replying to review summary comment #87417 The round-2 report (review `01M39T0ST0ZAZJTHPM4AJGVRA3`, head 85dfb5b) had one finding with no inline thread. Recording its outcome here, because that report has since been rewritten. - **high: post-fast-forward recursive checkout still strands commits no ref holds: agreed, fixed in feb2194.** Reproduced both routes you described: a nested submodule, and `ignore = all`. The per-path guard is gone. Before any fast-forward or selector move, the driver now walks every populated submodule with `git submodule foreach --recursive`. If any HEAD is neither the commit its superproject records nor held by a ref, that checkout gets no fast-forward and no selector move, and the report lists it as a decision. The tests cover both routes.
fix(audit-git-checkouts): keep worktrees whose submodules hold work only they have
Some checks failed
commit-msg / commitlint (pull_request) Failing after 16s
Node tests / node:test (pull_request) Successful in 1m5s
Review / Review (pull_request_target) Successful in 23m42s
29216b300e
Removing a linked worktree deletes its submodules' Git directories, and the
forced removal skipped Git's own submodule refusal. Right before removal the
driver now walks every populated submodule and keeps the worktree
(`judgment/submodule-local-work`) when one has uncommitted files, a stash, a
branch commit no remote-tracking ref holds, or an unrecorded HEAD no
remote-tracking ref holds.

The walk reads Gitlinks from HEAD and the index, not `.gitmodules`, so a
deleted `.gitmodules` or an `ignore` setting no longer hides a stranded
submodule from the update gate. Selector moves skip `.gitmodules` entries
that are not Gitlinks, and a local selector tag that does not point at a
commit is kept. A failed submodule check is now a failure row. The report
schema is now version 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jercik changed title from fix(audit-git-checkouts): audits should not strand submodule commits or local tags that no other ref holds to fix(audit-git-checkouts): audits should never lose commits that only a submodule holds 2026-09-24 15:16:43 +00:00
@ -16,3 +16,3 @@
| Request | Flags | What changes |
| --- | --- | --- |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |

low — Mode table cell restates submodule mechanics already carried by the bullet below it and by checkout-updates.md
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Anchor note: the passage I mean is the middle clause of the default row's "What changes" cell in the "Choose the mode" table — "in first-party default checkouts, check each submodule listed in the checkout's own .gitmodules out at its configured branch or tag, leaving the Gitlink change unstaged" — together with the clause before it, "then initialize their submodules and check them out at the recorded commits".

What I examined: the "Choose the mode" table in SKILL.md, the four stricter-mode bullets directly below it, and the "What the driver already updates" and "Submodule selectors" sections of references/checkout-updates.md.

What the subject says: the default row's cell is now five semicolon-joined clauses of about 75 words, up from about 30, with the two clauses quoted above newly added.

What goes wrong, three ways:

  1. One idea, three places. Both new clauses are restated in the fourth stricter-mode bullet nine lines below ("After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules...") and again in checkout-updates.md ("After a fast-forward it initializes committed submodules and checks them out at the recorded Gitlinks"; "the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit"). SKILL.md is the always-loaded file, which is where the writing standard says to cut most aggressively, and the duplication is already drifting: the bullet's copy adds a condition ("when nothing fast-forwards") the cell's copy does not have, and the two cannot both be right.

  2. The detail does not serve the table's job. This table exists to pick a mode, and rows 2 and 3 are defined relative to row 1 ("Everything above except removal"). Submodule mechanics do not distinguish the three modes; the stricter-mode bullet below is where the reader is actually told when submodule movement should push them to --no-fetch --no-remove.

  3. The cell is hard to parse at the point of use. The clause separates the verb "check" from its particle "out" by eleven words, so the reader reaches "out at its configured branch or tag" having already committed to a different reading of "check each submodule listed in ...".

Correction: return the cell to a scannable summary — "Fetch and prune origin; fast-forward eligible default checkouts and move their submodules; prune stale worktree registrations; remove proven-merged linked worktrees inside the root." — and let the fourth bullet carry the recorded-commit-versus-selector distinction, with checkout-updates.md carrying the mechanism. That preserves everything the cell needs for a mode choice (fetching, fast-forwarding, submodule movement, pruning, removal) and leaves one home for each fact.

What would refute this: a reader decision that turns on the submodule detail sitting inside the table rather than in the bullet below it. I read the whole section and found none; the stricter-mode bullet is the only place that choice is made.

claim 01M3A0590N60944F6QWN1CRD3G of review 01M39ZTYXRMDP1E05ME1J23DF1

<!-- review:claim:01M3A0590N60944F6QWN1CRD3G --> **low** — Mode table cell restates submodule mechanics already carried by the bullet below it and by checkout-updates.md lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Anchor note: the passage I mean is the middle clause of the default row's "What changes" cell in the "Choose the mode" table — "in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged" — together with the clause before it, "then initialize their submodules and check them out at the recorded commits". > > What I examined: the "Choose the mode" table in SKILL.md, the four stricter-mode bullets directly below it, and the "What the driver already updates" and "Submodule selectors" sections of references/checkout-updates.md. > > What the subject says: the default row's cell is now five semicolon-joined clauses of about 75 words, up from about 30, with the two clauses quoted above newly added. > > What goes wrong, three ways: > > 1. One idea, three places. Both new clauses are restated in the fourth stricter-mode bullet nine lines below ("After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules...") and again in checkout-updates.md ("After a fast-forward it initializes committed submodules and checks them out at the recorded Gitlinks"; "the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit"). SKILL.md is the always-loaded file, which is where the writing standard says to cut most aggressively, and the duplication is already drifting: the bullet's copy adds a condition ("when nothing fast-forwards") the cell's copy does not have, and the two cannot both be right. > > 2. The detail does not serve the table's job. This table exists to pick a mode, and rows 2 and 3 are defined relative to row 1 ("Everything above except removal"). Submodule mechanics do not distinguish the three modes; the stricter-mode bullet below is where the reader is actually told when submodule movement should push them to `--no-fetch --no-remove`. > > 3. The cell is hard to parse at the point of use. The clause separates the verb "check" from its particle "out" by eleven words, so the reader reaches "out at its configured branch or tag" having already committed to a different reading of "check each submodule listed in ...". > > Correction: return the cell to a scannable summary — "Fetch and prune `origin`; fast-forward eligible default checkouts and move their submodules; prune stale worktree registrations; remove proven-merged linked worktrees inside the root." — and let the fourth bullet carry the recorded-commit-versus-selector distinction, with checkout-updates.md carrying the mechanism. That preserves everything the cell needs for a mode choice (fetching, fast-forwarding, submodule movement, pruning, removal) and leaves one home for each fact. > > What would refute this: a reader decision that turns on the submodule detail sitting inside the table rather than in the bullet below it. I read the whole section and found none; the stricter-mode bullet is the only place that choice is made. claim `01M3A0590N60944F6QWN1CRD3G` of review `01M39ZTYXRMDP1E05ME1J23DF1`
jercik marked this conversation as resolved
@ -23,3 +23,4 @@
- A repository whose work integrates somewhere other than `origin/<default>`, such as a fork with an `upstream`: `--no-remove`. Removal proves containment against `origin/<default>` only.
- A repository with a custom `origin` fetch refspec: `--no-fetch --no-remove`. The fetch prunes every `refs/remotes/origin/*` ref that no server branch supplies (see [references/checkout-updates.md](references/checkout-updates.md)).
- A checkout whose submodules must stay where they are: `--no-fetch --no-remove`. After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and `update = none` stops only that move.

medium — SKILL.md says selector moves happen "when nothing fast-forwards", but they also run after a successful fast-forward
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new fourth bullet under "Choose the mode" in SKILL.md, the eligibility block for the selector move in scripts/audit-checkouts.sh (the tracking_update_eligible conditional inside audit_worktree), and the parallel wording in references/checkout-updates.md and the script's --help text.

What the subject says: the bullet reads "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and update = none stops only that move."

What the code does: the selector move is gated by
if [ "$comparison_fresh" = true ] && [ "$stranded_submodules" = '[]' ] && ! is_third_party_checkout "$worktree_path" && { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } ...
The fast-forward clause admits two cases: no fast-forward was attempted, and a fast-forward that succeeded. Only a failed fast-forward blocks it. So a first-party default checkout that does fast-forward gets its submodules checked out at the new recorded Gitlinks and then, in the same run, has its branch- or tag-tracked submodules floated off those Gitlinks.

What goes wrong: "when nothing fast-forwards" reads to a literal reader as a restriction — selector moves happen only in checkouts that did not fast-forward. An agent applying that model tells the user that a checkout which is behind origin will have its first-party submodules left at the recorded commits, when in fact those submodules are moved to their configured branch tip or tag and the Gitlink change is left unstaged in the working tree. This bullet exists precisely to tell the reader when to reach for the stricter mode, so a narrowed description of the automatic behavior undercuts the decision it is written to support.

Correction: state the additive relation the code implements, for example "In first-party checkouts it also moves branch- or tag-tracked submodules, whether or not the checkout fast-forwards; only a failed fast-forward stops the move, and update = none stops the move alone." That preserves the bullet's two useful facts (the selector move is first-party-only, and update = none opts out of the float but not the Gitlink checkout) while removing the false restriction.

What would refute this: a path where fast_forward_ok=true still skips synchronize_first_party_tracking_submodules. I traced the only assignment of tracking_update_eligible and found none; the surrounding conditions (fresh comparison, on the default branch, clean or deferred-guidance-only tree) are the same ones the fast-forward itself requires, so they do not exclude the fast-forwarded case.

claim 01M39ZZZYMYDQRPQ3RYHRT65DR of review 01M39ZTYXRMDP1E05ME1J23DF1

<!-- review:claim:01M39ZZZYMYDQRPQ3RYHRT65DR --> **medium** — SKILL.md says selector moves happen "when nothing fast-forwards", but they also run after a successful fast-forward lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new fourth bullet under "Choose the mode" in SKILL.md, the eligibility block for the selector move in scripts/audit-checkouts.sh (the `tracking_update_eligible` conditional inside `audit_worktree`), and the parallel wording in references/checkout-updates.md and the script's --help text. > > What the subject says: the bullet reads "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and `update = none` stops only that move." > > What the code does: the selector move is gated by > if [ "$comparison_fresh" = true ] && [ "$stranded_submodules" = '[]' ] && ! is_third_party_checkout "$worktree_path" && { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } ... > The fast-forward clause admits two cases: no fast-forward was attempted, and a fast-forward that succeeded. Only a *failed* fast-forward blocks it. So a first-party default checkout that does fast-forward gets its submodules checked out at the new recorded Gitlinks and then, in the same run, has its branch- or tag-tracked submodules floated off those Gitlinks. > > What goes wrong: "when nothing fast-forwards" reads to a literal reader as a restriction — selector moves happen only in checkouts that did not fast-forward. An agent applying that model tells the user that a checkout which is behind origin will have its first-party submodules left at the recorded commits, when in fact those submodules are moved to their configured branch tip or tag and the Gitlink change is left unstaged in the working tree. This bullet exists precisely to tell the reader when to reach for the stricter mode, so a narrowed description of the automatic behavior undercuts the decision it is written to support. > > Correction: state the additive relation the code implements, for example "In first-party checkouts it also moves branch- or tag-tracked submodules, whether or not the checkout fast-forwards; only a failed fast-forward stops the move, and `update = none` stops the move alone." That preserves the bullet's two useful facts (the selector move is first-party-only, and `update = none` opts out of the float but not the Gitlink checkout) while removing the false restriction. > > What would refute this: a path where `fast_forward_ok=true` still skips `synchronize_first_party_tracking_submodules`. I traced the only assignment of `tracking_update_eligible` and found none; the surrounding conditions (fresh comparison, on the default branch, clean or deferred-guidance-only tree) are the same ones the fast-forward itself requires, so they do not exclude the fast-forwarded case. claim `01M39ZZZYMYDQRPQ3RYHRT65DR` of review `01M39ZTYXRMDP1E05ME1J23DF1`
jercik marked this conversation as resolved
@ -18,3 +18,3 @@
- **Under a `third-party/` path component:** `.gitmodules` and Gitlinks belong to upstream. Never add or change selectors, URLs, paths, update policies, or Gitlinks; only synchronize what upstream committed.
- **Everywhere else (first-party):** each submodule needs exactly one selector, `branch = <name>` for a moving line or `tag = <name>` for an exact release. On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit.
- **Everywhere else (first-party):** each submodule needs exactly one selector, `branch = <name>` for a moving line or `tag = <name>` for an exact release. On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit. Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits. A local tag of the configured name on the same commit is kept as it is. One on another commit is replaced only when the fetched tag's commit contains that commit or another ref holds it. Otherwise, or when the local tag does not point at a commit, that submodule stays where it is, the local tag is kept, and the selector update fails naming the tag.

low — checkout-updates.md claims nested submodules follow their recorded commits, but the selector move checks out without --recurse-submodules
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the first-party bullet under "Submodule selectors" in references/checkout-updates.md, and the two functions in scripts/audit-checkouts.sh that move submodules — synchronize_submodules (the post-fast-forward sync) and synchronize_first_party_tracking_submodules (the selector move) — plus their call order in audit_worktree.

What the subject says: the new clause is "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move; nested submodules follow their recorded commits." It sits inside the bullet describing the first-party selector move, so a reader takes it as a statement about the state that move leaves behind.

What the code does, and where the two halves diverge:

  • The post-fast-forward sync recurses: git submodule sync --recursive followed by git submodule update --init --recursive --checkout. After that step, nested submodules genuinely are at their recorded commits.
  • The selector move does not. For each selector-bearing entry it runs git -C "$submodule_path" checkout --detach "$target_sha" — a plain checkout with no --recurse-submodules. grep -n 'recurse-submodules' scripts/audit-checkouts.sh returns nothing in the whole script.

The two run in that order in audit_worktree (fast-forward, then sync, then the selector move), so the sequence is: nested submodules are checked out at the commits the parent's old commit recorded, then the parent submodule is moved to a branch tip or tag without touching them. Whatever the parent's new commit records for its nested submodules is not applied. The nested submodule is left where the previous commit put it, and the parent submodule reports modified content.

What goes wrong: the clause asserts a post-condition the selector path does not establish. An agent that trusts it describes a run's outcome to the user as "submodules are at their recorded commits" and skips inspecting a nested submodule that is not, which matters here because this skill's whole discipline is reporting checkout state precisely (the same file already warns "never describe a failed multi-step update as atomic"). The clause is also ambiguous as written: it can be read as "no selector in a nested .gitmodules is honored" — which is true — or as "nested submodules end up at their recorded commits" — which is not true after a selector move. One sentence should not carry both readings.

Correction: state the scope rather than a post-condition, for example "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move; a selector in a nested .gitmodules is never honored, and a selector move does not recurse, so a moved submodule's own submodules stay where the last Gitlink sync put them." That keeps the useful boundary the clause was added for (nested submodules are outside the selector rule) and replaces the false half with the reason a reader needs.

Proof gap: this is traced statically from the two functions and their call order; I did not execute the driver. Running a selector move over a superproject whose tracked submodule has a nested submodule, and comparing the nested HEAD with git -C <parent-submodule> rev-parse HEAD:<nested-path> afterwards, would settle it. The change's own nested fixture (createTrackedSubmoduleFixture with nested: true) builds the needed shape but only asserts the stranded-blocking case, never the post-move nested position.

claim 01M3A07TG1XVJNNE29C5SVFME2 of review 01M39ZTYXRMDP1E05ME1J23DF1

<!-- review:claim:01M3A07TG1XVJNNE29C5SVFME2 --> **low** — checkout-updates.md claims nested submodules follow their recorded commits, but the selector move checks out without --recurse-submodules lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the first-party bullet under "Submodule selectors" in references/checkout-updates.md, and the two functions in scripts/audit-checkouts.sh that move submodules — synchronize_submodules (the post-fast-forward sync) and synchronize_first_party_tracking_submodules (the selector move) — plus their call order in audit_worktree. > > What the subject says: the new clause is "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits." It sits inside the bullet describing the first-party selector move, so a reader takes it as a statement about the state that move leaves behind. > > What the code does, and where the two halves diverge: > > - The post-fast-forward sync recurses: `git submodule sync --recursive` followed by `git submodule update --init --recursive --checkout`. After that step, nested submodules genuinely are at their recorded commits. > - The selector move does not. For each selector-bearing entry it runs `git -C "$submodule_path" checkout --detach "$target_sha"` — a plain checkout with no `--recurse-submodules`. `grep -n 'recurse-submodules' scripts/audit-checkouts.sh` returns nothing in the whole script. > > The two run in that order in audit_worktree (fast-forward, then sync, then the selector move), so the sequence is: nested submodules are checked out at the commits the parent's *old* commit recorded, then the parent submodule is moved to a branch tip or tag without touching them. Whatever the parent's new commit records for its nested submodules is not applied. The nested submodule is left where the previous commit put it, and the parent submodule reports modified content. > > What goes wrong: the clause asserts a post-condition the selector path does not establish. An agent that trusts it describes a run's outcome to the user as "submodules are at their recorded commits" and skips inspecting a nested submodule that is not, which matters here because this skill's whole discipline is reporting checkout state precisely (the same file already warns "never describe a failed multi-step update as atomic"). The clause is also ambiguous as written: it can be read as "no selector in a nested `.gitmodules` is honored" — which is true — or as "nested submodules end up at their recorded commits" — which is not true after a selector move. One sentence should not carry both readings. > > Correction: state the scope rather than a post-condition, for example "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; a selector in a nested `.gitmodules` is never honored, and a selector move does not recurse, so a moved submodule's own submodules stay where the last Gitlink sync put them." That keeps the useful boundary the clause was added for (nested submodules are outside the selector rule) and replaces the false half with the reason a reader needs. > > Proof gap: this is traced statically from the two functions and their call order; I did not execute the driver. Running a selector move over a superproject whose tracked submodule has a nested submodule, and comparing the nested HEAD with `git -C <parent-submodule> rev-parse HEAD:<nested-path>` afterwards, would settle it. The change's own nested fixture (createTrackedSubmoduleFixture with `nested: true`) builds the needed shape but only asserts the stranded-blocking case, never the post-move nested position. claim `01M3A07TG1XVJNNE29C5SVFME2` of review `01M39ZTYXRMDP1E05ME1J23DF1`
jercik marked this conversation as resolved
Lines 29-30
@ -27,0 +26,5 @@
echo " submodule selectors advanced outside third-party/ paths; removal of in-root linked"
echo " worktrees that pass every removal gate. A checkout with a submodule, at any depth, on a"
echo " commit neither recorded nor held by a ref gets no fast-forward or selector move; a local"
echo " selector tag is replaced only when the fetched tag or another ref keeps its commit; a"
echo " worktree whose submodules hold work no remote-tracking ref keeps is not removed."

low — New --help lines say a ref "keeps" a commit, a third verb for the skill's "holds", in a clause that garden-paths
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the "Writes in a default run" paragraph of usage() in scripts/audit-checkouts.sh as this change rewrote it, the same rules as stated in references/checkout-updates.md and references/removal-gates.md, the term's other uses across the skill, and update_submodule_tag / submodule_holds_local_work in the script.

What the subject says: two of the new help lines read "a local selector tag is replaced only when the fetched tag or another ref keeps its commit; a worktree whose submodules hold work no remote-tracking ref keeps is not removed."

What goes wrong, two things in one sentence pair:

  1. "keeps" is a third verb for a relation the skill already names consistently. SKILL.md says "commits no other ref holds"; checkout-updates.md says "no ref holds" and "another ref holds it"; removal-gates.md says "a branch commit no remote-tracking ref holds"; the new help line two lines above this one says "neither recorded nor held by a ref". grep -rn 'keeps\b' over the skill returns these two help lines as the only places a ref "keeps" a commit — everywhere else it is "holds". The writing standard asks for one term per concept; "keeps" additionally carries a plain-English sense of "retains/preserves" that pulls the reader away from reachability, which is what the code tests (merge-base --is-ancestor "$local_commit" "$fetched_commit" and for-each-ref --contains).

  2. "whose submodules hold work no remote-tracking ref keeps is not removed" is a garden path. With two bare relative clauses stacked and no relative pronoun, the reader hits "keeps is" before the structure resolves. A reader scanning --help for what blocks removal has to re-parse the clause to recover the rule.

The ambiguity is not only stylistic: "the fetched tag ... keeps its commit" leaves both the antecedent of "its" (the local tag's commit, not the fetched tag's) and the relation (the fetched commit has the local commit as an ancestor) unstated, so the help text does not let the reader predict when the driver rewrites a local tag. checkout-updates.md states it exactly — "the fetched tag's commit contains that commit or another ref holds it" — which is the wording this line should mirror.

Correction, keeping the same length budget: "... a local selector tag is replaced only when the fetched tag's commit contains the local tag's commit, or another ref holds that commit; a worktree is not removed while a submodule holds work no remote-tracking ref holds." That preserves both facts and aligns the help text with the two references and with SKILL.md.

What would refute this: an established use of "keeps" for this relation elsewhere in the skill. The grep above found none; the other four matches are unrelated ("keeps a status.showUntrackedFiles=no config from hiding...", "the machine keeps those files elsewhere", and two test/comment uses).

claim 01M3A02CWGVN82CZM0HJRW6KZY of review 01M39ZTYXRMDP1E05ME1J23DF1

<!-- review:claim:01M3A02CWGVN82CZM0HJRW6KZY --> **low** — New --help lines say a ref "keeps" a commit, a third verb for the skill's "holds", in a clause that garden-paths lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the "Writes in a default run" paragraph of usage() in scripts/audit-checkouts.sh as this change rewrote it, the same rules as stated in references/checkout-updates.md and references/removal-gates.md, the term's other uses across the skill, and update_submodule_tag / submodule_holds_local_work in the script. > > What the subject says: two of the new help lines read "a local selector tag is replaced only when the fetched tag or another ref keeps its commit; a worktree whose submodules hold work no remote-tracking ref keeps is not removed." > > What goes wrong, two things in one sentence pair: > > 1. "keeps" is a third verb for a relation the skill already names consistently. SKILL.md says "commits no other ref holds"; checkout-updates.md says "no ref holds" and "another ref holds it"; removal-gates.md says "a branch commit no remote-tracking ref holds"; the new help line two lines above this one says "neither recorded nor held by a ref". `grep -rn 'keeps\b'` over the skill returns these two help lines as the only places a ref "keeps" a commit — everywhere else it is "holds". The writing standard asks for one term per concept; "keeps" additionally carries a plain-English sense of "retains/preserves" that pulls the reader away from reachability, which is what the code tests (`merge-base --is-ancestor "$local_commit" "$fetched_commit"` and `for-each-ref --contains`). > > 2. "whose submodules hold work no remote-tracking ref keeps is not removed" is a garden path. With two bare relative clauses stacked and no relative pronoun, the reader hits "keeps is" before the structure resolves. A reader scanning --help for what blocks removal has to re-parse the clause to recover the rule. > > The ambiguity is not only stylistic: "the fetched tag ... keeps its commit" leaves both the antecedent of "its" (the local tag's commit, not the fetched tag's) and the relation (the fetched commit has the local commit as an ancestor) unstated, so the help text does not let the reader predict when the driver rewrites a local tag. checkout-updates.md states it exactly — "the fetched tag's commit contains that commit or another ref holds it" — which is the wording this line should mirror. > > Correction, keeping the same length budget: "... a local selector tag is replaced only when the fetched tag's commit contains the local tag's commit, or another ref holds that commit; a worktree is not removed while a submodule holds work no remote-tracking ref holds." That preserves both facts and aligns the help text with the two references and with SKILL.md. > > What would refute this: an established use of "keeps" for this relation elsewhere in the skill. The grep above found none; the other four matches are unrelated ("keeps a status.showUntrackedFiles=no config from hiding...", "the machine keeps those files elsewhere", and two test/comment uses). claim `01M3A02CWGVN82CZM0HJRW6KZY` of review `01M39ZTYXRMDP1E05ME1J23DF1`
jercik marked this conversation as resolved
Lines 499-502
@ -478,0 +496,7 @@
mode=$2
prefix=${3:-}
gitlink_paths=$({
git -C "$superproject_path" ls-files --stage -z
git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null
} | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1

medium — An unborn-HEAD checkout fails the new submodule walk, so every commit-less repository is reported as "submodule check failed"
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new list_submodules_with_local_work in skills/audit-git-checkouts/scripts/audit-checkouts.sh, its update-mode caller in audit_worktree (if stranded_output=$(list_submodules_with_local_work "$worktree_path" update 2>/dev/null); then ... else stranded_submodules=null; fi), and stepFailure in render-audit-report.ts, which this change extended with if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";.

Mechanism: the script runs under set -o pipefail. In a repository with no commits, git ls-tree -r -z --full-tree HEAD exits 128 ("Not a valid object name HEAD"); its stderr is discarded but its exit status is not, so the brace group's status is non-zero, pipefail propagates it through tr | awk | sort, and the || return 1 fires. The function returns failure for a reason that has nothing to do with submodules.

Observed, not inferred. Sourcing the script and calling the function directly on a freshly git inited repository:

out=$(list_submodules_with_local_work /tmp/lab/unborn update 2>/dev/null); echo rc=$?
rc=1     # unborn HEAD
rc=0     # the same repository after one commit

End to end through audit_worktree (invoked the way audit-checkouts.test.mjs invokes it, with a fake repoq reporting isUnborn: true), the emitted record is {"strandedSubmodules": null, "classification": "manual-review", "fastForward.attempted": false}. Feeding that record to formatAuditReport puts the checkout in the Failures table as | newproject | submodule check failed; checkout not updated |. Setting only strandedSubmodules to [] on the same record moves it back to "Needs your decision" as | newproject | main | n/a | unborn branch (no commits yet) |, which is the pre-change rendering that render-audit-report.ts still implements for isUnborn.

What goes wrong: a brand-new repository under the audit root -- git init with nothing committed yet, a case this codebase handles explicitly elsewhere (classify_checkout's isUnborn branch, the reason: "unborn-branch" containment short-circuit, and the unborn branch (no commits yet) render line) -- is now reported as an operational failure of a check that had nothing to examine. The accurate "unborn branch" line becomes unreachable for main/default checkouts because stepFailure runs before primaryDecision. The user is pointed at a non-existent submodule problem.

A safe correction: tolerate a missing HEAD, e.g. git ls-tree -r -z --full-tree HEAD 2>/dev/null || true, so the walk falls back to the index alone (git ls-files --stage -z succeeds in an unborn repository and correctly yields no Gitlinks); or skip the check entirely when the status snapshot reports an unborn branch.

What would refute it: evidence that an unborn-HEAD checkout cannot reach audit_worktree. It can -- audit_worktree calls the function for every worktree with outside_root = false, before write_worktree_snapshot, with no unborn guard, and the reproduction above shows it reaching it. No test in this change covers an unborn checkout; the suite passes (62 pass, 1 skipped) without exercising it.

claim 01M3A08NB6MVVT98N5F4PBSKRH of review 01M39ZTYXRMDP1E05ME1J23DF1

<!-- review:claim:01M3A08NB6MVVT98N5F4PBSKRH --> **medium** — An unborn-HEAD checkout fails the new submodule walk, so every commit-less repository is reported as "submodule check failed" lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new `list_submodules_with_local_work` in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, its `update`-mode caller in `audit_worktree` (`if stranded_output=$(list_submodules_with_local_work "$worktree_path" update 2>/dev/null); then ... else stranded_submodules=null; fi`), and `stepFailure` in `render-audit-report.ts`, which this change extended with `if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";`. > > Mechanism: the script runs under `set -o pipefail`. In a repository with no commits, `git ls-tree -r -z --full-tree HEAD` exits 128 ("Not a valid object name HEAD"); its stderr is discarded but its exit status is not, so the brace group's status is non-zero, pipefail propagates it through `tr | awk | sort`, and the `|| return 1` fires. The function returns failure for a reason that has nothing to do with submodules. > > Observed, not inferred. Sourcing the script and calling the function directly on a freshly `git init`ed repository: > > out=$(list_submodules_with_local_work /tmp/lab/unborn update 2>/dev/null); echo rc=$? > rc=1 # unborn HEAD > rc=0 # the same repository after one commit > > End to end through `audit_worktree` (invoked the way `audit-checkouts.test.mjs` invokes it, with a fake `repoq` reporting `isUnborn: true`), the emitted record is `{"strandedSubmodules": null, "classification": "manual-review", "fastForward.attempted": false}`. Feeding that record to `formatAuditReport` puts the checkout in the Failures table as `| newproject | submodule check failed; checkout not updated |`. Setting only `strandedSubmodules` to `[]` on the same record moves it back to "Needs your decision" as `| newproject | main | n/a | unborn branch (no commits yet) |`, which is the pre-change rendering that `render-audit-report.ts` still implements for `isUnborn`. > > What goes wrong: a brand-new repository under the audit root -- `git init` with nothing committed yet, a case this codebase handles explicitly elsewhere (`classify_checkout`'s `isUnborn` branch, the `reason: "unborn-branch"` containment short-circuit, and the `unborn branch (no commits yet)` render line) -- is now reported as an operational failure of a check that had nothing to examine. The accurate "unborn branch" line becomes unreachable for main/default checkouts because `stepFailure` runs before `primaryDecision`. The user is pointed at a non-existent submodule problem. > > A safe correction: tolerate a missing HEAD, e.g. `git ls-tree -r -z --full-tree HEAD 2>/dev/null || true`, so the walk falls back to the index alone (`git ls-files --stage -z` succeeds in an unborn repository and correctly yields no Gitlinks); or skip the check entirely when the status snapshot reports an unborn branch. > > What would refute it: evidence that an unborn-HEAD checkout cannot reach `audit_worktree`. It can -- `audit_worktree` calls the function for every worktree with `outside_root = false`, before `write_worktree_snapshot`, with no unborn guard, and the reproduction above shows it reaching it. No test in this change covers an unborn checkout; the suite passes (62 pass, 1 skipped) without exercising it. claim `01M3A08NB6MVVT98N5F4PBSKRH` of review `01M39ZTYXRMDP1E05ME1J23DF1`
Author
Owner

Fixed in e9f38b7. Reproduced: pipefail carried ls-tree HEAD's exit 128 out of the walk. It is now || true, so an unborn repository falls back to the index and yields nothing. New test: "a repository with no commits has no submodules to check". This was a regression this PR introduced into every commit-less repository, so I fixed it despite the round-4 gate.

<!-- gh-feedback:reply-to:87696 --> Fixed in e9f38b7. Reproduced: pipefail carried `ls-tree HEAD`'s exit 128 out of the walk. It is now `|| true`, so an unborn repository falls back to the index and yields nothing. New test: "a repository with no commits has no submodules to check". This was a regression this PR introduced into every commit-less repository, so I fixed it despite the round-4 gate.
jercik marked this conversation as resolved
Lines 527-532
@ -478,0 +524,9 @@
[ "$head" != "$recorded" ] && [ -z "$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head")" ]
return
fi
if [ "$head" != "$recorded" ] && [ -z "$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head" refs/remotes)" ]; then
return 0
fi
[ -n "$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all)" ] && return 0
git -C "$submodule_path" rev-parse --verify --quiet refs/stash >/dev/null && return 0
[ -n "$(git -C "$submodule_path" rev-list -n 1 --branches --not --remotes)" ]

high — Removal gate treats a recorded Gitlink as proof of survival, destroying unpushed submodule commits and leaving an unresolvable Gitlink on the default branch
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new submodule_holds_local_work removal branch in skills/audit-git-checkouts/scripts/audit-checkouts.sh, its caller list_submodules_with_local_work, the judgment/submodule-local-work gate added to maybe_remove_worktree, and the rule the change writes into references/removal-gates.md: "A submodule keeps the worktree ... when it has uncommitted files, a stash, a branch commit no remote-tracking ref holds, or a HEAD that is neither its recorded commit nor held by a remote-tracking ref."

The hole: the gate treats "HEAD equals the commit the superproject records" as proof the work survives. It does not. As the change's own comment says, removing a linked worktree deletes its submodule Git directories -- a linked worktree's submodule gitdir is <common>/worktrees/<name>/modules/<path>, a separate object store from the main worktree's <common>/modules/<path>. A commit made in that submodule lives only there. Recording it in a superproject Gitlink stores the name of the object, never the object. So a detached submodule HEAD at a commit the superproject records but the submodule's remote does not have passes every clause -- clean tree, no stash, rev-list --branches --not --remotes empty because the commit is on no local branch -- and the removal destroys it. Detached is the normal state for submodules: git submodule update leaves them detached, and this driver's own selector update uses git checkout --detach.

Reproduced end to end against the real gate (git 2.47.3). Superproject super with submodule dep; linked worktree on branch feature; inside linked/dep, git checkout --detach then one commit 7f5aa13; git add dep and commit the Gitlink in linked; merge feature into main and point refs/remotes/origin/main at it, so containment proves. Then, invoking the driver exactly as audit-checkouts.test.mjs does:

$ bash -c 'source audit-checkouts.sh; script_directory=...; \
    maybe_remove_worktree /tmp/lab6/linked /tmp/lab6/super "$COMMON" main true result.json work'
$ jq '.removal | {outcome, error}' result.json
{ "outcome": "removed", "error": null }

$ git -C /tmp/lab6/super cat-file -t 7f5aa13...
fatal: git cat-file: could not get object info
$ git -C /tmp/lab6/sub  cat-file -t 7f5aa13...      # the submodule's origin
fatal: git cat-file: could not get object info
$ git -C /tmp/lab6/super rev-parse HEAD:dep
7f5aa13a898351b829b351491fb57bf40300aa23

The gate itself reports nothing: list_submodules_with_local_work /tmp/lab6/linked removal exits 0 with empty output. The superproject is left permanently broken:

$ git -C /tmp/lab6/super submodule update --init
fatal: remote error: upload-pack: not our ref 7f5aa13a898351b829b351491fb57bf40300aa23
fatal: Fetched in submodule path 'dep', but it did not contain 7f5aa13a... Direct fetching of that commit failed.

What goes wrong: committed work is destroyed with no warning and no record, by the single gate whose stated job is "No populated submodule, at any depth, holding work that would be lost with the worktree." The damage is worse than losing a stray commit: main and origin/main now carry a Gitlink no clone on earth can resolve, so the superproject cannot be checked out with submodules by anyone, forever. The driver removes worktrees in its default, no-confirmation mode.

A safe correction: the head == recorded shortcut must additionally prove the commit survives the deletion -- i.e. require that a remote-tracking ref in the submodule contains $head regardless of whether it matches $recorded. That is exactly the for-each-ref --count=1 --contains "$head" refs/remotes test already written on this line; dropping the [ "$head" != "$recorded" ] conjunct would close the hole. (That is stricter; whether the project wants to also accept "present in the main worktree's <common>/modules/<path> object store" is a design call I cannot make from the tree.)

What would refute it: evidence that a linked worktree's submodule objects survive git worktree remove --force. They do not -- I verified <common>/worktrees/<name>/modules/dep is the gitdir (cat dep/.git prints gitdir: ../../super/.git/worktrees/linked/modules/dep) and that <common>/modules does not exist after removal. The change's new test "a merged worktree is kept while its submodules hold work that exists only there" covers only the head != recorded case and so does not exercise this path.

claim 01M3A0C1NTK178K7H5JFY9Z7Z0 of review 01M39ZTYXRMDP1E05ME1J23DF1

<!-- review:claim:01M3A0C1NTK178K7H5JFY9Z7Z0 --> **high** — Removal gate treats a recorded Gitlink as proof of survival, destroying unpushed submodule commits and leaving an unresolvable Gitlink on the default branch lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new `submodule_holds_local_work` removal branch in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, its caller `list_submodules_with_local_work`, the `judgment/submodule-local-work` gate added to `maybe_remove_worktree`, and the rule the change writes into `references/removal-gates.md`: "A submodule keeps the worktree ... when it has uncommitted files, a stash, a branch commit no remote-tracking ref holds, or a HEAD that is neither its recorded commit nor held by a remote-tracking ref." > > The hole: the gate treats "HEAD equals the commit the superproject records" as proof the work survives. It does not. As the change's own comment says, removing a linked worktree deletes its submodule Git directories -- a linked worktree's submodule gitdir is `<common>/worktrees/<name>/modules/<path>`, a separate object store from the main worktree's `<common>/modules/<path>`. A commit made in that submodule lives only there. Recording it in a superproject Gitlink stores the *name* of the object, never the object. So a detached submodule HEAD at a commit the superproject records but the submodule's remote does not have passes every clause -- clean tree, no stash, `rev-list --branches --not --remotes` empty because the commit is on no local branch -- and the removal destroys it. Detached is the normal state for submodules: `git submodule update` leaves them detached, and this driver's own selector update uses `git checkout --detach`. > > Reproduced end to end against the real gate (git 2.47.3). Superproject `super` with submodule `dep`; linked worktree on branch `feature`; inside `linked/dep`, `git checkout --detach` then one commit `7f5aa13`; `git add dep` and commit the Gitlink in `linked`; merge `feature` into `main` and point `refs/remotes/origin/main` at it, so containment proves. Then, invoking the driver exactly as `audit-checkouts.test.mjs` does: > > $ bash -c 'source audit-checkouts.sh; script_directory=...; \ > maybe_remove_worktree /tmp/lab6/linked /tmp/lab6/super "$COMMON" main true result.json work' > $ jq '.removal | {outcome, error}' result.json > { "outcome": "removed", "error": null } > > $ git -C /tmp/lab6/super cat-file -t 7f5aa13... > fatal: git cat-file: could not get object info > $ git -C /tmp/lab6/sub cat-file -t 7f5aa13... # the submodule's origin > fatal: git cat-file: could not get object info > $ git -C /tmp/lab6/super rev-parse HEAD:dep > 7f5aa13a898351b829b351491fb57bf40300aa23 > > The gate itself reports nothing: `list_submodules_with_local_work /tmp/lab6/linked removal` exits 0 with empty output. The superproject is left permanently broken: > > $ git -C /tmp/lab6/super submodule update --init > fatal: remote error: upload-pack: not our ref 7f5aa13a898351b829b351491fb57bf40300aa23 > fatal: Fetched in submodule path 'dep', but it did not contain 7f5aa13a... Direct fetching of that commit failed. > > What goes wrong: committed work is destroyed with no warning and no record, by the single gate whose stated job is "No populated submodule, at any depth, holding work that would be lost with the worktree." The damage is worse than losing a stray commit: `main` and `origin/main` now carry a Gitlink no clone on earth can resolve, so the superproject cannot be checked out with submodules by anyone, forever. The driver removes worktrees in its default, no-confirmation mode. > > A safe correction: the `head == recorded` shortcut must additionally prove the commit survives the deletion -- i.e. require that a remote-tracking ref in the submodule contains `$head` regardless of whether it matches `$recorded`. That is exactly the `for-each-ref --count=1 --contains "$head" refs/remotes` test already written on this line; dropping the `[ "$head" != "$recorded" ]` conjunct would close the hole. (That is stricter; whether the project wants to also accept "present in the main worktree's `<common>/modules/<path>` object store" is a design call I cannot make from the tree.) > > What would refute it: evidence that a linked worktree's submodule objects survive `git worktree remove --force`. They do not -- I verified `<common>/worktrees/<name>/modules/dep` is the gitdir (`cat dep/.git` prints `gitdir: ../../super/.git/worktrees/linked/modules/dep`) and that `<common>/modules` does not exist after removal. The change's new test "a merged worktree is kept while its submodules hold work that exists only there" covers only the `head != recorded` case and so does not exercise this path. claim `01M3A0C1NTK178K7H5JFY9Z7Z0` of review `01M39ZTYXRMDP1E05ME1J23DF1`

medium — Removal gate checks only branches, so a submodule commit held only by a local tag is deleted with the worktree
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the last clause of the new submodule_holds_local_work removal branch in skills/audit-git-checkouts/scripts/audit-checkouts.sh, the rule the change writes into references/removal-gates.md ("uncommitted files, a stash, a branch commit no remote-tracking ref holds, or a HEAD that is neither its recorded commit nor held by a remote-tracking ref"), the "Treat user work as untouchable" section of SKILL.md ("Uncommitted files, commits no other ref holds, stashes, and ignored files ... are user work"), and the sibling update_submodule_tag added by the same change.

What goes wrong: git rev-list -n 1 --branches --not --remotes enumerates commits reachable from refs/heads/* only. A commit held solely by a local tag in a submodule is reachable from neither --branches nor --remotes, so it contributes nothing to the gate, yet git worktree remove --force deletes the linked worktree's submodule Git directory (<common>/worktrees/<name>/modules/<path>) along with that tag and its objects. The gate's other clauses do not cover it either: the submodule can sit detached at exactly the recorded Gitlink with a clean tree and no stash.

Reproduced end to end against the real gate (git 2.47.3). Superproject super, submodule dep, linked worktree on feature merged into main/origin/main. Inside linked/dep I left HEAD at the recorded commit and committed a side note, tagged it wip, then detached back:

dep HEAD=c1e9463... recorded=c1e9463...
status: []

$ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work /tmp/lab7/linked removal'
rc=0 out=[]                       # the gate sees nothing

$ maybe_remove_worktree /tmp/lab7/linked /tmp/lab7/super "$COMMON" main true result.json work
$ jq -r '.removal.outcome' result.json
removed

$ git -C /tmp/lab7/super cat-file -t 984b741...   # the tagged commit
fatal: git cat-file: could not get object info
$ git -C /tmp/lab7/sub   cat-file -t 984b741...   # the submodule's origin
fatal: git cat-file: could not get object info

The tag and its commit exist nowhere afterwards, and the driver removed the worktree in its default, no-confirmation mode without mentioning it.

Why this is a defect rather than a scope decision: the same change treats exactly this object as precious on the update side. update_submodule_tag refuses to move a submodule and emits "local tag $tag in $submodule_path holds commit $local_commit that no other ref holds; not replacing it" rather than let a local tag's commit become unreachable. The removal side deletes the identical thing without a word. SKILL.md states the governing rule as "commits no other ref holds ... are user work", which a local tag's commit is. references/removal-gates.md explicitly carves out only ignored files inside submodules ("Ignored files inside submodules are not scanned"); it makes no such carve-out for tags, so the documented gate reads as stronger than the implemented one.

A safe correction: widen the reachability set for the last clause and for the HEAD clause -- e.g. git rev-list -n 1 --all --not --remotes (or --branches --tags --not --remotes), which covers local tags while still exempting anything a remote-tracking ref holds.

What would refute it: evidence that a linked worktree's submodule refs survive removal. They do not; the same run shows <common>/modules does not exist afterwards and the tagged object is unreadable from both the superproject and the submodule's origin. No test in this change covers a local tag in a submodule at removal time; the new test "a merged worktree is kept while its submodules hold work that exists only there" uses a detached commit that differs from the recorded Gitlink.

claim 01M3A0EVG61DHR3PHC417ZZ96F of review 01M39ZTYXRMDP1E05ME1J23DF1

<!-- review:claim:01M3A0EVG61DHR3PHC417ZZ96F --> **medium** — Removal gate checks only branches, so a submodule commit held only by a local tag is deleted with the worktree lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the last clause of the new `submodule_holds_local_work` removal branch in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, the rule the change writes into `references/removal-gates.md` ("uncommitted files, a stash, a branch commit no remote-tracking ref holds, or a HEAD that is neither its recorded commit nor held by a remote-tracking ref"), the "Treat user work as untouchable" section of `SKILL.md` ("Uncommitted files, commits no other ref holds, stashes, and ignored files ... are user work"), and the sibling `update_submodule_tag` added by the same change. > > What goes wrong: `git rev-list -n 1 --branches --not --remotes` enumerates commits reachable from `refs/heads/*` only. A commit held solely by a local tag in a submodule is reachable from neither `--branches` nor `--remotes`, so it contributes nothing to the gate, yet `git worktree remove --force` deletes the linked worktree's submodule Git directory (`<common>/worktrees/<name>/modules/<path>`) along with that tag and its objects. The gate's other clauses do not cover it either: the submodule can sit detached at exactly the recorded Gitlink with a clean tree and no stash. > > Reproduced end to end against the real gate (git 2.47.3). Superproject `super`, submodule `dep`, linked worktree on `feature` merged into `main`/`origin/main`. Inside `linked/dep` I left HEAD at the recorded commit and committed a side note, tagged it `wip`, then detached back: > > dep HEAD=c1e9463... recorded=c1e9463... > status: [] > > $ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work /tmp/lab7/linked removal' > rc=0 out=[] # the gate sees nothing > > $ maybe_remove_worktree /tmp/lab7/linked /tmp/lab7/super "$COMMON" main true result.json work > $ jq -r '.removal.outcome' result.json > removed > > $ git -C /tmp/lab7/super cat-file -t 984b741... # the tagged commit > fatal: git cat-file: could not get object info > $ git -C /tmp/lab7/sub cat-file -t 984b741... # the submodule's origin > fatal: git cat-file: could not get object info > > The tag and its commit exist nowhere afterwards, and the driver removed the worktree in its default, no-confirmation mode without mentioning it. > > Why this is a defect rather than a scope decision: the same change treats exactly this object as precious on the update side. `update_submodule_tag` refuses to move a submodule and emits "local tag $tag in $submodule_path holds commit $local_commit that no other ref holds; not replacing it" rather than let a local tag's commit become unreachable. The removal side deletes the identical thing without a word. `SKILL.md` states the governing rule as "commits no other ref holds ... are user work", which a local tag's commit is. `references/removal-gates.md` explicitly carves out only ignored files inside submodules ("Ignored files inside submodules are not scanned"); it makes no such carve-out for tags, so the documented gate reads as stronger than the implemented one. > > A safe correction: widen the reachability set for the last clause and for the HEAD clause -- e.g. `git rev-list -n 1 --all --not --remotes` (or `--branches --tags --not --remotes`), which covers local tags while still exempting anything a remote-tracking ref holds. > > What would refute it: evidence that a linked worktree's submodule refs survive removal. They do not; the same run shows `<common>/modules` does not exist afterwards and the tagged object is unreadable from both the superproject and the submodule's origin. No test in this change covers a local tag in a submodule at removal time; the new test "a merged worktree is kept while its submodules hold work that exists only there" uses a detached commit that differs from the recorded Gitlink. claim `01M3A0EVG61DHR3PHC417ZZ96F` of review `01M39ZTYXRMDP1E05ME1J23DF1`
Author
Owner

Fixed in e9f38b7. Reproduced with a local submodule commit recorded in a merged branch: the worktree was removed. The removal gate now requires a remote-tracking ref for the submodule HEAD even when it equals the recorded Gitlink, because a Gitlink names the commit without keeping it. The removal test now covers this and fails with the old exemption restored.

<!-- gh-feedback:reply-to:87694 --> Fixed in e9f38b7. Reproduced with a local submodule commit recorded in a merged branch: the worktree was removed. The removal gate now requires a remote-tracking ref for the submodule HEAD even when it equals the recorded Gitlink, because a Gitlink names the commit without keeping it. The removal test now covers this and fails with the old exemption restored.
Author
Owner

Fixed in e9f38b7. Reproduced: the tag-only commit went with the worktree. The last clause is now rev-list --branches --tags --not --remotes, and the removal test covers a tag-only commit.

<!-- gh-feedback:reply-to:87697 --> Fixed in e9f38b7. Reproduced: the tag-only commit went with the worktree. The last clause is now `rev-list --branches --tags --not --remotes`, and the removal test covers a tag-only commit.
jercik marked this conversation as resolved
@ -312,1 +320,4 @@
const onDefault = worktree.status?.branch.isDetached === false && worktree.status.branch.current === defaultBranch;
const stranded = worktree.strandedSubmodules ?? [];
if ((isMain || onDefault) && stranded.length > 0) {
const why = `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated`;

low — Decision-row note says "submodule on a commit no ref holds" but prints a list of paths
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new decision-row branch in formatAuditReport in scripts/render-audit-report.ts, the listPaths helper in the same file, the sibling messages it sits beside, and list_submodules_with_local_work in scripts/audit-checkouts.sh, which produces the strandedSubmodules array.

What the subject does: the row's note is built as submodule on a commit no ref holds: ${listPaths(stranded)}; not updated, where listPaths joins up to five paths with ", " and appends "(+N more)" beyond that. The noun is singular while the value it introduces is a list.

Why the plural case is real, not hypothetical: list_submodules_with_local_work prints one line per stranded submodule and then recurses into that submodule, so a checkout with two first-party submodules both detached at commits no ref holds, or a stranded submodule that itself contains a stranded one, yields several paths. The change's own test fixture builds exactly that nesting ("dependency/inner"); nothing caps the array at one. The parallel removal message in the same file, which reads from the same walk, already assumes plural: KEPT_REASON_BY_OUTCOME["judgment/submodule-local-work"] is "submodules hold work that exists only here", rendered as "merged; submodules hold work that exists only here: dep, dep/inner".

What goes wrong: with more than one path the user reads "submodule on a commit no ref holds: dep-a, dep-b; not updated" — a singular subject with a two-item list, which reads as a single submodule followed by an unexplained second path. SKILL.md tells the agent to "Use the report's plain wording" when reporting to the user, so this sentence is repeated verbatim to a person who cannot see the JSON. The count also matters for their decision: they are being asked to resolve every listed submodule, and the singular undercounts the work at a glance. It also breaks the parallelism with the removal note for the same condition, two rows away in the same report.

Correction: use a number-neutral phrasing that reads correctly at any count, for example submodule commits no ref holds: ${listPaths(stranded)}; not updated, or pluralize conditionally — ${stranded.length === 1 ? "submodule" : "submodules"} on a commit no ref holds: .... Either keeps the note's useful meaning (which submodules blocked the update, and that the checkout was left alone) and matches the plural convention already used for the removal outcome. The existing test asserts the one-path string, so a neutral rewrite needs that assertion updated and is worth a second assertion at two paths.

What would refute this: a guarantee that strandedSubmodules never holds more than one entry. I traced the shell function and the jq that builds the array (split("\n") | map(select(. != ""))) and found no such cap.

claim 01M3A040D2C8J5T211YSFAAMWY of review 01M39ZTYXRMDP1E05ME1J23DF1

<!-- review:claim:01M3A040D2C8J5T211YSFAAMWY --> **low** — Decision-row note says "submodule on a commit no ref holds" but prints a list of paths lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new decision-row branch in formatAuditReport in scripts/render-audit-report.ts, the listPaths helper in the same file, the sibling messages it sits beside, and list_submodules_with_local_work in scripts/audit-checkouts.sh, which produces the strandedSubmodules array. > > What the subject does: the row's note is built as `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated`, where listPaths joins up to five paths with ", " and appends "(+N more)" beyond that. The noun is singular while the value it introduces is a list. > > Why the plural case is real, not hypothetical: list_submodules_with_local_work prints one line per stranded submodule and then recurses into that submodule, so a checkout with two first-party submodules both detached at commits no ref holds, or a stranded submodule that itself contains a stranded one, yields several paths. The change's own test fixture builds exactly that nesting ("dependency/inner"); nothing caps the array at one. The parallel removal message in the same file, which reads from the same walk, already assumes plural: KEPT_REASON_BY_OUTCOME["judgment/submodule-local-work"] is "submodules hold work that exists only here", rendered as "merged; submodules hold work that exists only here: dep, dep/inner". > > What goes wrong: with more than one path the user reads "submodule on a commit no ref holds: dep-a, dep-b; not updated" — a singular subject with a two-item list, which reads as a single submodule followed by an unexplained second path. SKILL.md tells the agent to "Use the report's plain wording" when reporting to the user, so this sentence is repeated verbatim to a person who cannot see the JSON. The count also matters for their decision: they are being asked to resolve every listed submodule, and the singular undercounts the work at a glance. It also breaks the parallelism with the removal note for the same condition, two rows away in the same report. > > Correction: use a number-neutral phrasing that reads correctly at any count, for example `submodule commits no ref holds: ${listPaths(stranded)}; not updated`, or pluralize conditionally — `${stranded.length === 1 ? "submodule" : "submodules"} on a commit no ref holds: ...`. Either keeps the note's useful meaning (which submodules blocked the update, and that the checkout was left alone) and matches the plural convention already used for the removal outcome. The existing test asserts the one-path string, so a neutral rewrite needs that assertion updated and is worth a second assertion at two paths. > > What would refute this: a guarantee that strandedSubmodules never holds more than one entry. I traced the shell function and the jq that builds the array (`split("\n") | map(select(. != ""))`) and found no such cap. claim `01M3A040D2C8J5T211YSFAAMWY` of review `01M39ZTYXRMDP1E05ME1J23DF1`
jercik marked this conversation as resolved
fix(audit-git-checkouts): removal should require a remote-tracking ref for submodule commits
All checks were successful
commit-msg / commitlint (pull_request) Successful in 24s
Node tests / node:test (pull_request) Successful in 1m6s
Review / Review (pull_request_target) Successful in 22m32s
e9f38b7789
A linked worktree's submodule HEAD at its recorded Gitlink passed the
removal gate even when the commit existed only in that worktree's submodule
Git directory, and a commit held only by a local tag was never checked.
Both were deleted with the worktree. The gate now requires a
remote-tracking ref for HEAD, and for every branch and tag commit.

The submodule walk no longer fails in a repository with no commits, which
had turned every such checkout into a "submodule check failed" row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Replying to review summary comment #87417

Round-4 outcomes for review 01M39ZTYXRMDP1E05ME1J23DF1 (head 29216b3). This PR is now in round 4, so only clear, reproduced data-loss bugs and one regression were fixed, in e9f38b7. The other items below are real but change only wording, so they are acknowledged and deferred to a follow-up PR with the exact fix.

Fixed in e9f38b7:

  • high, a recorded Gitlink passes removal: agreed. Replies in the inline thread.
  • medium, a tag-only submodule commit is deleted: agreed. Replies in the inline thread.
  • medium, an unborn HEAD fails the submodule walk: agreed. Replies in the inline thread.

Deferred, acknowledged (real, documentation or wording only):

  • medium, --help still says "schema 5" (no inline thread). In scripts/audit-checkouts.sh usage(), change "Writes one JSON report (schema 5)" to "(schema 6)".
  • low, SKILL.md's routing list names four removal gates (no inline thread). In SKILL.md, the "A merged worktree kept by a gate (…)" bullet should add "submodule work" to the list.
  • medium, "when nothing fast-forwards" (87695). In SKILL.md's stricter-mode bullet, say "In first-party checkouts it also moves branch- or tag-tracked submodules, with or without a fast-forward".
  • low, the mode table cell restates the bullet (87698). In SKILL.md, shorten the cell to "check submodules out at their recorded commits or configured selectors (see below)".
  • low, nested submodules and the selector move (87699). In references/checkout-updates.md, say "The selector move does not recurse; nested submodules move only with the post-fast-forward checkout, to their recorded commits."
  • low, --help uses "keeps" (87700). In scripts/audit-checkouts.sh usage(), use "holds" and split the tag clause into its own sentence.
  • low, singular note over a list of paths (87701). In scripts/render-audit-report.ts, render "submodules on a commit no ref holds: ".
> Replying to review summary comment #87417 Round-4 outcomes for review `01M39ZTYXRMDP1E05ME1J23DF1` (head 29216b3). This PR is now in round 4, so only clear, reproduced data-loss bugs and one regression were fixed, in e9f38b7. The other items below are real but change only wording, so they are acknowledged and deferred to a follow-up PR with the exact fix. Fixed in e9f38b7: - **high, a recorded Gitlink passes removal:** agreed. Replies in the inline thread. - **medium, a tag-only submodule commit is deleted:** agreed. Replies in the inline thread. - **medium, an unborn HEAD fails the submodule walk:** agreed. Replies in the inline thread. Deferred, acknowledged (real, documentation or wording only): - **medium, `--help` still says "schema 5"** (no inline thread). In `scripts/audit-checkouts.sh` `usage()`, change "Writes one JSON report (schema 5)" to "(schema 6)". - **low, SKILL.md's routing list names four removal gates** (no inline thread). In `SKILL.md`, the "A merged worktree kept by a gate (…)" bullet should add "submodule work" to the list. - **medium, "when nothing fast-forwards" (87695).** In `SKILL.md`'s stricter-mode bullet, say "In first-party checkouts it also moves branch- or tag-tracked submodules, with or without a fast-forward". - **low, the mode table cell restates the bullet (87698).** In `SKILL.md`, shorten the cell to "check submodules out at their recorded commits or configured selectors (see below)". - **low, nested submodules and the selector move (87699).** In `references/checkout-updates.md`, say "The selector move does not recurse; nested submodules move only with the post-fast-forward checkout, to their recorded commits." - **low, `--help` uses "keeps" (87700).** In `scripts/audit-checkouts.sh` `usage()`, use "holds" and split the tag clause into its own sentence. - **low, singular note over a list of paths (87701).** In `scripts/render-audit-report.ts`, render "submodules on a commit no ref holds: <paths>".
@ -16,3 +16,3 @@
| Request | Flags | What changes |
| --- | --- | --- |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |

low — Mode-selection table cell tripled in length and buries a "check ... out" split across ten words
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The "Choose the mode" table in SKILL.md, the four-bullet list immediately beneath it, and the "Submodule selectors" section of references/checkout-updates.md, which the table's own third row links to.

What the subject says. The default-mode cell grew from 20 words to roughly 60, now reading: "Fetch and prune origin; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check each submodule listed in the checkout's own .gitmodules out at its configured branch or tag, leaving the Gitlink change unstaged; prune stale worktree registrations; remove proven-merged linked worktrees inside the root." Its sibling cells remain 4 words ("Everything above except removal.") and about 30.

What goes wrong. Two separable costs.

A garden path. "check each submodule listed in the checkout's own .gitmodules out at its configured branch or tag" separates the particle "out" from its verb "check" by ten words. Through "check each submodule listed in the checkout's own .gitmodules" the sentence parses as complete with "check" meaning verify -- which is exactly what a skill named audit-git-checkouts primes -- and then "out at its configured branch or tag" arrives with nothing to attach to, forcing a reparse. The clause describes a write, so the misparse points the wrong way: a reader deciding whether the default mode is safe momentarily reads a mutation as an inspection.

Wrong home for the detail. The table's column headers are "Request | Flags | What changes"; its job is picking a mode at a glance. The submodule mechanics it now carries are stated twice more within a screen: the new fourth bullet directly below ("After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules...") and references/checkout-updates.md in full, including the unstaged-Gitlink detail. The writing standard asks for one home per instruction -- "Do not restate what ... an earlier sentence, or a derivable summary already says" -- and reserves tables for flat choices. Burying five semicolon-joined clauses in a scanning cell makes the mode decision harder to read while adding nothing the bullet and the reference do not already carry.

Proposed correction. Restore the cell to the decision-relevant summary and let the bullet own the mechanics: "Fetch and prune origin; fast-forward eligible default checkouts and move their submodules; prune stale worktree registrations; remove proven-merged linked worktrees inside the root." That keeps the one fact the mode decision turns on -- the default mode writes to submodules -- and keeps the row scannable beside its siblings. The recorded-commit checkout, the first-party .gitmodules scope, and the unstaged Gitlink are already stated in the fourth bullet and in references/checkout-updates.md, so nothing is lost by removing them here. If the phrasing is kept anywhere, write "check out each submodule ... at its configured branch or tag" so the particle stays with its verb.

What would establish or refute this. The duplication is checkable by reading the three passages side by side, which I did; the parse hazard is a judgment about reading order rather than a measured result, and I did not test it with a reader. The claim would be weakened if the bullet below were removed and the table became the only statement of this behavior -- as the subject stands, all three are present.

claim 01M3A1NZF0PFD14A40XTQAMG5F of review 01M3A1CRCCTK8AGTYWGXAJDQPF

<!-- review:claim:01M3A1NZF0PFD14A40XTQAMG5F --> **low** — Mode-selection table cell tripled in length and buries a "check ... out" split across ten words lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The "Choose the mode" table in `SKILL.md`, the four-bullet list immediately beneath it, and the "Submodule selectors" section of `references/checkout-updates.md`, which the table's own third row links to. > > **What the subject says.** The default-mode cell grew from 20 words to roughly 60, now reading: "Fetch and prune `origin`; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged; prune stale worktree registrations; remove proven-merged linked worktrees inside the root." Its sibling cells remain 4 words ("Everything above except removal.") and about 30. > > **What goes wrong.** Two separable costs. > > *A garden path.* "check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag" separates the particle "out" from its verb "check" by ten words. Through "check each submodule listed in the checkout's own `.gitmodules`" the sentence parses as complete with "check" meaning *verify* -- which is exactly what a skill named `audit-git-checkouts` primes -- and then "out at its configured branch or tag" arrives with nothing to attach to, forcing a reparse. The clause describes a *write*, so the misparse points the wrong way: a reader deciding whether the default mode is safe momentarily reads a mutation as an inspection. > > *Wrong home for the detail.* The table's column headers are "Request | Flags | What changes"; its job is picking a mode at a glance. The submodule mechanics it now carries are stated twice more within a screen: the new fourth bullet directly below ("After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules...") and `references/checkout-updates.md` in full, including the unstaged-Gitlink detail. The writing standard asks for one home per instruction -- "Do not restate what ... an earlier sentence, or a derivable summary already says" -- and reserves tables for flat choices. Burying five semicolon-joined clauses in a scanning cell makes the mode decision harder to read while adding nothing the bullet and the reference do not already carry. > > **Proposed correction.** Restore the cell to the decision-relevant summary and let the bullet own the mechanics: "Fetch and prune `origin`; fast-forward eligible default checkouts and move their submodules; prune stale worktree registrations; remove proven-merged linked worktrees inside the root." That keeps the one fact the mode decision turns on -- the default mode writes to submodules -- and keeps the row scannable beside its siblings. The recorded-commit checkout, the first-party `.gitmodules` scope, and the unstaged Gitlink are already stated in the fourth bullet and in `references/checkout-updates.md`, so nothing is lost by removing them here. If the phrasing is kept anywhere, write "check out each submodule ... at its configured branch or tag" so the particle stays with its verb. > > **What would establish or refute this.** The duplication is checkable by reading the three passages side by side, which I did; the parse hazard is a judgment about reading order rather than a measured result, and I did not test it with a reader. The claim would be weakened if the bullet below were removed and the table became the only statement of this behavior -- as the subject stands, all three are present. claim `01M3A1NZF0PFD14A40XTQAMG5F` of review `01M3A1CRCCTK8AGTYWGXAJDQPF`
jercik marked this conversation as resolved
@ -23,3 +23,4 @@
- A repository whose work integrates somewhere other than `origin/<default>`, such as a fork with an `upstream`: `--no-remove`. Removal proves containment against `origin/<default>` only.
- A repository with a custom `origin` fetch refspec: `--no-fetch --no-remove`. The fetch prunes every `refs/remotes/origin/*` ref that no server branch supplies (see [references/checkout-updates.md](references/checkout-updates.md)).
- A checkout whose submodules must stay where they are: `--no-fetch --no-remove`. After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and `update = none` stops only that move.

medium — SKILL.md mode bullet says selector moves happen "when nothing fast-forwards", but they also run after a successful fast-forward
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The "Choose the mode" section of SKILL.md, the eligibility conditions for tracking_update_eligible in scripts/audit-checkouts.sh, and the parallel wording in references/checkout-updates.md.

What the subject says. The new fourth bullet reads: "A checkout whose submodules must stay where they are: --no-fetch --no-remove. After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and update = none stops only that move."

What the code does. In audit_worktree, the selector move is gated by tracking_update_eligible, whose conditions include { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; }. That predicate admits two cases: no fast-forward was attempted, and a fast-forward was attempted and succeeded. The selector move therefore runs in both situations. It is excluded only when a fast-forward was attempted and failed (and separately by is_third_party_checkout, a stale comparison, a non-default or detached branch, and a non-empty stranded_submodules).

What goes wrong. "when nothing fast-forwards" states a temporal condition, and the writing standard's own audience model is "a literal reader". Read literally, the clause restricts the selector move to checkouts that did not fast-forward, which is the opposite of what the sentence is trying to warn about. The intended meaning is concessive: the move happens even when no fast-forward runs, so a reader cannot rely on "nothing to fast-forward" as a reason submodules will be left alone. The bullet exists precisely to justify reaching for --no-fetch --no-remove; under the literal reading, a user with an already-up-to-date first-party checkout concludes the bullet does not apply to them, skips the stricter flags, and gets their branch- or tag-tracked submodules moved off the commits they wanted preserved. That is the exact outcome the bullet was added to prevent.

Proposed correction. Replace "when nothing fast-forwards" with "even when no fast-forward runs". One word ("even") restores the concessive sense; "no fast-forward runs" also avoids reading "nothing" as the subject. The rest of the bullet is accurate and should stand: the post-fast-forward checkout at recorded commits does cover third-party submodules (synchronize_submodules runs git submodule update --init --recursive --checkout with no third-party guard), and update = none is consulted only inside synchronize_first_party_tracking_submodules, so it does stop only the selector move -- a point references/checkout-updates.md makes in the same terms.

What would establish or refute this. Auditing a first-party default checkout that is already level with origin/<default> but whose .gitmodules carries a branch or tag selector pointing past the recorded Gitlink, and observing whether the submodule moves. The claim otherwise rests on reading the tracking_update_eligible condition block; I did not run the driver. It would be refuted if some earlier guard made a selector move unreachable whenever a fast-forward succeeded, and I found none -- fast_forward_ok = true is an explicitly admitted branch of that disjunction.

claim 01M3A1KZV50Q8KGG3GWKSFDW1G of review 01M3A1CRCCTK8AGTYWGXAJDQPF

<!-- review:claim:01M3A1KZV50Q8KGG3GWKSFDW1G --> **medium** — SKILL.md mode bullet says selector moves happen "when nothing fast-forwards", but they also run after a successful fast-forward lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The "Choose the mode" section of `SKILL.md`, the eligibility conditions for `tracking_update_eligible` in `scripts/audit-checkouts.sh`, and the parallel wording in `references/checkout-updates.md`. > > **What the subject says.** The new fourth bullet reads: "A checkout whose submodules must stay where they are: `--no-fetch --no-remove`. After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules **when nothing fast-forwards**, and `update = none` stops only that move." > > **What the code does.** In `audit_worktree`, the selector move is gated by `tracking_update_eligible`, whose conditions include `{ [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; }`. That predicate admits two cases: no fast-forward was attempted, **and** a fast-forward was attempted and succeeded. The selector move therefore runs in both situations. It is excluded only when a fast-forward was attempted and failed (and separately by `is_third_party_checkout`, a stale comparison, a non-default or detached branch, and a non-empty `stranded_submodules`). > > **What goes wrong.** "when nothing fast-forwards" states a temporal condition, and the writing standard's own audience model is "a literal reader". Read literally, the clause restricts the selector move to checkouts that did not fast-forward, which is the opposite of what the sentence is trying to warn about. The intended meaning is concessive: the move happens *even when* no fast-forward runs, so a reader cannot rely on "nothing to fast-forward" as a reason submodules will be left alone. The bullet exists precisely to justify reaching for `--no-fetch --no-remove`; under the literal reading, a user with an already-up-to-date first-party checkout concludes the bullet does not apply to them, skips the stricter flags, and gets their branch- or tag-tracked submodules moved off the commits they wanted preserved. That is the exact outcome the bullet was added to prevent. > > **Proposed correction.** Replace "when nothing fast-forwards" with "even when no fast-forward runs". One word ("even") restores the concessive sense; "no fast-forward runs" also avoids reading "nothing" as the subject. The rest of the bullet is accurate and should stand: the post-fast-forward checkout at recorded commits does cover third-party submodules (`synchronize_submodules` runs `git submodule update --init --recursive --checkout` with no third-party guard), and `update = none` is consulted only inside `synchronize_first_party_tracking_submodules`, so it does stop only the selector move -- a point `references/checkout-updates.md` makes in the same terms. > > **What would establish or refute this.** Auditing a first-party default checkout that is already level with `origin/<default>` but whose `.gitmodules` carries a `branch` or `tag` selector pointing past the recorded Gitlink, and observing whether the submodule moves. The claim otherwise rests on reading the `tracking_update_eligible` condition block; I did not run the driver. It would be refuted if some earlier guard made a selector move unreachable whenever a fast-forward succeeded, and I found none -- `fast_forward_ok = true` is an explicitly admitted branch of that disjunction. claim `01M3A1KZV50Q8KGG3GWKSFDW1G` of review `01M3A1CRCCTK8AGTYWGXAJDQPF`
jercik marked this conversation as resolved
@ -5,3 +5,3 @@
## What the driver already updates
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth) or first-party `.gitmodules`/Gitlink maintenance, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it synchronizes committed submodules to the recorded Gitlinks.
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth), a first-party `.gitmodules` edit, or a first-party Gitlink change on a submodule with a `branch` or `tag` selector, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it initializes committed submodules and checks them out at the recorded Gitlinks. When any populated submodule, at any depth, found from the Gitlinks rather than `.gitmodules`, is checked out at a commit that its superproject does not record and no ref holds, the driver skips the fast-forward and every selector move for that checkout, and the report lists it under "Needs your decision" with the submodule's path.

medium — The stranded-submodule rule says "no ref holds" without naming the repository, and the refs searched are the submodule's, not the superproject's
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. references/checkout-updates.md ("What the driver already updates"), the list_submodules_with_local_work / submodule_holds_local_work pair in scripts/audit-checkouts.sh including their block comment, the usage() text in the same script, and the "Needs your decision: not current" row built in scripts/render-audit-report.ts.

What the subject says. The new sentence describes the gate as a submodule "checked out at a commit that its superproject does not record and no ref holds". The --help text states the same rule as "a submodule, at any depth, on a commit neither recorded nor held by a ref". Neither says whose refs.

What the code does. submodule_holds_local_work in update mode evaluates [ "$head" != "$recorded" ] && [ -z "$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head")" ]. The -C "$submodule_path" is decisive: the refs searched are the submodule's own refs, not the superproject's. The script's own block comment says this plainly -- "HEAD is neither the commit its superproject records nor held by any ref in the submodule" -- so the internal comment carries a disambiguator that both reader-facing texts drop.

What goes wrong. The sentence names the superproject four words earlier ("its superproject does not record"), so the nearest antecedent for the unqualified "no ref" is the superproject, which is the wrong repository. The failure is in the follow-up task, not the audit: SKILL.md directs the agent to work the "Needs your decision" items, and the report row reads "submodule on a commit no ref holds: mid/inner; not updated". To unblock it, the agent must establish a ref inside the submodule that reaches that commit (branch it, tag it, or push it). An agent that reads this reference and looks in the superproject finds no such ref, cannot reconcile the report with what it sees, and may propose the wrong remedy -- tagging or branching in the superproject, which changes nothing about the gate and leaves the submodule commit just as strand-able. The writing standard requires claims to be verifiable and terms used for one concept; "ref" here silently denotes refs of a repository the sentence never names.

Proposed correction. In references/checkout-updates.md: "...is checked out at a commit that its superproject does not record and no ref in the submodule holds...". In the usage() line: "on a commit its superproject does not record and no ref in it holds". Three words in each place. This preserves the sentence's real content -- both halves of the conjunction, the at-any-depth scope, the Gitlinks-not-.gitmodules discovery note -- and adds the one fact needed to act on the report row. It also brings the prose into line with the wording the script's own comment already uses, so the three texts describe the predicate identically.

What would establish or refute this. Reading the -C target on the for-each-ref call in submodule_holds_local_work (it is $submodule_path), and the test a visible submodule commit no ref holds blocks the fast-forward and the selector move, whose commitDetached helper detaches and commits inside the submodule checkout so that no submodule ref reaches the commit while the superproject is untouched. I did not run the tests. The claim would be refuted if the sentence elsewhere established the submodule as the ref namespace under discussion; the surrounding paragraph does not -- the only other repository it names in that clause is the superproject.

claim 01M3A1MRYKK826KYZJJDN3HZ2G of review 01M3A1CRCCTK8AGTYWGXAJDQPF

<!-- review:claim:01M3A1MRYKK826KYZJJDN3HZ2G --> **medium** — The stranded-submodule rule says "no ref holds" without naming the repository, and the refs searched are the submodule's, not the superproject's lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** `references/checkout-updates.md` ("What the driver already updates"), the `list_submodules_with_local_work` / `submodule_holds_local_work` pair in `scripts/audit-checkouts.sh` including their block comment, the `usage()` text in the same script, and the "Needs your decision: not current" row built in `scripts/render-audit-report.ts`. > > **What the subject says.** The new sentence describes the gate as a submodule "checked out at a commit that its superproject does not record **and no ref holds**". The `--help` text states the same rule as "a submodule, at any depth, on a commit neither recorded nor **held by a ref**". Neither says whose refs. > > **What the code does.** `submodule_holds_local_work` in `update` mode evaluates `[ "$head" != "$recorded" ] && [ -z "$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head")" ]`. The `-C "$submodule_path"` is decisive: the refs searched are the **submodule's own** refs, not the superproject's. The script's own block comment says this plainly -- "HEAD is neither the commit its superproject records nor held by any ref in the submodule" -- so the internal comment carries a disambiguator that both reader-facing texts drop. > > **What goes wrong.** The sentence names the superproject four words earlier ("its superproject does not record"), so the nearest antecedent for the unqualified "no ref" is the superproject, which is the wrong repository. The failure is in the follow-up task, not the audit: `SKILL.md` directs the agent to work the "Needs your decision" items, and the report row reads "submodule on a commit no ref holds: mid/inner; not updated". To unblock it, the agent must establish a ref **inside the submodule** that reaches that commit (branch it, tag it, or push it). An agent that reads this reference and looks in the superproject finds no such ref, cannot reconcile the report with what it sees, and may propose the wrong remedy -- tagging or branching in the superproject, which changes nothing about the gate and leaves the submodule commit just as strand-able. The writing standard requires claims to be verifiable and terms used for one concept; "ref" here silently denotes refs of a repository the sentence never names. > > **Proposed correction.** In `references/checkout-updates.md`: "...is checked out at a commit that its superproject does not record and no ref in the submodule holds...". In the `usage()` line: "on a commit its superproject does not record and no ref in it holds". Three words in each place. This preserves the sentence's real content -- both halves of the conjunction, the at-any-depth scope, the Gitlinks-not-`.gitmodules` discovery note -- and adds the one fact needed to act on the report row. It also brings the prose into line with the wording the script's own comment already uses, so the three texts describe the predicate identically. > > **What would establish or refute this.** Reading the `-C` target on the `for-each-ref` call in `submodule_holds_local_work` (it is `$submodule_path`), and the test `a visible submodule commit no ref holds blocks the fast-forward and the selector move`, whose `commitDetached` helper detaches and commits inside the submodule checkout so that no submodule ref reaches the commit while the superproject is untouched. I did not run the tests. The claim would be refuted if the sentence elsewhere established the submodule as the ref namespace under discussion; the surrounding paragraph does not -- the only other repository it names in that clause is the superproject. claim `01M3A1MRYKK826KYZJJDN3HZ2G` of review `01M3A1CRCCTK8AGTYWGXAJDQPF`
jercik marked this conversation as resolved
Lines 29-30
@ -27,0 +26,5 @@
echo " submodule selectors advanced outside third-party/ paths; removal of in-root linked"
echo " worktrees that pass every removal gate. A checkout with a submodule, at any depth, on a"
echo " commit neither recorded nor held by a ref gets no fast-forward or selector move; a local"
echo " selector tag is replaced only when the fetched tag or another ref keeps its commit; a"
echo " worktree whose submodules hold work no remote-tracking ref keeps is not removed."

low — --help alternates "held by", "keeps", and "holds" for one reachability relation, and its last clause stacks three bare verbs
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The rewritten "Writes in a default run, per repository:" block in usage() in scripts/audit-checkouts.sh, the corresponding prose in references/removal-gates.md and references/checkout-updates.md, and the predicates those sentences describe (submodule_holds_local_work, update_submodule_tag).

What the subject says. Three consecutive new clauses use three different verbs for what the code treats as one relation, reachability of a commit from a ref:

  • "on a commit neither recorded nor held by a ref gets no fast-forward or selector move" (for-each-ref --contains)
  • "a local selector tag is replaced only when the fetched tag or another ref keeps its commit" (merge-base --is-ancestor, plus for-each-ref --contains)
  • "a worktree whose submodules hold work no remote-tracking ref keeps is not removed" (for-each-ref --contains refs/remotes, rev-list --branches --tags --not --remotes)

The reference documents settle on one of these: references/removal-gates.md says "a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds", and references/checkout-updates.md says "no ref holds". "Keeps" appears only in --help.

What goes wrong. Two costs, both in text SKILL.md requires the agent to read first ("Run scripts/audit-checkouts.sh --help ... before first use").

Two terms, one concept. The writing standard's "Use precise language" section requires one term per concept. Reading "held by a ref" and "another ref keeps its commit" in adjacent clauses invites the inference that they name different relations -- plausibly "points at" versus "reaches" -- and that distinction would matter, because it decides whether tagging a commit somewhere unrelated in the submodule is enough to unblock the gate. It is one relation in both cases. The mismatch against the references compounds it: an agent cross-reading --help and removal-gates.md sees "keeps" and "holds" and has no way to tell whether the difference is meaningful.

A garden path in the last clause. "a worktree whose submodules hold work no remote-tracking ref keeps is not removed" stacks a reduced relative ("work [that] no remote-tracking ref keeps") inside another relative ("worktree whose submodules hold..."), leaving three bare verbs in a row -- "hold work no remote-tracking ref keeps is not removed". The reader reaches "keeps is" before the structure resolves.

Proposed correction. Use "holds" throughout, matching both references, and break the last clause: "...; a local selector tag is replaced only when the fetched tag or another ref holds its commit; a worktree is not removed while any of its submodules holds work that no remote-tracking ref holds." This preserves each clause's content exactly -- the tag-replacement condition, the removal gate, and the remote-tracking-ref qualifier that distinguishes the removal rule from the update rule -- while removing the false contrast and the stacked relatives.

What would establish or refute this. The terminology mismatch is checkable by grepping "holds", "keeps", and "held" across usage() and the two reference files, which I did. That all four sites reduce to reachability rests on reading the git invocations (for-each-ref --contains, merge-base --is-ancestor, rev-list --not --remotes); I did not run them. The claim would be refuted if "keeps" were intended to mean "points directly at" -- update_submodule_tag disproves that, since it accepts a fetched tag whose commit merely contains the local one.

claim 01M3A1PSQ4NCBQKYC6B64XXAAX of review 01M3A1CRCCTK8AGTYWGXAJDQPF

<!-- review:claim:01M3A1PSQ4NCBQKYC6B64XXAAX --> **low** — `--help` alternates "held by", "keeps", and "holds" for one reachability relation, and its last clause stacks three bare verbs lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The rewritten "Writes in a default run, per repository:" block in `usage()` in `scripts/audit-checkouts.sh`, the corresponding prose in `references/removal-gates.md` and `references/checkout-updates.md`, and the predicates those sentences describe (`submodule_holds_local_work`, `update_submodule_tag`). > > **What the subject says.** Three consecutive new clauses use three different verbs for what the code treats as one relation, reachability of a commit from a ref: > > - "on a commit neither recorded nor **held by** a ref gets no fast-forward or selector move" (`for-each-ref --contains`) > - "a local selector tag is replaced only when the fetched tag or another ref **keeps** its commit" (`merge-base --is-ancestor`, plus `for-each-ref --contains`) > - "a worktree whose submodules **hold** work no remote-tracking ref **keeps** is not removed" (`for-each-ref --contains refs/remotes`, `rev-list --branches --tags --not --remotes`) > > The reference documents settle on one of these: `references/removal-gates.md` says "a branch or tag commit no remote-tracking ref **holds**, or a HEAD no remote-tracking ref **holds**", and `references/checkout-updates.md` says "no ref **holds**". "Keeps" appears only in `--help`. > > **What goes wrong.** Two costs, both in text `SKILL.md` requires the agent to read first ("Run `scripts/audit-checkouts.sh --help` ... before first use"). > > *Two terms, one concept.* The writing standard's "Use precise language" section requires one term per concept. Reading "held by a ref" and "another ref keeps its commit" in adjacent clauses invites the inference that they name different relations -- plausibly "points at" versus "reaches" -- and that distinction would matter, because it decides whether tagging a commit somewhere unrelated in the submodule is enough to unblock the gate. It is one relation in both cases. The mismatch against the references compounds it: an agent cross-reading `--help` and `removal-gates.md` sees "keeps" and "holds" and has no way to tell whether the difference is meaningful. > > *A garden path in the last clause.* "a worktree whose submodules hold work no remote-tracking ref keeps is not removed" stacks a reduced relative ("work [that] no remote-tracking ref keeps") inside another relative ("worktree whose submodules hold..."), leaving three bare verbs in a row -- "hold work no remote-tracking ref keeps is not removed". The reader reaches "keeps is" before the structure resolves. > > **Proposed correction.** Use "holds" throughout, matching both references, and break the last clause: "...; a local selector tag is replaced only when the fetched tag or another ref holds its commit; a worktree is not removed while any of its submodules holds work that no remote-tracking ref holds." This preserves each clause's content exactly -- the tag-replacement condition, the removal gate, and the remote-tracking-ref qualifier that distinguishes the removal rule from the update rule -- while removing the false contrast and the stacked relatives. > > **What would establish or refute this.** The terminology mismatch is checkable by grepping "holds", "keeps", and "held" across `usage()` and the two reference files, which I did. That all four sites reduce to reachability rests on reading the git invocations (`for-each-ref --contains`, `merge-base --is-ancestor`, `rev-list --not --remotes`); I did not run them. The claim would be refuted if "keeps" were intended to mean "points directly at" -- `update_submodule_tag` disproves that, since it accepts a fetched tag whose commit merely *contains* the local one. claim `01M3A1PSQ4NCBQKYC6B64XXAAX` of review `01M3A1CRCCTK8AGTYWGXAJDQPF`
jercik marked this conversation as resolved
@ -478,0 +505,4 @@
[ -n "$gitlink_path" ] || continue
submodule_path="$superproject_path/$gitlink_path"
[ -e "$submodule_path/.git" ] || continue
head=$(git -C "$submodule_path" rev-parse --verify --quiet HEAD) || continue

high — A submodule whose HEAD cannot be read is silently reported as holding no local work, so the removal gate deletes it
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new list_submodules_with_local_work / submodule_holds_local_work helpers in skills/audit-git-checkouts/scripts/audit-checkouts.sh, their call site in decide_removal_outcome (if ! submodule_work=$(list_submodules_with_local_work "$worktree_path" removal 2>>"$gate_error_path"); then echo operational/gate-check-failed), the live-status gate immediately after it, the new judgment/submodule-local-work row in references/removal-gates.md, and the new test a merged worktree is kept while its submodules hold work that exists only there in audit-checkouts.test.mjs.

What the subject does: the walk reads each populated submodule's HEAD with the anchored line. rev-parse --verify --quiet prints nothing and exits non-zero when the submodule's repository cannot be read, and the || continue then skips that submodule entirely -- both submodule_holds_local_work and the recursion into anything nested below it. The function still exits 0 with that submodule absent from its output, which the caller reads as "this submodule holds nothing that removal would lose". The git error goes to gate_error_path, but nothing ever inspects that file's size on this path; contrast the adjacent live-status gate, which does exactly that (if [ -s "$live_status_error_path" ]; then echo operational/gate-check-failed).

What goes wrong -- reproduced end to end with git 2.47.3 (no jq in this sandbox, so I sourced the script and drove the helper plus the real git commands the gate runs):

  1. repo with submodule dep, .gitmodules carrying ignore = all (the same setting the new removal test's own fixture uses for this submodule role); linked worktree on branch feature; submodule update --init in the worktree.
  2. In dep: git checkout -b wip, commit "local work". refs/heads/wip now holds a commit no remote-tracking ref holds. Detach back to main.
  3. list_submodules_with_local_work <worktree> removal prints dep -> gate fires, worktree kept. Correct.
  4. git -C <worktree>/dep config core.worktree /nonexistent/path (the symptom of a relocated or corrupted submodule gitdir). The scan now prints nothing and exits 0, writing only fatal: Invalid path '/nonexistent': No such file or directory to the stderr nobody reads.
  5. git -C <worktree> status --porcelain --untracked-files=normal -> empty stdout, empty stderr, because ignore = all suppresses it. So the live-status gate passes too.
  6. git -C repo/.git -c status.showUntrackedFiles=all worktree remove --force <worktree> succeeds. git -C repo cat-file -t <wip commit> then fails: fatal: git cat-file: could not get object info. The commit and refs/heads/wip are gone, because the submodule gitdir lives at .git/worktrees/<name>/modules/dep (I verified that layout separately) and removal deletes it.

I also checked the variant without any ignore setting: there git status in the superproject emits fatal: 'git status --porcelain=2' failed in submodule dep on stderr, the [ -s "$live_status_error_path" ] check fires, and the worktree is kept. So the loss needs submodule.<name>.ignore set to all or dirty -- an ordinary Git setting, and the one the change's own fixture chose. The same || continue also lets the update-mode scan report [] for an unreadable submodule, so a fast-forward and selector move proceed on a checkout nobody could actually inspect.

This contradicts the gate's stated contract in references/removal-gates.md ("No populated submodule, at any depth, holding work that would be lost with the worktree") and the skill's charter in SKILL.md ("commits no other ref holds ... are user work. Discard, delete, or drop them only on the user's instruction"). A safety gate that cannot read a repository must not answer "nothing here".

Safe correction: make an unreadable submodule a gate failure rather than a skip -- on a non-zero rev-parse, either return 1 (so the caller emits operational/gate-check-failed with the captured stderr) or print the path so it lands in judgment/submodule-local-work. Checking [ -s "$gate_error_path" ] after the scan, the way the live-status gate does, would close the same hole.

Proof gap: I reproduced the mechanism with the real helper and real git, but drove git worktree remove --force by hand rather than through decide_removal_outcome, because jq is unavailable here so the full driver cannot run. Every gate between the scan and the removal was evaluated with the same commands the script uses, and all passed.

claim 01M3A2DSD56MWBG1DRWY1NS6KM of review 01M3A1CRCCTK8AGTYWGXAJDQPF

<!-- review:claim:01M3A2DSD56MWBG1DRWY1NS6KM --> **high** — A submodule whose HEAD cannot be read is silently reported as holding no local work, so the removal gate deletes it lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new `list_submodules_with_local_work` / `submodule_holds_local_work` helpers in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, their call site in `decide_removal_outcome` (`if ! submodule_work=$(list_submodules_with_local_work "$worktree_path" removal 2>>"$gate_error_path"); then echo operational/gate-check-failed`), the live-status gate immediately after it, the new `judgment/submodule-local-work` row in `references/removal-gates.md`, and the new test `a merged worktree is kept while its submodules hold work that exists only there` in `audit-checkouts.test.mjs`. > > What the subject does: the walk reads each populated submodule's HEAD with the anchored line. `rev-parse --verify --quiet` prints nothing and exits non-zero when the submodule's repository cannot be read, and the `|| continue` then skips that submodule entirely -- both `submodule_holds_local_work` and the recursion into anything nested below it. The function still exits 0 with that submodule absent from its output, which the caller reads as "this submodule holds nothing that removal would lose". The git error goes to `gate_error_path`, but nothing ever inspects that file's size on this path; contrast the adjacent live-status gate, which does exactly that (`if [ -s "$live_status_error_path" ]; then echo operational/gate-check-failed`). > > What goes wrong -- reproduced end to end with git 2.47.3 (no jq in this sandbox, so I sourced the script and drove the helper plus the real git commands the gate runs): > > 1. repo with submodule `dep`, `.gitmodules` carrying `ignore = all` (the same setting the new removal test's own fixture uses for this submodule role); linked worktree on branch `feature`; `submodule update --init` in the worktree. > 2. In `dep`: `git checkout -b wip`, commit "local work". `refs/heads/wip` now holds a commit no remote-tracking ref holds. Detach back to `main`. > 3. `list_submodules_with_local_work <worktree> removal` prints `dep` -> gate fires, worktree kept. Correct. > 4. `git -C <worktree>/dep config core.worktree /nonexistent/path` (the symptom of a relocated or corrupted submodule gitdir). The scan now prints **nothing** and exits 0, writing only `fatal: Invalid path '/nonexistent': No such file or directory` to the stderr nobody reads. > 5. `git -C <worktree> status --porcelain --untracked-files=normal` -> empty stdout, **empty stderr**, because `ignore = all` suppresses it. So the live-status gate passes too. > 6. `git -C repo/.git -c status.showUntrackedFiles=all worktree remove --force <worktree>` succeeds. `git -C repo cat-file -t <wip commit>` then fails: `fatal: git cat-file: could not get object info`. The commit and `refs/heads/wip` are gone, because the submodule gitdir lives at `.git/worktrees/<name>/modules/dep` (I verified that layout separately) and removal deletes it. > > I also checked the variant without any `ignore` setting: there `git status` in the superproject emits `fatal: 'git status --porcelain=2' failed in submodule dep` on stderr, the `[ -s "$live_status_error_path" ]` check fires, and the worktree is kept. So the loss needs `submodule.<name>.ignore` set to `all` or `dirty` -- an ordinary Git setting, and the one the change's own fixture chose. The same `|| continue` also lets the update-mode scan report `[]` for an unreadable submodule, so a fast-forward and selector move proceed on a checkout nobody could actually inspect. > > This contradicts the gate's stated contract in `references/removal-gates.md` ("No populated submodule, at any depth, holding work that would be lost with the worktree") and the skill's charter in SKILL.md ("commits no other ref holds ... are user work. Discard, delete, or drop them only on the user's instruction"). A safety gate that cannot read a repository must not answer "nothing here". > > Safe correction: make an unreadable submodule a gate failure rather than a skip -- on a non-zero `rev-parse`, either `return 1` (so the caller emits `operational/gate-check-failed` with the captured stderr) or print the path so it lands in `judgment/submodule-local-work`. Checking `[ -s "$gate_error_path" ]` after the scan, the way the live-status gate does, would close the same hole. > > Proof gap: I reproduced the mechanism with the real helper and real git, but drove `git worktree remove --force` by hand rather than through `decide_removal_outcome`, because `jq` is unavailable here so the full driver cannot run. Every gate between the scan and the removal was evaluated with the same commands the script uses, and all passed. claim `01M3A2DSD56MWBG1DRWY1NS6KM` of review `01M3A1CRCCTK8AGTYWGXAJDQPF`
Author
Owner

Fixed in 7f0da38. Reproduced with ignore = all, a local wip branch in the submodule, and a broken core.worktree: the walk returned 0 with no output. It now prints cannot read submodule <path> and fails. Removal becomes operational/gate-check-failed, and the update check becomes a failure row. The removal test now covers an unreadable submodule and fails against e9f38b7.

<!-- gh-feedback:reply-to:87746 --> Fixed in 7f0da38. Reproduced with `ignore = all`, a local `wip` branch in the submodule, and a broken `core.worktree`: the walk returned 0 with no output. It now prints `cannot read submodule <path>` and fails. Removal becomes `operational/gate-check-failed`, and the update check becomes a failure row. The removal test now covers an unreadable submodule and fails against e9f38b7.
jercik marked this conversation as resolved
Lines 1241-1242
@ -1123,0 +1281,5 @@
assert.equal(refused.trackingUpdate.attempted, true);
assert.equal(refused.trackingUpdate.ok, false);
assert.match(refused.trackingUpdate.error, /local tag v1 .* no other ref holds/);
assert.equal(git(dependencyPath, "rev-parse", "v1^{commit}").trim(), localCommit);
assert.equal(git(dependencyPath, "rev-parse", "HEAD").trim(), thirdCommit);

low — The refused-tag case asserts the submodule HEAD equals the commit a successful move would also produce, so "stays where it is" is unprotected
lens test-trimming · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new test a configured tag follows origin unless the local tag holds a commit no other ref holds in skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs, its fixture createTrackedSubmoduleFixture, and the implementation it exercises — update_submodule_tag and synchronize_first_party_tracking_submodules in skills/audit-git-checkouts/scripts/audit-checkouts.sh. The behavior under test is the one references/checkout-updates.md promises for this case: "Otherwise, or when the local tag does not point at a commit, that submodule stays where it is, the local tag is kept, and the selector update fails naming the tag."

What the test does: the third phase builds the refusal state with

const localCommit = commitDetached(dependencyPath);
git(dependencyPath, "tag", "--no-sign", "--force", "v1");
git(dependencyPath, "checkout", "--quiet", "--detach", thirdCommit);

so the submodule's HEAD is parked on thirdCommit — and thirdCommit is exactly the commit origin's v1 points at, because the phase above ran const thirdCommit = advanceUpstream("third"); retag();. The anchored assertion then checks HEAD == thirdCommit. On the refusal path the driver never checks the submodule out at all (update_submodule_tag returns 1, the loop does refused=true; continue), and on a non-refusing path it would run git checkout --detach at refs/tags/v1^{commit} — which, after the fetched tag is adopted, is also thirdCommit. Both branches land on the same SHA, so the assertion cannot distinguish them.

What goes wrong: the assertion reads as the "stays where it is" half of the contract but protects nothing. A regression that keeps the local tag and still reports ok=false while moving the submodule anyway — e.g. hoisting target_ref/git checkout --detach out of the refusal branch, or resolving the target from FETCH_HEAD^{commit} instead of the (kept) local tag — leaves this whole test green, because every surviving assertion is about trackingUpdate.ok, the error text, and refs/tags/v1. The driver would silently move a first-party submodule off the commit its owner left it on, which is precisely the loss the refusal exists to prevent.

Evidence I ran: I could not execute audit-checkouts.test.mjs here (jq is not installed in this sandbox and audit_worktree requires it), so I rebuilt the same submodule fixture by hand under /tmp and drove synchronize_first_party_tracking_submodules directly by sourcing audit-checkouts.sh. With the real script the refusal fires as the test expects: stderr carries local tag v1 in .../dependency holds commit 7a64399... that no other ref holds; not replacing it with origin's 7248fb1..., rc=1, HEAD stays 7248fb1 (= thirdCommit). I then copied the script directory and disabled the refusal condition (if false && ! git ... merge-base --is-ancestor ...); rc became 0, the local tag was overwritten to 7248fb1 (losing 7a64399), and HEAD was still 7248fb1 — the anchored assertion passes unchanged under that mutant. That mutant is caught by the sibling ok/tag assertions, but it confirms the HEAD assertion itself is non-discriminating for any SHA the fetched tag also names.

Suggested repair (keeps the intended check rather than deleting it): park the submodule on a commit other than the fetched tag's commit before the refused run — e.g. git(dependencyPath, "checkout", "--quiet", "--detach", secondCommit) instead of thirdCommit, then assert HEAD == secondCommit. secondCommit is still contained by the local v1 (which now points at localCommit, a descendant), so the strandedSubmodules gate still passes and trackingUpdate is still attempted; the refusal verdict and the tag assertion are unaffected, but the HEAD assertion now fails if any code path checks the submodule out.

What would refute this: showing a path in synchronize_first_party_tracking_submodules where a non-refused run would land the submodule on something other than refs/tags/v1^{commit} in this fixture — I traced the function and found none; the only checkout is git -C "$submodule_path" checkout --detach "$target_sha" with target_ref="refs/tags/$tag^{commit}".

claim 01M3A1TNK4DSZ5Y0F097EAY7S6 of review 01M3A1CRCCTK8AGTYWGXAJDQPF

<!-- review:claim:01M3A1TNK4DSZ5Y0F097EAY7S6 --> **low** — The refused-tag case asserts the submodule HEAD equals the commit a successful move would also produce, so "stays where it is" is unprotected lens `test-trimming` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new test `a configured tag follows origin unless the local tag holds a commit no other ref holds` in `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs`, its fixture `createTrackedSubmoduleFixture`, and the implementation it exercises — `update_submodule_tag` and `synchronize_first_party_tracking_submodules` in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`. The behavior under test is the one `references/checkout-updates.md` promises for this case: "Otherwise, or when the local tag does not point at a commit, that submodule stays where it is, the local tag is kept, and the selector update fails naming the tag." > > What the test does: the third phase builds the refusal state with > > const localCommit = commitDetached(dependencyPath); > git(dependencyPath, "tag", "--no-sign", "--force", "v1"); > git(dependencyPath, "checkout", "--quiet", "--detach", thirdCommit); > > so the submodule's HEAD is parked on `thirdCommit` — and `thirdCommit` is exactly the commit `origin`'s `v1` points at, because the phase above ran `const thirdCommit = advanceUpstream("third"); retag();`. The anchored assertion then checks `HEAD == thirdCommit`. On the refusal path the driver never checks the submodule out at all (`update_submodule_tag` returns 1, the loop does `refused=true; continue`), and on a non-refusing path it would run `git checkout --detach` at `refs/tags/v1^{commit}` — which, after the fetched tag is adopted, is also `thirdCommit`. Both branches land on the same SHA, so the assertion cannot distinguish them. > > What goes wrong: the assertion reads as the "stays where it is" half of the contract but protects nothing. A regression that keeps the local tag and still reports `ok=false` while moving the submodule anyway — e.g. hoisting `target_ref`/`git checkout --detach` out of the refusal branch, or resolving the target from `FETCH_HEAD^{commit}` instead of the (kept) local tag — leaves this whole test green, because every surviving assertion is about `trackingUpdate.ok`, the error text, and `refs/tags/v1`. The driver would silently move a first-party submodule off the commit its owner left it on, which is precisely the loss the refusal exists to prevent. > > Evidence I ran: I could not execute `audit-checkouts.test.mjs` here (`jq` is not installed in this sandbox and `audit_worktree` requires it), so I rebuilt the same submodule fixture by hand under `/tmp` and drove `synchronize_first_party_tracking_submodules` directly by sourcing `audit-checkouts.sh`. With the real script the refusal fires as the test expects: stderr carries `local tag v1 in .../dependency holds commit 7a64399... that no other ref holds; not replacing it with origin's 7248fb1...`, rc=1, HEAD stays `7248fb1` (= thirdCommit). I then copied the script directory and disabled the refusal condition (`if false && ! git ... merge-base --is-ancestor ...`); rc became 0, the local tag was overwritten to `7248fb1` (losing `7a64399`), and **HEAD was still `7248fb1`** — the anchored assertion passes unchanged under that mutant. That mutant is caught by the sibling `ok`/tag assertions, but it confirms the HEAD assertion itself is non-discriminating for any SHA the fetched tag also names. > > Suggested repair (keeps the intended check rather than deleting it): park the submodule on a commit other than the fetched tag's commit before the refused run — e.g. `git(dependencyPath, "checkout", "--quiet", "--detach", secondCommit)` instead of `thirdCommit`, then assert `HEAD == secondCommit`. `secondCommit` is still contained by the local `v1` (which now points at `localCommit`, a descendant), so the `strandedSubmodules` gate still passes and `trackingUpdate` is still attempted; the refusal verdict and the tag assertion are unaffected, but the HEAD assertion now fails if any code path checks the submodule out. > > What would refute this: showing a path in `synchronize_first_party_tracking_submodules` where a non-refused run would land the submodule on something other than `refs/tags/v1^{commit}` in this fixture — I traced the function and found none; the only checkout is `git -C "$submodule_path" checkout --detach "$target_sha"` with `target_ref="refs/tags/$tag^{commit}"`. claim `01M3A1TNK4DSZ5Y0F097EAY7S6` of review `01M3A1CRCCTK8AGTYWGXAJDQPF`
jercik marked this conversation as resolved
fix(audit-git-checkouts): an unreadable submodule should stop removal and updates
All checks were successful
commit-msg / commitlint (pull_request) Successful in 28s
Node tests / node:test (pull_request) Successful in 1m27s
Review / Review (pull_request_target) Successful in 25m27s
7f0da3835a
The submodule walk skipped a submodule whose HEAD it could not read and
reported it as holding nothing. With `ignore = all` the final status check
stayed silent too, so the worktree was removed with the submodule's local
branches. The walk now fails on an unreadable submodule, which makes removal
an `operational/gate-check-failed` and the update check a failure row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Replying to review summary comment #87417

Round-5 outcomes for review 01M3A1CRCCTK8AGTYWGXAJDQPF (head e9f38b7). From round 5, a new push goes only to clear, severe bugs.

Fixed in 7f0da38:

  • high, an unreadable submodule passes the removal gate (87746): agreed. See the inline thread.

Acknowledged and deferred to a follow-up PR. These change only wording or test precision:

  • medium, --help still says "schema 5" (no inline thread; same as round 4). In scripts/audit-checkouts.sh usage(), drop the number: "Writes one JSON report to stdout".
  • medium, "when nothing fast-forwards" (87747; same as 87695). In SKILL.md, use "even when no fast-forward runs".
  • medium, "no ref holds" names no repository (87748). In references/checkout-updates.md and usage(), write "no ref in the submodule holds".
  • low, the mode table cell is too long (87749; same as 87698). In SKILL.md, shorten it to "fast-forward eligible default checkouts and move their submodules".
  • low, --help uses "held by", "keeps" and "holds" (87750; same as 87700). Use "holds" throughout, and split the removal clause as the finding proposes.
  • low, the refused-tag assertion is weak (87751). In scripts/audit-checkouts.test.mjs, park the submodule on secondCommit before the refused run and assert HEAD stays there.
> Replying to review summary comment #87417 Round-5 outcomes for review `01M3A1CRCCTK8AGTYWGXAJDQPF` (head e9f38b7). From round 5, a new push goes only to clear, severe bugs. Fixed in 7f0da38: - **high, an unreadable submodule passes the removal gate (87746):** agreed. See the inline thread. Acknowledged and deferred to a follow-up PR. These change only wording or test precision: - **medium, `--help` still says "schema 5"** (no inline thread; same as round 4). In `scripts/audit-checkouts.sh` `usage()`, drop the number: "Writes one JSON report to stdout". - **medium, "when nothing fast-forwards" (87747; same as 87695).** In `SKILL.md`, use "even when no fast-forward runs". - **medium, "no ref holds" names no repository (87748).** In `references/checkout-updates.md` and `usage()`, write "no ref in the submodule holds". - **low, the mode table cell is too long (87749; same as 87698).** In `SKILL.md`, shorten it to "fast-forward eligible default checkouts and move their submodules". - **low, `--help` uses "held by", "keeps" and "holds" (87750; same as 87700).** Use "holds" throughout, and split the removal clause as the finding proposes. - **low, the refused-tag assertion is weak (87751).** In `scripts/audit-checkouts.test.mjs`, park the submodule on `secondCommit` before the refused run and assert HEAD stays there.
@ -16,3 +16,3 @@
| Request | Flags | What changes |
| --- | --- | --- |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |

low — Mode-table cell grew to a 70-word semicolon chain that splits "check … out" across nine words and restates the reference
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The "Choose the mode" table in skills/audit-git-checkouts/SKILL.md, all three of its rows, the fourth bullet that follows it, and the first-party bullet in skills/audit-git-checkouts/references/checkout-updates.md that both summarize.

What the subject says. The default row's "What changes" cell is now a five-clause semicolon chain of about 70 words, of which roughly 45 are new submodule detail. The other two rows in the same column are short ("Everything above except removal."). Inside the cell, the phrasal verb is split across nine words of object: "check each submodule listed in the checkout's own .gitmodules out at its configured branch or tag". The reader meets "check each submodule listed in…" and naturally parses "check" as "inspect" — the sense that fits a skill about auditing — and only recovers the intended "check out" on reaching the stranded particle. The same construction appears again in the bullet below: "the driver checks every submodule, third-party ones included, out at its recorded commit."

What goes wrong. Two costs. First, the table exists so a reader can compare three modes at a glance and pick flags; a cell that has to be read twice, and that dwarfs the rows it is being compared against, defeats that. The installed writing standard asks for hierarchy that makes content easier to navigate and for the decisive constraint to be prominent — here the decisive per-mode fact (removal happens) is the last of five clauses. Second, the same submodule mechanism is now stated three times within this skill: in this cell, in the fourth bullet under the table, and in references/checkout-updates.md's first-party bullet, which SKILL.md links for exactly this topic. "One Idea, One Place" rules that out, and triplication is how the three copies came to disagree — the bullet's "when nothing fast-forwards" condition appears in neither of the other two.

Proposed correction. Return the cell to mode-level granularity and let the bullet and the reference carry the detail: "Fetch and prune origin; fast-forward eligible default checkouts and bring their submodules to the recorded commits, then move first-party submodules to their selectors; prune stale worktree registrations; remove proven-merged linked worktrees inside the root." That keeps every distinction the mode table must draw — what the default mode does that --no-remove and --no-fetch --no-remove do not — and drops the .gitmodules lookup rule and the unstaged-Gitlink detail, which references/checkout-updates.md already states and states more fully. Rewrite the split phrasal verb in both places ("check out each submodule … at its configured branch or tag"; "checks out every submodule, third-party ones included, at its recorded commit").

What would settle it. Comparing the three cells in the rendered table and comparing the cell's submodule sentence with the first-party bullet in references/checkout-updates.md; this is a structure and construction judgment against the installed standard, and I found no factual error in the cell itself.

claim 01M3BGV3J1HRMKZ1EBQ61CS1FV of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BGV3J1HRMKZ1EBQ61CS1FV --> **low** — Mode-table cell grew to a 70-word semicolon chain that splits "check … out" across nine words and restates the reference lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The "Choose the mode" table in `skills/audit-git-checkouts/SKILL.md`, all three of its rows, the fourth bullet that follows it, and the first-party bullet in `skills/audit-git-checkouts/references/checkout-updates.md` that both summarize. > > **What the subject says.** The default row's "What changes" cell is now a five-clause semicolon chain of about 70 words, of which roughly 45 are new submodule detail. The other two rows in the same column are short ("Everything above except removal."). Inside the cell, the phrasal verb is split across nine words of object: "check each submodule listed in the checkout's own `.gitmodules` **out** at its configured branch or tag". The reader meets "check each submodule listed in…" and naturally parses "check" as "inspect" — the sense that fits a skill about auditing — and only recovers the intended "check out" on reaching the stranded particle. The same construction appears again in the bullet below: "the driver checks every submodule, third-party ones included, **out** at its recorded commit." > > **What goes wrong.** Two costs. First, the table exists so a reader can compare three modes at a glance and pick flags; a cell that has to be read twice, and that dwarfs the rows it is being compared against, defeats that. The installed writing standard asks for hierarchy that makes content easier to navigate and for the decisive constraint to be prominent — here the decisive per-mode fact (removal happens) is the last of five clauses. Second, the same submodule mechanism is now stated three times within this skill: in this cell, in the fourth bullet under the table, and in `references/checkout-updates.md`'s first-party bullet, which SKILL.md links for exactly this topic. "One Idea, One Place" rules that out, and triplication is how the three copies came to disagree — the bullet's "when nothing fast-forwards" condition appears in neither of the other two. > > **Proposed correction.** Return the cell to mode-level granularity and let the bullet and the reference carry the detail: "Fetch and prune `origin`; fast-forward eligible default checkouts and bring their submodules to the recorded commits, then move first-party submodules to their selectors; prune stale worktree registrations; remove proven-merged linked worktrees inside the root." That keeps every distinction the mode table must draw — what the default mode does that `--no-remove` and `--no-fetch --no-remove` do not — and drops the `.gitmodules` lookup rule and the unstaged-Gitlink detail, which `references/checkout-updates.md` already states and states more fully. Rewrite the split phrasal verb in both places ("check out each submodule … at its configured branch or tag"; "checks out every submodule, third-party ones included, at its recorded commit"). > > **What would settle it.** Comparing the three cells in the rendered table and comparing the cell's submodule sentence with the first-party bullet in `references/checkout-updates.md`; this is a structure and construction judgment against the installed standard, and I found no factual error in the cell itself. claim `01M3BGV3J1HRMKZ1EBQ61CS1FV` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
@ -23,3 +23,4 @@
- A repository whose work integrates somewhere other than `origin/<default>`, such as a fork with an `upstream`: `--no-remove`. Removal proves containment against `origin/<default>` only.
- A repository with a custom `origin` fetch refspec: `--no-fetch --no-remove`. The fetch prunes every `refs/remotes/origin/*` ref that no server branch supplies (see [references/checkout-updates.md](references/checkout-updates.md)).
- A checkout whose submodules must stay where they are: `--no-fetch --no-remove`. After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and `update = none` stops only that move.

medium — SKILL.md restricts first-party selector moves to checkouts where "nothing fast-forwards", but they also run after a successful fast-forward
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The new fourth bullet under "Choose the mode" in skills/audit-git-checkouts/SKILL.md, the paragraph it summarizes in skills/audit-git-checkouts/references/checkout-updates.md, and the tracking_update_eligible condition in skills/audit-git-checkouts/scripts/audit-checkouts.sh.

What the subject says. The bullet lays out two cases in sequence: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and update = none stops only that move." The trailing when nothing fast-forwards reads as a condition restricting selector moves to checkouts that were not fast-forwarded, and the deliberate contrast with the preceding "After a fast-forward" sentence pushes the reader toward that split reading.

What the driver actually does. In audit-checkouts.sh, eligibility for the selector move is gated on && { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } — it runs both when no fast-forward was attempted and when one succeeded; only a failed fast-forward suppresses it. The reference file agrees with the code rather than with SKILL.md: references/checkout-updates.md says "On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit," attaching no no-fast-forward condition. So after a successful fast-forward a first-party submodule is first checked out at the recorded Gitlink and then moved again to its branch/tag selector.

What goes wrong. This bullet's only job is to tell the reader when to pick a stricter mode for a checkout whose submodules must stay put. An agent that trusts it concludes that a checkout which is behind — and therefore will fast-forward — is safe in default mode, and never offers --no-fetch --no-remove. That is the common case, not an edge case: a checkout that is up to date does not fast-forward, so the wording exempts precisely the checkouts most likely to be touched. It also leaves SKILL.md, the always-loaded entry point, contradicting its own reference file, which the writing standard's "One Idea, One Place" and "Make claims verifiable" both rule out.

Proposed correction. Drop the false condition and state the union, keeping the first-party scope and the update = none carve-out: "In first-party checkouts it then moves branch- or tag-tracked submodules to their selectors, whether or not anything fast-forwarded; update = none stops only that move."

What would settle it. The tracking_update_eligible guard quoted above, and any case in scripts/audit-checkouts.test.mjs that asserts a selector move on a checkout that fast-forwarded in the same run. I read the shell source and traced the condition; I did not execute the driver.

claim 01M3BGP9SEGPG8CRH5HFRCE6FV of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BGP9SEGPG8CRH5HFRCE6FV --> **medium** — SKILL.md restricts first-party selector moves to checkouts where "nothing fast-forwards", but they also run after a successful fast-forward lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The new fourth bullet under "Choose the mode" in `skills/audit-git-checkouts/SKILL.md`, the paragraph it summarizes in `skills/audit-git-checkouts/references/checkout-updates.md`, and the `tracking_update_eligible` condition in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`. > > **What the subject says.** The bullet lays out two cases in sequence: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and `update = none` stops only that move." The trailing `when nothing fast-forwards` reads as a condition restricting selector moves to checkouts that were not fast-forwarded, and the deliberate contrast with the preceding "After a fast-forward" sentence pushes the reader toward that split reading. > > **What the driver actually does.** In `audit-checkouts.sh`, eligibility for the selector move is gated on `&& { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; }` — it runs both when no fast-forward was attempted *and* when one succeeded; only a failed fast-forward suppresses it. The reference file agrees with the code rather than with SKILL.md: `references/checkout-updates.md` says "On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit," attaching no no-fast-forward condition. So after a successful fast-forward a first-party submodule is first checked out at the recorded Gitlink and then moved again to its `branch`/`tag` selector. > > **What goes wrong.** This bullet's only job is to tell the reader when to pick a stricter mode for a checkout whose submodules must stay put. An agent that trusts it concludes that a checkout which is behind — and therefore will fast-forward — is safe in default mode, and never offers `--no-fetch --no-remove`. That is the common case, not an edge case: a checkout that is up to date does not fast-forward, so the wording exempts precisely the checkouts most likely to be touched. It also leaves SKILL.md, the always-loaded entry point, contradicting its own reference file, which the writing standard's "One Idea, One Place" and "Make claims verifiable" both rule out. > > **Proposed correction.** Drop the false condition and state the union, keeping the first-party scope and the `update = none` carve-out: "In first-party checkouts it then moves branch- or tag-tracked submodules to their selectors, whether or not anything fast-forwarded; `update = none` stops only that move." > > **What would settle it.** The `tracking_update_eligible` guard quoted above, and any case in `scripts/audit-checkouts.test.mjs` that asserts a selector move on a checkout that fast-forwarded in the same run. I read the shell source and traced the condition; I did not execute the driver. claim `01M3BGP9SEGPG8CRH5HFRCE6FV` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
@ -5,3 +5,3 @@
## What the driver already updates
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth) or first-party `.gitmodules`/Gitlink maintenance, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it synchronizes committed submodules to the recorded Gitlinks.
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth), a first-party `.gitmodules` edit, or a first-party Gitlink change on a submodule with a `branch` or `tag` selector, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it initializes committed submodules and checks them out at the recorded Gitlinks. When any populated submodule, at any depth, found from the Gitlinks rather than `.gitmodules`, is checked out at a commit that its superproject does not record and no ref holds, the driver skips the fast-forward and every selector move for that checkout, and the report lists it under "Needs your decision" with the submodule's path.

low — The stranded-submodule rule is a 57-word sentence that stacks three modifiers before its subject reaches a verb
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The "What the driver already updates" paragraph in skills/audit-git-checkouts/references/checkout-updates.md in full, before and after the change, and the guidance in /opt/review/skills/writing-for-agents/SKILL.md on sentence construction ("Lead with the action and its object; attach conditions to the action they govern") and on paragraph structure.

What the subject says. The change appends the anchored sentence to a paragraph that already ran about 130 words. In it, the subject "any populated submodule" is separated from its verb "is checked out" by two stacked modifiers — "at any depth" and "found from the Gitlinks rather than .gitmodules" — after which a two-part condition ("a commit that its superproject does not record and no ref holds") must be held in mind before the reader reaches the first main clause. Two coordinated main clauses then follow. The decisive fact for the reader — the driver refuses to touch this checkout — lands at word 33 of 57, and the second decisive fact, where the checkout shows up in the report, lands at the very end.

What goes wrong. This is the paragraph an agent reads to answer "why was this checkout not fast-forwarded?", and the answer is the part deferred longest. The condition is not inherently hard; the sentence shape is. Read in a hurry, the fronted "When any populated submodule, at any depth, found from the Gitlinks rather than .gitmodules" also invites the misreading that the driver enumerates submodules by depth first and .gitmodules second, rather than the intended point — that the walk starts from Gitlinks so that a deleted .gitmodules or an ignore setting cannot hide a submodule, which is the rationale the code comment above list_submodules_with_local_work in scripts/audit-checkouts.sh states plainly.

Proposed correction. Lead with the refusal, attach the condition to it, and split off the reporting consequence:

The driver skips a checkout's fast-forward and every selector move when one of its submodules sits on a commit the superproject does not record and no ref holds. It walks every populated submodule at any depth, finding them from the Gitlinks rather than .gitmodules, so neither a deleted .gitmodules nor an ignore setting hides one. The report lists such a checkout under "Needs your decision" with the submodule's path.

That preserves every fact the original carries — the depth, the Gitlink-based discovery, both halves of the condition, both consequences — while putting the action first and giving the discovery rule its own sentence, with the reason the code comment already supplies.

What would settle it. Reading the sentence in place against the surrounding paragraph; this is a construction judgment against the installed writing standard, not a behavioral claim, and I verified no factual error in it.

claim 01M3BGSMFG15D1KAC28GVZRRW5 of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BGSMFG15D1KAC28GVZRRW5 --> **low** — The stranded-submodule rule is a 57-word sentence that stacks three modifiers before its subject reaches a verb lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The "What the driver already updates" paragraph in `skills/audit-git-checkouts/references/checkout-updates.md` in full, before and after the change, and the guidance in `/opt/review/skills/writing-for-agents/SKILL.md` on sentence construction ("Lead with the action and its object; attach conditions to the action they govern") and on paragraph structure. > > **What the subject says.** The change appends the anchored sentence to a paragraph that already ran about 130 words. In it, the subject "any populated submodule" is separated from its verb "is checked out" by two stacked modifiers — "at any depth" and "found from the Gitlinks rather than `.gitmodules`" — after which a two-part condition ("a commit that its superproject does not record and no ref holds") must be held in mind before the reader reaches the first main clause. Two coordinated main clauses then follow. The decisive fact for the reader — the driver refuses to touch this checkout — lands at word 33 of 57, and the second decisive fact, where the checkout shows up in the report, lands at the very end. > > **What goes wrong.** This is the paragraph an agent reads to answer "why was this checkout not fast-forwarded?", and the answer is the part deferred longest. The condition is not inherently hard; the sentence shape is. Read in a hurry, the fronted "When any populated submodule, at any depth, found from the Gitlinks rather than `.gitmodules`" also invites the misreading that the driver enumerates submodules by depth first and `.gitmodules` second, rather than the intended point — that the walk starts from Gitlinks so that a deleted `.gitmodules` or an `ignore` setting cannot hide a submodule, which is the rationale the code comment above `list_submodules_with_local_work` in `scripts/audit-checkouts.sh` states plainly. > > **Proposed correction.** Lead with the refusal, attach the condition to it, and split off the reporting consequence: > > > The driver skips a checkout's fast-forward and every selector move when one of its submodules sits on a commit the superproject does not record and no ref holds. It walks every populated submodule at any depth, finding them from the Gitlinks rather than `.gitmodules`, so neither a deleted `.gitmodules` nor an `ignore` setting hides one. The report lists such a checkout under "Needs your decision" with the submodule's path. > > That preserves every fact the original carries — the depth, the Gitlink-based discovery, both halves of the condition, both consequences — while putting the action first and giving the discovery rule its own sentence, with the reason the code comment already supplies. > > **What would settle it.** Reading the sentence in place against the surrounding paragraph; this is a construction judgment against the installed writing standard, not a behavioral claim, and I verified no factual error in it. claim `01M3BGSMFG15D1KAC28GVZRRW5` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
@ -18,3 +18,3 @@
- **Under a `third-party/` path component:** `.gitmodules` and Gitlinks belong to upstream. Never add or change selectors, URLs, paths, update policies, or Gitlinks; only synchronize what upstream committed.
- **Everywhere else (first-party):** each submodule needs exactly one selector, `branch = <name>` for a moving line or `tag = <name>` for an exact release. On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit.
- **Everywhere else (first-party):** each submodule needs exactly one selector, `branch = <name>` for a moving line or `tag = <name>` for an exact release. On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit. Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits. A local tag of the configured name on the same commit is kept as it is. One on another commit is replaced only when the fetched tag's commit contains that commit or another ref holds it. Otherwise, or when the local tag does not point at a commit, that submodule stays where it is, the local tag is kept, and the selector update fails naming the tag.

medium — The new submodule prose uses "contains", "holds", and "keeps" interchangeably for ref reachability, twice inside one sentence
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. Every passage the change adds about commit reachability: the first-party selector bullet and the fast-forward paragraph in skills/audit-git-checkouts/references/checkout-updates.md, the submodule paragraph and the new gate bullet in skills/audit-git-checkouts/references/removal-gates.md, the usage text in skills/audit-git-checkouts/scripts/audit-checkouts.sh, and the update_submodule_tag implementation those passages describe.

What the subject says. One relation — "some ref makes this commit reachable" — is named by three different verbs, and the anchored sentence switches between two of them across a single or:

  • references/checkout-updates.md: "the fetched tag's commit contains that commit or another ref holds it" — both clauses describe the same predicate. In update_submodule_tag they are git merge-base --is-ancestor "$local_commit" "$fetched_commit" and git for-each-ref --contains "$local_commit"; the second is literally Git's --contains, yet the prose calls it "holds" and calls the first "contains".
  • The same file, a paragraph earlier: "a commit that its superproject does not record and no ref holds".
  • references/removal-gates.md: "a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds. A Gitlink names a commit without keeping it".
  • scripts/audit-checkouts.sh usage: "a local selector tag is replaced only when the fetched tag or another ref keeps its commit; a worktree whose submodules hold work no remote-tracking ref keeps is not removed."

keeps is additionally overloaded: in removal-gates.md the same change writes "A submodule keeps the worktree", where it means "causes the worktree to be retained", not reachability at all.

What goes wrong. The writing standard's "Use Precise Language" requires one term for one concept, and this material is where that matters most: the reader's job is to decide whether a specific commit is safe to move away from. A reader who assumes three verbs mean three predicates will look for a distinction that does not exist — plausibly reading "contains" as ancestry and "holds" as "is the ref's tip", which would make "another ref holds it" a far narrower condition than the for-each-ref --contains the driver runs, and would lead an agent to tell the user a tag move was refused when it would in fact have been allowed. The keeps overload compounds it: within one reference file, "keeps" means both reachability and worktree retention.

Proposed correction. Pick one verb for reachability and use it everywhere the change touches. "Reaches" avoids the collision with worktree retention and with Git's own --contains flag: "One on another commit is replaced only when the fetched tag's commit reaches that commit, or another ref reaches it." Then rewrite the other four sites the same way ("no ref reaches", "no remote-tracking ref reaches", "the fetched tag or another ref still reaches its commit"), and leave "keeps" for worktree retention alone. This preserves the exact conditions being stated while making the shared predicate visible as one predicate.

What would settle it. The update_submodule_tag and submodule_holds_local_work bodies in scripts/audit-checkouts.sh, which show that every one of these phrasings resolves to a --contains or merge-base --is-ancestor test. I read the source; I did not execute the driver.

claim 01M3BGQTHFHS7SX6CGGV98VD1Y of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BGQTHFHS7SX6CGGV98VD1Y --> **medium** — The new submodule prose uses "contains", "holds", and "keeps" interchangeably for ref reachability, twice inside one sentence lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** Every passage the change adds about commit reachability: the first-party selector bullet and the fast-forward paragraph in `skills/audit-git-checkouts/references/checkout-updates.md`, the submodule paragraph and the new gate bullet in `skills/audit-git-checkouts/references/removal-gates.md`, the `usage` text in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, and the `update_submodule_tag` implementation those passages describe. > > **What the subject says.** One relation — "some ref makes this commit reachable" — is named by three different verbs, and the anchored sentence switches between two of them across a single `or`: > > - `references/checkout-updates.md`: "the fetched tag's commit **contains** that commit or another ref **holds** it" — both clauses describe the same predicate. In `update_submodule_tag` they are `git merge-base --is-ancestor "$local_commit" "$fetched_commit"` and `git for-each-ref --contains "$local_commit"`; the second is literally Git's `--contains`, yet the prose calls it "holds" and calls the first "contains". > - The same file, a paragraph earlier: "a commit that its superproject does not record and no ref **holds**". > - `references/removal-gates.md`: "a branch or tag commit no remote-tracking ref **holds**, or a HEAD no remote-tracking ref **holds**. A Gitlink names a commit without **keeping** it". > - `scripts/audit-checkouts.sh` usage: "a local selector tag is replaced only when the fetched tag or another ref **keeps** its commit; a worktree whose submodules hold work no remote-tracking ref **keeps** is not removed." > > `keeps` is additionally overloaded: in `removal-gates.md` the same change writes "A submodule **keeps** the worktree", where it means "causes the worktree to be retained", not reachability at all. > > **What goes wrong.** The writing standard's "Use Precise Language" requires one term for one concept, and this material is where that matters most: the reader's job is to decide whether a specific commit is safe to move away from. A reader who assumes three verbs mean three predicates will look for a distinction that does not exist — plausibly reading "contains" as ancestry and "holds" as "is the ref's tip", which would make "another ref holds it" a far narrower condition than the `for-each-ref --contains` the driver runs, and would lead an agent to tell the user a tag move was refused when it would in fact have been allowed. The `keeps` overload compounds it: within one reference file, "keeps" means both reachability and worktree retention. > > **Proposed correction.** Pick one verb for reachability and use it everywhere the change touches. "Reaches" avoids the collision with worktree retention and with Git's own `--contains` flag: "One on another commit is replaced only when the fetched tag's commit reaches that commit, or another ref reaches it." Then rewrite the other four sites the same way ("no ref reaches", "no remote-tracking ref reaches", "the fetched tag or another ref still reaches its commit"), and leave "keeps" for worktree retention alone. This preserves the exact conditions being stated while making the shared predicate visible as one predicate. > > **What would settle it.** The `update_submodule_tag` and `submodule_holds_local_work` bodies in `scripts/audit-checkouts.sh`, which show that every one of these phrasings resolves to a `--contains` or `merge-base --is-ancestor` test. I read the source; I did not execute the driver. claim `01M3BGQTHFHS7SX6CGGV98VD1Y` of review `01M3BGFRYK9R81EWETEHWA9X9E`

medium — "nested submodules follow their recorded commits" promises an update the selector path never performs
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The first-party bullet under "Submodule selectors" in skills/audit-git-checkouts/references/checkout-updates.md; the two functions it describes in skills/audit-git-checkouts/scripts/audit-checkouts.sh, synchronize_submodules and synchronize_first_party_tracking_submodules; the order in which collect_worktree_record calls them; and the nested-submodule cases in skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs.

What the subject says. The bullet describes the selector move — fetch the configured branch/tag, check the submodule out detached, leave the Gitlink unstaged — and then adds: "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move; nested submodules follow their recorded commits." Placed immediately after the description of a move, the second clause reads as a promise about what happens to submodules inside a moved submodule: they are brought to the commits their (now moved) parent records.

What the code does on that path. synchronize_first_party_tracking_submodules runs git submodule sync --recursive (URLs only), then for each selected submodule runs git submodule update --init --checkout -- "$configured_path" — with no --recursive, and only when $submodule_path/.git is absent — then fetches and runs git checkout --detach "$target_sha" inside the submodule. Nothing recurses after that detach. So once the parent submodule moves to its selector commit, a submodule nested inside it stays at whatever commit it already had; and when the parent was initialized in this same pass, the nested one is never initialized at all. Recursive Gitlink following happens only in the other function, synchronize_submodules, which runs git submodule update --init --recursive --checkout after a fast-forward — and it runs before the selector move, so the commits it installs are the pre-move ones.

What goes wrong. The sentence is the reader's only statement about nested submodules on the selector path, and it states the opposite of the behavior. SKILL.md directs the agent to read this file in full before acting on "a submodule selector problem," so an agent reaches it precisely when it is about to tell the user what state a first-party checkout is in. Believing it, the agent reports the checkout as consistently synchronized when a nested submodule can be sitting at a commit its parent no longer records, or missing entirely. A second reading is available — "nested submodules are not selector-driven; only Gitlinks apply to them" — but that reading is not what the words say, and even under it "follow their recorded commits" still asserts an update that does not occur on this path.

I checked the tests for a counterexample: audit-checkouts.test.mjs exercises nested submodules only in "a nested submodule commit no ref holds blocks the fast-forward", which asserts the refusal. No test asserts that a nested submodule is moved to a recorded commit after a selector move.

Proposed correction. Say what is and is not in scope, without claiming an action: "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move; a selector inside a nested .gitmodules is ignored, and a submodule nested inside a moved one is left where the last Gitlink sync put it." That keeps the useful scoping fact — nested selectors do not apply — and replaces the false promise with the state the reader will actually find.

What would settle it. The absence of --recursive on the git submodule update --init --checkout call and the absence of any post-checkout --detach recursion in synchronize_first_party_tracking_submodules. If the intended behavior is recursion, the defect is in the code rather than the sentence; either way the two disagree. I traced the shell source and read the tests; I did not run the driver against a nested fixture.

claim 01M3BGRV25RCM405CYVBKKMHTD of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BGRV25RCM405CYVBKKMHTD --> **medium** — "nested submodules follow their recorded commits" promises an update the selector path never performs lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The first-party bullet under "Submodule selectors" in `skills/audit-git-checkouts/references/checkout-updates.md`; the two functions it describes in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, `synchronize_submodules` and `synchronize_first_party_tracking_submodules`; the order in which `collect_worktree_record` calls them; and the nested-submodule cases in `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs`. > > **What the subject says.** The bullet describes the selector move — fetch the configured `branch`/`tag`, check the submodule out detached, leave the Gitlink unstaged — and then adds: "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits." Placed immediately after the description of a move, the second clause reads as a promise about what happens to submodules inside a moved submodule: they are brought to the commits their (now moved) parent records. > > **What the code does on that path.** `synchronize_first_party_tracking_submodules` runs `git submodule sync --recursive` (URLs only), then for each selected submodule runs `git submodule update --init --checkout -- "$configured_path"` — with no `--recursive`, and only when `$submodule_path/.git` is absent — then fetches and runs `git checkout --detach "$target_sha"` inside the submodule. Nothing recurses after that detach. So once the parent submodule moves to its selector commit, a submodule nested inside it stays at whatever commit it already had; and when the parent was initialized in this same pass, the nested one is never initialized at all. Recursive Gitlink following happens only in the *other* function, `synchronize_submodules`, which runs `git submodule update --init --recursive --checkout` after a fast-forward — and it runs *before* the selector move, so the commits it installs are the pre-move ones. > > **What goes wrong.** The sentence is the reader's only statement about nested submodules on the selector path, and it states the opposite of the behavior. `SKILL.md` directs the agent to read this file in full before acting on "a submodule selector problem," so an agent reaches it precisely when it is about to tell the user what state a first-party checkout is in. Believing it, the agent reports the checkout as consistently synchronized when a nested submodule can be sitting at a commit its parent no longer records, or missing entirely. A second reading is available — "nested submodules are not selector-driven; only Gitlinks apply to them" — but that reading is not what the words say, and even under it "follow their recorded commits" still asserts an update that does not occur on this path. > > I checked the tests for a counterexample: `audit-checkouts.test.mjs` exercises nested submodules only in "a nested submodule commit no ref holds blocks the fast-forward", which asserts the *refusal*. No test asserts that a nested submodule is moved to a recorded commit after a selector move. > > **Proposed correction.** Say what is and is not in scope, without claiming an action: "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; a selector inside a nested `.gitmodules` is ignored, and a submodule nested inside a moved one is left where the last Gitlink sync put it." That keeps the useful scoping fact — nested selectors do not apply — and replaces the false promise with the state the reader will actually find. > > **What would settle it.** The absence of `--recursive` on the `git submodule update --init --checkout` call and the absence of any post-`checkout --detach` recursion in `synchronize_first_party_tracking_submodules`. If the intended behavior is recursion, the defect is in the code rather than the sentence; either way the two disagree. I traced the shell source and read the tests; I did not run the driver against a nested fixture. claim `01M3BGRV25RCM405CYVBKKMHTD` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
@ -18,3 +19,3 @@
A worktree lock is an owner pin that expires seven days after its `locked` file's mtime. `git worktree lock` refuses an already locked worktree, so the mtime dates from the original lock; to renew a pin, unlock and lock again. A future-dated lock counts as young. The driver unlocks an expired lock only after every other gate passes, immediately before removal. It reads the lock once, at the gate: a pin renewed during the containment proof and ignored scan that follow is unlocked anyway. If unlock fails, the outcome is `operational/removal-failed`. If removal then fails, the worktree stays unlocked and the next run evaluates it from scratch. Expiry deliberately overrides pins agents leave behind.
Worktrees containing `.gitmodules` are removed with `--force`, because Git otherwise refuses any worktree with submodules; the repeated status check replaces the check `--force` disables. Submodules get no audit or veto of their own: superproject containment says nothing about a submodule's history or ignored files.
Worktrees containing `.gitmodules` are removed with `--force`, because Git otherwise refuses any worktree with submodules; the repeated status check replaces the check `--force` disables. Removal also deletes each submodule's Git directory, local branches and stash included, so right before the status check the driver walks every populated submodule at any depth. A submodule keeps the worktree (`judgment/submodule-local-work`, paths in `removal.error`) when it has uncommitted files, a stash, a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds. A Gitlink names a commit without keeping it, so a recorded HEAD needs a remote-tracking ref too. Ignored files inside submodules are not scanned.

low — "right before the status check" is ambiguous in a document that defines two status-check gates, and points at the wrong one
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The "What removal requires" gate list and the .gitmodules paragraph below it in skills/audit-git-checkouts/references/removal-gates.md, and the gate sequence in remove_worktree in skills/audit-git-checkouts/scripts/audit-checkouts.sh.

What the subject says. The gate list opens with "Every gate is checked live, in this order, and the first failure becomes the worktree's removal.outcome", then names two distinct status-check gates: the third bullet, "git status --porcelain --untracked-files=normal succeeds, prints nothing, and writes no warning to stderr", and the last, "A second status check and an unchanged HEAD, immediately before git worktree remove". The new submodule bullet sits between the ignored-content scan and that second check. The prose paragraph below then locates the walk with a bare definite article: "so right before the status check the driver walks every populated submodule at any depth."

What goes wrong. With two status checks defined a few lines above and no ordinal here, the phrase does not identify which one, and the nearer antecedent in reading order is the first. That places the submodule walk fourth in the sequence instead of eighth. Order is load-bearing in this document by its own statement: the first failing gate is the outcome the user sees and the reason the report gives. An agent that believes the walk runs before the cleanliness gate will expect judgment/submodule-local-work on a worktree that is also dirty or locked, and will report the wrong blocker when the actual outcome is expected/dirty-working-tree or judgment/worktree-locked. In the driver, the list_submodules_with_local_work "$worktree_path" removal call is inserted after the ignored-content scan and immediately before the block that writes final-status.error, confirming the second check is meant.

Proposed correction. Name the gate the list already named: "so immediately before the second status check the driver walks every populated submodule at any depth." One word, and the paragraph then agrees with the ordered list it annotates.

What would settle it. The placement of the list_submodules_with_local_work … removal call relative to live_status_error_path="$work_path/final-status.error" in remove_worktree, which I read in the subject tree. I traced the shell source; I did not run the driver.

claim 01M3BGW03X9ZGQ1QSM494RBYF5 of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BGW03X9ZGQ1QSM494RBYF5 --> **low** — "right before the status check" is ambiguous in a document that defines two status-check gates, and points at the wrong one lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The "What removal requires" gate list and the `.gitmodules` paragraph below it in `skills/audit-git-checkouts/references/removal-gates.md`, and the gate sequence in `remove_worktree` in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`. > > **What the subject says.** The gate list opens with "Every gate is checked live, **in this order**, and the first failure becomes the worktree's `removal.outcome`", then names two distinct status-check gates: the third bullet, "`git status --porcelain --untracked-files=normal` succeeds, prints nothing, and writes no warning to stderr", and the last, "A second status check and an unchanged HEAD, immediately before `git worktree remove`". The new submodule bullet sits between the ignored-content scan and that second check. The prose paragraph below then locates the walk with a bare definite article: "so right before **the** status check the driver walks every populated submodule at any depth." > > **What goes wrong.** With two status checks defined a few lines above and no ordinal here, the phrase does not identify which one, and the nearer antecedent in reading order is the first. That places the submodule walk fourth in the sequence instead of eighth. Order is load-bearing in this document by its own statement: the first failing gate is the outcome the user sees and the reason the report gives. An agent that believes the walk runs before the cleanliness gate will expect `judgment/submodule-local-work` on a worktree that is also dirty or locked, and will report the wrong blocker when the actual outcome is `expected/dirty-working-tree` or `judgment/worktree-locked`. In the driver, the `list_submodules_with_local_work "$worktree_path" removal` call is inserted after the ignored-content scan and immediately before the block that writes `final-status.error`, confirming the second check is meant. > > **Proposed correction.** Name the gate the list already named: "so immediately before the second status check the driver walks every populated submodule at any depth." One word, and the paragraph then agrees with the ordered list it annotates. > > **What would settle it.** The placement of the `list_submodules_with_local_work … removal` call relative to `live_status_error_path="$work_path/final-status.error"` in `remove_worktree`, which I read in the subject tree. I traced the shell source; I did not run the driver. claim `01M3BGW03X9ZGQ1QSM494RBYF5` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
Lines 27-30
@ -26,1 +25,6 @@
echo " third-party/ paths; removal of in-root linked worktrees that pass every removal gate."
echo " submodule init and checkout at recorded Gitlinks after a fast-forward; branch/tag"
echo " submodule selectors advanced outside third-party/ paths; removal of in-root linked"
echo " worktrees that pass every removal gate. A checkout with a submodule, at any depth, on a"
echo " commit neither recorded nor held by a ref gets no fast-forward or selector move; a local"
echo " selector tag is replaced only when the fetched tag or another ref keeps its commit; a"
echo " worktree whose submodules hold work no remote-tracking ref keeps is not removed."

medium — --help's "Writes in a default run" section now ends with three refusals, one of them a garden-path clause
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The usage function in skills/audit-git-checkouts/scripts/audit-checkouts.sh, in full, and the instruction in skills/audit-git-checkouts/SKILL.md that says "Run scripts/audit-checkouts.sh --help from this skill's directory before first use," which makes this text the agent's first briefing on the driver.

What the subject says. The section is introduced as Writes in a default run, per repository: and then lists writes as semicolon-separated fragments — git fetch --prune origin, git worktree prune, merge --ff-only ..., submodule init and checkout at recorded Gitlinks after a fast-forward, branch/tag submodule selectors advanced outside third-party/ paths, removal of in-root linked worktrees that pass every removal gate. The change appends three full sentences' worth of material to that same list, all of which describe things the run does not write: a checkout "gets no fast-forward or selector move"; a tag "is replaced only when ..."; a worktree "is not removed."

What goes wrong. Two separate costs to the reader.

First, the heading now lies about its contents. An agent skimming for "what will this command change?" — the exact question --help is consulted for — reads a list of writes and hits three non-writes with no boundary marker between them. The writing standard's "Group by concept" and "Make the decisive constraint prominent" both call for the refusals to sit under their own label rather than be tacked onto the writes list.

Second, the last clause is a garden path: a worktree whose submodules hold work no remote-tracking ref keeps is not removed. The reader parses hold work as the main verb phrase, then meets the reduced relative no remote-tracking ref keeps, then a second finite verb is not removed with no punctuation to mark the boundary. Three finite verbs stack before the sentence resolves. The line after it, Branch refs and stashes are never deleted., is a single short clause by comparison, which is what the rest of this help text reads like.

Proposed correction. Split the refusals out under their own lead-in and give each its own line, so the writes list stays a writes list:

echo "Refuses to act, per repository:"
echo "  no fast-forward and no selector move for a checkout whose submodule, at any depth, sits"
echo "  on a commit that is neither recorded nor reachable from a ref;"
echo "  no tag replacement unless the fetched tag or another ref still reaches the local tag's"
echo "  commit;"
echo "  no removal of a worktree whose submodules hold commits, stashes, or edits that no"
echo "  remote-tracking ref reaches."

That preserves every fact the new text added — the three refusal conditions and their scope — while restoring the one-clause-per-line rhythm of the surrounding help and eliminating the stacked reduced relative.

What would settle it. Running scripts/audit-checkouts.sh --help and reading the rendered paragraph; the structural mismatch is visible in the source lines quoted in the anchor. I read the source rather than executing it.

claim 01M3BGQ09SVGZABQEBS1M2V004 of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BGQ09SVGZABQEBS1M2V004 --> **medium** — `--help`'s "Writes in a default run" section now ends with three refusals, one of them a garden-path clause lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The `usage` function in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, in full, and the instruction in `skills/audit-git-checkouts/SKILL.md` that says "Run `scripts/audit-checkouts.sh --help` from this skill's directory before first use," which makes this text the agent's first briefing on the driver. > > **What the subject says.** The section is introduced as `Writes in a default run, per repository:` and then lists writes as semicolon-separated fragments — `git fetch --prune origin`, `git worktree prune`, `merge --ff-only ...`, `submodule init and checkout at recorded Gitlinks after a fast-forward`, `branch/tag submodule selectors advanced outside third-party/ paths`, `removal of in-root linked worktrees that pass every removal gate`. The change appends three full sentences' worth of material to that same list, all of which describe things the run does **not** write: a checkout "gets no fast-forward or selector move"; a tag "is replaced only when ..."; a worktree "is not removed." > > **What goes wrong.** Two separate costs to the reader. > > First, the heading now lies about its contents. An agent skimming for "what will this command change?" — the exact question `--help` is consulted for — reads a list of writes and hits three non-writes with no boundary marker between them. The writing standard's "Group by concept" and "Make the decisive constraint prominent" both call for the refusals to sit under their own label rather than be tacked onto the writes list. > > Second, the last clause is a garden path: `a worktree whose submodules hold work no remote-tracking ref keeps is not removed`. The reader parses `hold work` as the main verb phrase, then meets the reduced relative `no remote-tracking ref keeps`, then a second finite verb `is not removed` with no punctuation to mark the boundary. Three finite verbs stack before the sentence resolves. The line after it, `Branch refs and stashes are never deleted.`, is a single short clause by comparison, which is what the rest of this help text reads like. > > **Proposed correction.** Split the refusals out under their own lead-in and give each its own line, so the writes list stays a writes list: > > ``` > echo "Refuses to act, per repository:" > echo " no fast-forward and no selector move for a checkout whose submodule, at any depth, sits" > echo " on a commit that is neither recorded nor reachable from a ref;" > echo " no tag replacement unless the fetched tag or another ref still reaches the local tag's" > echo " commit;" > echo " no removal of a worktree whose submodules hold commits, stashes, or edits that no" > echo " remote-tracking ref reaches." > ``` > > That preserves every fact the new text added — the three refusal conditions and their scope — while restoring the one-clause-per-line rhythm of the surrounding help and eliminating the stacked reduced relative. > > **What would settle it.** Running `scripts/audit-checkouts.sh --help` and reading the rendered paragraph; the structural mismatch is visible in the source lines quoted in the anchor. I read the source rather than executing it. claim `01M3BGQ09SVGZABQEBS1M2V004` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
@ -478,0 +500,4 @@
gitlink_paths=$({
git -C "$superproject_path" ls-files --stage -z
git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true
} | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1

low — The || return 1 guard on the Gitlink enumeration can never fire, so an unreadable index silently degrades the submodule walk to HEAD-only
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: list_submodules_with_local_work in skills/audit-git-checkouts/scripts/audit-checkouts.sh, the set -o pipefail at the top of the same file (line 3, there is no set -e), and the function's own comments.

What the subject does. The walk enumerates Gitlinks from both the index and HEAD, and guards the enumeration:

gitlink_paths=$({
    git -C "$superproject_path" ls-files --stage -z
    git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true
  } | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1

The guard cannot fire for a failure of git ls-files. A brace group's exit status is that of its last command, which here is git ls-tree ... || true — unconditionally 0. pipefail then sees 0 from the group and 0 from tr, awk, and sort, so the pipeline exits 0 no matter what ls-files did.

Observed (run here, git 2.47.3, bash 5.2.37). In a repository whose index I truncated to GARBAGE:

$ git ls-files --stage -z >/dev/null; echo $?
fatal: .git/index: index file smaller than expected
128
$ out=$({ git ls-files --stage -z
          git ls-tree -r -z --full-tree HEAD 2>/dev/null || true
        } | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u); echo $?
fatal: .git/index: index file smaller than expected
0            # with pipefail on as well as off

What goes wrong: the enumeration silently falls back to HEAD-only, which contradicts the intent stated three lines above it — "Submodules come from the Gitlinks in HEAD and the index, not from .gitmodules, so neither a deleted .gitmodules nor an ignore setting hides one" — and the fail-closed rule the function applies to the very next query: "A submodule the walk cannot read may hold anything; fail rather than report it empty." Any Gitlink present in the index but not in HEAD (a submodule whose path was staged or moved without a commit) is dropped from the walk at any depth, and neither the update-mode caller (strandedSubmodules) nor the removal-mode caller (judgment/submodule-local-work) learns that the enumeration was incomplete; the ls-files stderr is the only trace, and it is sent to /dev/null on the update path.

Scope, stated honestly: I could not construct a case where this alone destroys work, because an index-only Gitlink also makes the superproject dirty, and a dirty worktree is stopped earlier by expected/dirty-working-tree and by the fast-forward's cleanliness gate. The concrete cost is therefore a dead error guard and a walk whose completeness the callers cannot trust — worth fixing on its own terms, and it compounds with the separately reported fail-open on the submodule status query, which is the same unreadable-index condition reaching a destructive path.

Safe correction: run the two enumerations into separate captures (or a temporary file) and check each exit status, e.g. index_entries=$(git -C "$superproject_path" ls-files --stage -z) || return 1, then combine; or keep the pipeline but test ${PIPESTATUS[@]} of an explicitly ordered sequence.

Evidence status: the mechanism and the exit status are observed above; the absence of a work-destroying consequence from this defect in isolation is a reasoned conclusion from reading the surrounding gates, not something I proved exhaustively. Both repository suites pass unmodified (node --test audit-checkouts.test.mjs: 63 pass / 1 skipped / 0 fail; node --test --experimental-strip-types render-audit-report.test.ts: 17 pass); no test exercises an unreadable index at this line.

claim 01M3BHE917P1HSFWK0RCQYEHN1 of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BHE917P1HSFWK0RCQYEHN1 --> **low** — The `|| return 1` guard on the Gitlink enumeration can never fire, so an unreadable index silently degrades the submodule walk to HEAD-only lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: `list_submodules_with_local_work` in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, the `set -o pipefail` at the top of the same file (line 3, there is no `set -e`), and the function's own comments. > > What the subject does. The walk enumerates Gitlinks from both the index and HEAD, and guards the enumeration: > > gitlink_paths=$({ > git -C "$superproject_path" ls-files --stage -z > git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true > } | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1 > > The guard cannot fire for a failure of `git ls-files`. A brace group's exit status is that of its last command, which here is `git ls-tree ... || true` — unconditionally 0. `pipefail` then sees 0 from the group and 0 from `tr`, `awk`, and `sort`, so the pipeline exits 0 no matter what `ls-files` did. > > Observed (run here, git 2.47.3, bash 5.2.37). In a repository whose index I truncated to `GARBAGE`: > > $ git ls-files --stage -z >/dev/null; echo $? > fatal: .git/index: index file smaller than expected > 128 > $ out=$({ git ls-files --stage -z > git ls-tree -r -z --full-tree HEAD 2>/dev/null || true > } | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u); echo $? > fatal: .git/index: index file smaller than expected > 0 # with pipefail on as well as off > > What goes wrong: the enumeration silently falls back to HEAD-only, which contradicts the intent stated three lines above it — "Submodules come from the Gitlinks in HEAD and the index, not from .gitmodules, so neither a deleted `.gitmodules` nor an `ignore` setting hides one" — and the fail-closed rule the function applies to the very next query: "A submodule the walk cannot read may hold anything; fail rather than report it empty." Any Gitlink present in the index but not in HEAD (a submodule whose path was staged or moved without a commit) is dropped from the walk at any depth, and neither the update-mode caller (`strandedSubmodules`) nor the removal-mode caller (`judgment/submodule-local-work`) learns that the enumeration was incomplete; the `ls-files` stderr is the only trace, and it is sent to `/dev/null` on the update path. > > Scope, stated honestly: I could not construct a case where this alone destroys work, because an index-only Gitlink also makes the superproject dirty, and a dirty worktree is stopped earlier by `expected/dirty-working-tree` and by the fast-forward's cleanliness gate. The concrete cost is therefore a dead error guard and a walk whose completeness the callers cannot trust — worth fixing on its own terms, and it compounds with the separately reported fail-open on the submodule `status` query, which is the same unreadable-index condition reaching a destructive path. > > Safe correction: run the two enumerations into separate captures (or a temporary file) and check each exit status, e.g. `index_entries=$(git -C "$superproject_path" ls-files --stage -z) || return 1`, then combine; or keep the pipeline but test `${PIPESTATUS[@]}` of an explicitly ordered sequence. > > Evidence status: the mechanism and the exit status are observed above; the absence of a work-destroying consequence from this defect in isolation is a reasoned conclusion from reading the surrounding gates, not something I proved exhaustively. Both repository suites pass unmodified (`node --test audit-checkouts.test.mjs`: 63 pass / 1 skipped / 0 fail; `node --test --experimental-strip-types render-audit-report.test.ts`: 17 pass); no test exercises an unreadable index at this line. claim `01M3BHE917P1HSFWK0RCQYEHN1` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
@ -478,0 +530,4 @@
return
fi
[ -z "$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head" refs/remotes)" ] && return 0
[ -n "$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all)" ] && return 0

high — submodule_holds_local_work judges a submodule clean when its git status fails, so a forced worktree removal silently deletes uncommitted submodule work
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new submodule_holds_local_work in skills/audit-git-checkouts/scripts/audit-checkouts.sh, its caller list_submodules_with_local_work, the judgment/submodule-local-work gate in decide_removal_outcome, the error_json case statement in maybe_remove_worktree, and the superproject status gate ~120 lines above in the same function.

What the subject does. The new removal gate decides whether a submodule holds uncommitted work with a bare emptiness test on stdout:

[ -n "$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all)" ] && return 0

The command's exit status and stderr are both discarded, so a git status that fails with empty stdout reads as "clean". The same shape is used for the next-to-last check, [ -n "$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes)" ], which is also the function's return value. This contradicts the discipline the file states explicitly for the superproject's own status check inside the same function: "Exit-status-first: a failed query's stdout is untrusted, and stderr with a clean stdout is an evidence failure, not dirt. All three routes block removal." That check captures live_status_exit=$?, cats stderr into gate_error_path, and returns operational/gate-check-failed on either signal. The sibling for-each-ref checks in this new function happen to fail closed; status and rev-list fail open.

Observed reproduction, end to end (run here, git 2.47.3, jq 1.7.1). Superproject with a dependency submodule declared ignore = all — the exact configuration the new gate exists to cover, and the one the new test fixture uses — plus a linked worktree feature whose HEAD is contained in refs/remotes/origin/main:

# real uncommitted work in the submodule
$ echo "PRECIOUS UNCOMMITTED WORK" > linked/dependency/f

# healthy index: the gate works
$ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work "$1" removal' _ /tmp/t8/linked
dependency
rc=0

# corrupt only the submodule's index (HEAD and refs stay readable)
$ printf 'GARBAGE-NOT-AN-INDEX' > .../worktrees/linked/modules/dependency/index
$ git -C linked/dependency status --porcelain --untracked-files=normal --ignore-submodules=all
fatal: .../modules/dependency/index: index file smaller than expected
status rc=128                      # stdout empty, exit 128

# the superproject's own status is still clean, because ignore = all
$ git -C /tmp/t8/linked status --porcelain --untracked-files=normal
super status rc=0                  # empty

# the gate now reports nothing
$ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work "$1" removal' _ /tmp/t8/linked
fatal: ... index file smaller than expected
rc=0                               # stdout empty => "no local work"

# and removal proceeds, exactly as the driver runs it
$ bash -c 'source audit-checkouts.sh; script_directory=...; maybe_remove_worktree "$1" "$2" "$3" main true "$4" "$5"' ...
$ jq -c '.removal | {outcome, error}' result.json
{"outcome":"removed","error":null}
worktree still registered? 0
directory exists? NO
submodule gitdir exists? NO

The file containing PRECIOUS UNCOMMITTED WORK is gone, along with the submodule's Git directory under worktrees/linked/modules/dependency (I confirmed separately that a linked worktree's submodule gitdir lives there: linked/dependency/.git reads gitdir: ../../super/.git/worktrees/linked/modules/dependency). removal.error is null and the outcome is removed, so nothing in the report hints that a gate's evidence query failed — the walk's stderr does land in gate_error_path, but maybe_remove_worktree only reads that file for operational/removal-failed, operational/gate-check-failed, and judgment/submodule-local-work.

What goes wrong: unrecoverable, silent loss of the user's uncommitted submodule work on the very path whose stated purpose is to prevent it. references/removal-gates.md promises "A submodule keeps the worktree ... when it has uncommitted files" — with a failing status the driver asserts the opposite. Any condition that makes git status exit non-zero inside a submodule while HEAD and refs stay readable is enough: a corrupt or truncated index (shown above), an unreadable index or object, or a failing core.fsmonitor hook. Because git worktree remove --force is used, Git's own submodule refusal is not there to catch it either — the comment in the code says as much ("--force crosses Git's categorical submodule refusal").

Safe correction: mirror the superproject gate. Capture the exit status and stderr of each submodule query, and make a non-zero exit, or stderr with empty stdout, propagate as a walk failure (return 1 from list_submodules_with_local_work, which decide_removal_outcome already maps to operational/gate-check-failed) rather than as "clean". The same applies to the rev-list --branches --tags --not --remotes check and, in update mode, to for-each-ref.

Evidence status: fully observed, including the end-to-end maybe_remove_worktree run above. Both repository suites pass unmodified (node --test audit-checkouts.test.mjs: 63 pass / 1 skipped / 0 fail; node --test --experimental-strip-types render-audit-report.test.ts: 17 pass) — the new removal test only exercises an unreadable submodule via a broken core.worktree, which makes rev-parse HEAD fail and so hits the deliberate fail-closed branch, never the status query. Severity note: the loss requires a submodule whose git status fails, which is an edge condition rather than normal operation; the loss itself is silent and total.

claim 01M3BHBYWXC3Z8BY113BPM6MZN of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BHBYWXC3Z8BY113BPM6MZN --> **high** — submodule_holds_local_work judges a submodule clean when its `git status` fails, so a forced worktree removal silently deletes uncommitted submodule work lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new `submodule_holds_local_work` in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, its caller `list_submodules_with_local_work`, the `judgment/submodule-local-work` gate in `decide_removal_outcome`, the `error_json` case statement in `maybe_remove_worktree`, and the superproject status gate ~120 lines above in the same function. > > What the subject does. The new removal gate decides whether a submodule holds uncommitted work with a bare emptiness test on stdout: > > [ -n "$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all)" ] && return 0 > > The command's exit status and stderr are both discarded, so a `git status` that fails with empty stdout reads as "clean". The same shape is used for the next-to-last check, `[ -n "$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes)" ]`, which is also the function's return value. This contradicts the discipline the file states explicitly for the superproject's own status check inside the same function: "Exit-status-first: a failed query's stdout is untrusted, and stderr with a clean stdout is an evidence failure, not dirt. All three routes block removal." That check captures `live_status_exit=$?`, cats stderr into `gate_error_path`, and returns `operational/gate-check-failed` on either signal. The sibling `for-each-ref` checks in this new function happen to fail closed; `status` and `rev-list` fail open. > > Observed reproduction, end to end (run here, git 2.47.3, jq 1.7.1). Superproject with a `dependency` submodule declared `ignore = all` — the exact configuration the new gate exists to cover, and the one the new test fixture uses — plus a linked worktree `feature` whose HEAD is contained in `refs/remotes/origin/main`: > > # real uncommitted work in the submodule > $ echo "PRECIOUS UNCOMMITTED WORK" > linked/dependency/f > > # healthy index: the gate works > $ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work "$1" removal' _ /tmp/t8/linked > dependency > rc=0 > > # corrupt only the submodule's index (HEAD and refs stay readable) > $ printf 'GARBAGE-NOT-AN-INDEX' > .../worktrees/linked/modules/dependency/index > $ git -C linked/dependency status --porcelain --untracked-files=normal --ignore-submodules=all > fatal: .../modules/dependency/index: index file smaller than expected > status rc=128 # stdout empty, exit 128 > > # the superproject's own status is still clean, because ignore = all > $ git -C /tmp/t8/linked status --porcelain --untracked-files=normal > super status rc=0 # empty > > # the gate now reports nothing > $ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work "$1" removal' _ /tmp/t8/linked > fatal: ... index file smaller than expected > rc=0 # stdout empty => "no local work" > > # and removal proceeds, exactly as the driver runs it > $ bash -c 'source audit-checkouts.sh; script_directory=...; maybe_remove_worktree "$1" "$2" "$3" main true "$4" "$5"' ... > $ jq -c '.removal | {outcome, error}' result.json > {"outcome":"removed","error":null} > worktree still registered? 0 > directory exists? NO > submodule gitdir exists? NO > > The file containing `PRECIOUS UNCOMMITTED WORK` is gone, along with the submodule's Git directory under `worktrees/linked/modules/dependency` (I confirmed separately that a linked worktree's submodule gitdir lives there: `linked/dependency/.git` reads `gitdir: ../../super/.git/worktrees/linked/modules/dependency`). `removal.error` is `null` and the outcome is `removed`, so nothing in the report hints that a gate's evidence query failed — the walk's stderr does land in `gate_error_path`, but `maybe_remove_worktree` only reads that file for `operational/removal-failed`, `operational/gate-check-failed`, and `judgment/submodule-local-work`. > > What goes wrong: unrecoverable, silent loss of the user's uncommitted submodule work on the very path whose stated purpose is to prevent it. `references/removal-gates.md` promises "A submodule keeps the worktree ... when it has uncommitted files" — with a failing `status` the driver asserts the opposite. Any condition that makes `git status` exit non-zero inside a submodule while HEAD and refs stay readable is enough: a corrupt or truncated index (shown above), an unreadable index or object, or a failing `core.fsmonitor` hook. Because `git worktree remove --force` is used, Git's own submodule refusal is not there to catch it either — the comment in the code says as much ("--force crosses Git's categorical submodule refusal"). > > Safe correction: mirror the superproject gate. Capture the exit status and stderr of each submodule query, and make a non-zero exit, or stderr with empty stdout, propagate as a walk failure (`return 1` from `list_submodules_with_local_work`, which `decide_removal_outcome` already maps to `operational/gate-check-failed`) rather than as "clean". The same applies to the `rev-list --branches --tags --not --remotes` check and, in `update` mode, to `for-each-ref`. > > Evidence status: fully observed, including the end-to-end `maybe_remove_worktree` run above. Both repository suites pass unmodified (`node --test audit-checkouts.test.mjs`: 63 pass / 1 skipped / 0 fail; `node --test --experimental-strip-types render-audit-report.test.ts`: 17 pass) — the new removal test only exercises an unreadable submodule via a broken `core.worktree`, which makes `rev-parse HEAD` fail and so hits the deliberate fail-closed branch, never the `status` query. Severity note: the loss requires a submodule whose `git status` fails, which is an edge condition rather than normal operation; the loss itself is silent and total. claim `01M3BHBYWXC3Z8BY113BPM6MZN` of review `01M3BGFRYK9R81EWETEHWA9X9E`
Author
Owner

Fixed in 0cdf081. Reproduced with a corrupt submodule index and ignore = all: the walk returned nothing. Every submodule query now fails closed. submodule_holds_local_work returns 2 on a failed for-each-ref, status or rev-list, and the walk turns that into cannot inspect submodule <path>, so removal becomes operational/gate-check-failed. The removal test covers a corrupt index with uncommitted work and fails against 7f0da38.

<!-- gh-feedback:reply-to:87929 --> Fixed in 0cdf081. Reproduced with a corrupt submodule index and `ignore = all`: the walk returned nothing. Every submodule query now fails closed. `submodule_holds_local_work` returns 2 on a failed `for-each-ref`, `status` or `rev-list`, and the walk turns that into `cannot inspect submodule <path>`, so removal becomes `operational/gate-check-failed`. The removal test covers a corrupt index with uncommitted work and fails against 7f0da38.
jercik marked this conversation as resolved
@ -478,0 +532,4 @@
[ -z "$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head" refs/remotes)" ] && return 0
[ -n "$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all)" ] && return 0
git -C "$submodule_path" rev-parse --verify --quiet refs/stash >/dev/null && return 0
[ -n "$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes)" ]

medium — Removal gate counts upstream tags inside a submodule as local work, permanently blocking removal of merged worktrees
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new submodule_holds_local_work / list_submodules_with_local_work helpers in skills/audit-git-checkouts/scripts/audit-checkouts.sh, their call site in decide_removal_outcome (the judgment/submodule-local-work gate added just before the final status check), the matching prose in skills/audit-git-checkouts/references/removal-gates.md, and the new test a merged worktree is kept while its submodules hold work that exists only there in audit-checkouts.test.mjs.

What the subject does: in removal mode the last gate is [ -n "$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes)" ] — any commit reachable from a local branch or tag but not from a remote-tracking ref makes the submodule "hold local work". But git submodule update --init clones the submodule, and a clone fetches every upstream tag into refs/tags/*. A tag whose commit is not an ancestor of any remote-tracking branch (a maintenance-release tag whose branch was deleted upstream, an RC tag on an abandoned line) therefore always satisfies this condition, even though the commit is fully published upstream.

Observed reproduction (run here, git 2.47.3). I built a submodule upstream with main plus tag v0.9 on a side commit and deleted the side branch upstream, then a superproject with a dependency Gitlink, a linked worktree feature, and git submodule update --init in the worktree. The submodule is pristine: git -C linked/dependency rev-parse HEAD equals the recorded Gitlink, git status is empty, no stash, no local commits. Its refs after init are exactly refs/heads/main, refs/remotes/origin/HEAD, refs/remotes/origin/main (all the same commit) and refs/tags/v0.9. Then:

$ git -C linked/dependency rev-list -n 1 --branches --tags --not --remotes
0724ce9479cdd544638d00a60007093177dbd1ee      # the upstream v0.9 commit
$ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work "$1" removal' _ /tmp/t5/linked
dependency

So the gate fires and decide_removal_outcome returns judgment/submodule-local-work for a worktree that is clean, merged, and whose submodule contains nothing that is not already on the server.

What goes wrong: removal of that merged worktree is refused on this run and on every future run — the condition is a permanent property of the submodule's upstream tag graph, so no amount of pushing or rerunning clears it. The renderer prints merged; submodules hold work that exists only here: dependency, and references/removal-gates.md tells the operator to "Show the owner what each holds; rerun once it is pushed or they authorize discarding it" — advice the owner cannot act on, because there is nothing unpushed. For any repository whose submodules carry a tag off the live branch lines, the skill's headline function (cleaning up proven-merged worktrees) silently stops working, and the operator is pushed toward manual removal, i.e. toward bypassing the gates entirely.

Why the tests miss it: the removal fixture's submodule repository is created with git init + one commit and no tags, so --tags never contributes. The tagged fixture (createTrackedSubmoduleFixture, which does git tag -a v1) is only driven through audit_worktree, never through maybe_remove_worktree. I ran both suites after installing jq — node --test audit-checkouts.test.mjs (63 pass / 1 skipped / 0 fail) and node --test --experimental-strip-types render-audit-report.test.ts (17 pass) — so this is a coverage gap, not a failing test.

A safe correction: the gate's own stated rationale is "only what the remote keeps survives the worktree's deletion". A tag that the remote also has does survive, so tags need to be compared against the remote (e.g. git ls-remote --tags origin, or fetching tags into a dedicated tracking namespace) rather than against refs/remotes/* reachability; alternatively restrict the reachability test to --branches and handle tags separately. Unresolved proof gap: I could not verify whether the authors intend a submodule tag that is merely identical to an upstream tag to count as local work — the prose in removal-gates.md ("work only this worktree has", "rerun once it is pushed") reads as though it should not, which is what makes this a defect rather than a decided policy.

claim 01M3BH2BM6K1SSQ81M73VNE1GD of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BH2BM6K1SSQ81M73VNE1GD --> **medium** — Removal gate counts upstream tags inside a submodule as local work, permanently blocking removal of merged worktrees lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new `submodule_holds_local_work` / `list_submodules_with_local_work` helpers in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, their call site in `decide_removal_outcome` (the `judgment/submodule-local-work` gate added just before the final status check), the matching prose in `skills/audit-git-checkouts/references/removal-gates.md`, and the new test `a merged worktree is kept while its submodules hold work that exists only there` in `audit-checkouts.test.mjs`. > > What the subject does: in `removal` mode the last gate is `[ -n "$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes)" ]` — any commit reachable from a local branch *or tag* but not from a remote-tracking ref makes the submodule "hold local work". But `git submodule update --init` clones the submodule, and a clone fetches **every upstream tag** into `refs/tags/*`. A tag whose commit is not an ancestor of any remote-tracking branch (a maintenance-release tag whose branch was deleted upstream, an RC tag on an abandoned line) therefore always satisfies this condition, even though the commit is fully published upstream. > > Observed reproduction (run here, git 2.47.3). I built a submodule upstream with `main` plus tag `v0.9` on a side commit and deleted the side branch upstream, then a superproject with a `dependency` Gitlink, a linked worktree `feature`, and `git submodule update --init` in the worktree. The submodule is pristine: `git -C linked/dependency rev-parse HEAD` equals the recorded Gitlink, `git status` is empty, no stash, no local commits. Its refs after init are exactly `refs/heads/main`, `refs/remotes/origin/HEAD`, `refs/remotes/origin/main` (all the same commit) and `refs/tags/v0.9`. Then: > > $ git -C linked/dependency rev-list -n 1 --branches --tags --not --remotes > 0724ce9479cdd544638d00a60007093177dbd1ee # the upstream v0.9 commit > $ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work "$1" removal' _ /tmp/t5/linked > dependency > > So the gate fires and `decide_removal_outcome` returns `judgment/submodule-local-work` for a worktree that is clean, merged, and whose submodule contains nothing that is not already on the server. > > What goes wrong: removal of that merged worktree is refused on this run and on every future run — the condition is a permanent property of the submodule's upstream tag graph, so no amount of pushing or rerunning clears it. The renderer prints `merged; submodules hold work that exists only here: dependency`, and `references/removal-gates.md` tells the operator to "Show the owner what each holds; rerun once it is pushed or they authorize discarding it" — advice the owner cannot act on, because there is nothing unpushed. For any repository whose submodules carry a tag off the live branch lines, the skill's headline function (cleaning up proven-merged worktrees) silently stops working, and the operator is pushed toward manual removal, i.e. toward bypassing the gates entirely. > > Why the tests miss it: the removal fixture's submodule repository is created with `git init` + one commit and no tags, so `--tags` never contributes. The tagged fixture (`createTrackedSubmoduleFixture`, which does `git tag -a v1`) is only driven through `audit_worktree`, never through `maybe_remove_worktree`. I ran both suites after installing `jq` — `node --test audit-checkouts.test.mjs` (63 pass / 1 skipped / 0 fail) and `node --test --experimental-strip-types render-audit-report.test.ts` (17 pass) — so this is a coverage gap, not a failing test. > > A safe correction: the gate's own stated rationale is "only what the remote keeps survives the worktree's deletion". A tag that the remote also has does survive, so tags need to be compared against the remote (e.g. `git ls-remote --tags origin`, or fetching tags into a dedicated tracking namespace) rather than against `refs/remotes/*` reachability; alternatively restrict the reachability test to `--branches` and handle tags separately. Unresolved proof gap: I could not verify whether the authors intend a submodule tag that is merely *identical* to an upstream tag to count as local work — the prose in `removal-gates.md` ("work only this worktree has", "rerun once it is pushed") reads as though it should not, which is what makes this a defect rather than a decided policy. claim `01M3BH2BM6K1SSQ81M73VNE1GD` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
@ -1415,2 +1527,4 @@
submodule_metadata_ok=$(jq -r '.ok' "$submodule_metadata_path")
if [ "$outside_root" = false ]; then
if stranded_output=$(list_submodules_with_local_work "$worktree_path" update 2>/dev/null); then

medium — The update-mode submodule walk discards its only diagnostic, so a checkout whose submodule cannot be read stops being updated with no reportable reason
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new list_submodules_with_local_work walk and its two call sites in skills/audit-git-checkouts/scripts/audit-checkouts.sh (audit_worktree for update mode, decide_removal_outcome for removal mode), the jq -n block in audit_worktree that assembles the per-worktree record, stepFailure in render-audit-report.ts, and references/checkout-updates.md.

What the subject does. The walk deliberately fails closed when a submodule cannot be read:

# A submodule the walk cannot read may hold anything; fail rather than report it empty.
if ! head=$(git -C "$submodule_path" rev-parse --verify --quiet HEAD); then
  printf 'cannot read submodule %s\n' "$prefix$gitlink_path" >&2
  return 1
fi

That message — naming the offending submodule — is the walk's only explanation, and it goes to stderr. The removal call site keeps it (list_submodules_with_local_work "$worktree_path" removal 2>>"$gate_error_path", surfaced later as removal.error via read_error_json). The update call site throws it away with 2>/dev/null, sets stranded_submodules=null, and the record's jq -n block emits strandedSubmodules: $strandedSubmodules with no companion error field — there is no other place in the record where the reason could land.

Consequence, traced through the code: stranded_submodules=null fails both [ "$stranded_submodules" = '[]' ] guards, so the fast-forward gate and the tracking_update_eligible gate are both false — the checkout gets no fast-forward and no selector move. The renderer then produces, from stepFailure, the fixed string submodule check failed; checkout not updated, with no path and no cause. SKILL.md instructs the agent to "Read report.md. Reply with its Summary section, the failures if any, and the path to report.md", so that string is the whole of what the operator sees. The condition is not self-healing: every subsequent run repeats it.

Observed reproduction (run here, git 2.47.3). Superproject with a dependency Gitlink, git submodule update --init, then mv .git/modules/dependency .git/modules/dependency.moved (the state left by cleaning .git/modules, or by copying a checkout without it):

$ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work "$1" update' _ /tmp/t7/super
fatal: not a git repository: /tmp/t7/super/dependency/../.git/modules/dependency
cannot read submodule dependency
rc=1
# and as audit_worktree actually calls it:
$ ... 2>/dev/null
rc=1, stdout empty   -> strandedSubmodules=null, nothing recorded about why

What goes wrong: the driver knows exactly which submodule is unreadable and why, and then discards it, leaving an operator with a checkout that has silently stopped being fast-forwarded and a report line that names neither the submodule nor the failure. This also contradicts the adjacent promise in references/checkout-updates.md for the sibling case — "the report lists it under 'Needs your decision' with the submodule's path" — which holds when the walk succeeds and is impossible when it fails. The asymmetry with the removal call site, which appends the same stderr to gate_error_path precisely so it reaches the report, indicates an oversight rather than a decision.

Safe correction: give audit_worktree an error file for this walk (as it already has fast_forward_error_path, submodule_update_error_path, tracking_update_error_path), redirect the walk's stderr into it, and carry it in the record (e.g. a strandedSubmodulesError field) so stepFailure can append firstLine(...) to its message.

Evidence status: the mechanism is traced through the shell and the renderer, and the walk's failure and the loss of its message are observed above. I did not run the whole driver end to end for this case (audit-checkouts.sh resolves repoq@latest through npx, which is unavailable in this sandbox), so the exact rendered row is derived from stepFailure by reading rather than observed. Both repository suites pass as-is (node --test audit-checkouts.test.mjs: 63 pass / 1 skipped / 0 fail; node --test --experimental-strip-types render-audit-report.test.ts: 17 pass) — no test covers an unreadable submodule on the update path, only on the removal path (cannot read submodule dependency is asserted there).

claim 01M3BH809Z5NDAKJ8VPKSNTS1Q of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BH809Z5NDAKJ8VPKSNTS1Q --> **medium** — The update-mode submodule walk discards its only diagnostic, so a checkout whose submodule cannot be read stops being updated with no reportable reason lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new `list_submodules_with_local_work` walk and its two call sites in `skills/audit-git-checkouts/scripts/audit-checkouts.sh` (`audit_worktree` for `update` mode, `decide_removal_outcome` for `removal` mode), the `jq -n` block in `audit_worktree` that assembles the per-worktree record, `stepFailure` in `render-audit-report.ts`, and `references/checkout-updates.md`. > > What the subject does. The walk deliberately fails closed when a submodule cannot be read: > > # A submodule the walk cannot read may hold anything; fail rather than report it empty. > if ! head=$(git -C "$submodule_path" rev-parse --verify --quiet HEAD); then > printf 'cannot read submodule %s\n' "$prefix$gitlink_path" >&2 > return 1 > fi > > That message — naming the offending submodule — is the walk's only explanation, and it goes to stderr. The `removal` call site keeps it (`list_submodules_with_local_work "$worktree_path" removal 2>>"$gate_error_path"`, surfaced later as `removal.error` via `read_error_json`). The `update` call site throws it away with `2>/dev/null`, sets `stranded_submodules=null`, and the record's `jq -n` block emits `strandedSubmodules: $strandedSubmodules` with no companion error field — there is no other place in the record where the reason could land. > > Consequence, traced through the code: `stranded_submodules=null` fails both `[ "$stranded_submodules" = '[]' ]` guards, so the fast-forward gate and the `tracking_update_eligible` gate are both false — the checkout gets no fast-forward and no selector move. The renderer then produces, from `stepFailure`, the fixed string `submodule check failed; checkout not updated`, with no path and no cause. `SKILL.md` instructs the agent to "Read `report.md`. Reply with its Summary section, the failures if any, and the path to `report.md`", so that string is the whole of what the operator sees. The condition is not self-healing: every subsequent run repeats it. > > Observed reproduction (run here, git 2.47.3). Superproject with a `dependency` Gitlink, `git submodule update --init`, then `mv .git/modules/dependency .git/modules/dependency.moved` (the state left by cleaning `.git/modules`, or by copying a checkout without it): > > $ bash -c 'source audit-checkouts.sh; list_submodules_with_local_work "$1" update' _ /tmp/t7/super > fatal: not a git repository: /tmp/t7/super/dependency/../.git/modules/dependency > cannot read submodule dependency > rc=1 > # and as audit_worktree actually calls it: > $ ... 2>/dev/null > rc=1, stdout empty -> strandedSubmodules=null, nothing recorded about why > > What goes wrong: the driver knows exactly which submodule is unreadable and why, and then discards it, leaving an operator with a checkout that has silently stopped being fast-forwarded and a report line that names neither the submodule nor the failure. This also contradicts the adjacent promise in `references/checkout-updates.md` for the sibling case — "the report lists it under 'Needs your decision' with the submodule's path" — which holds when the walk succeeds and is impossible when it fails. The asymmetry with the `removal` call site, which appends the same stderr to `gate_error_path` precisely so it reaches the report, indicates an oversight rather than a decision. > > Safe correction: give `audit_worktree` an error file for this walk (as it already has `fast_forward_error_path`, `submodule_update_error_path`, `tracking_update_error_path`), redirect the walk's stderr into it, and carry it in the record (e.g. a `strandedSubmodulesError` field) so `stepFailure` can append `firstLine(...)` to its message. > > Evidence status: the mechanism is traced through the shell and the renderer, and the walk's failure and the loss of its message are observed above. I did not run the whole driver end to end for this case (`audit-checkouts.sh` resolves `repoq@latest` through npx, which is unavailable in this sandbox), so the exact rendered row is derived from `stepFailure` by reading rather than observed. Both repository suites pass as-is (`node --test audit-checkouts.test.mjs`: 63 pass / 1 skipped / 0 fail; `node --test --experimental-strip-types render-audit-report.test.ts`: 17 pass) — no test covers an unreadable submodule on the update path, only on the removal path (`cannot read submodule dependency` is asserted there). claim `01M3BH809Z5NDAKJ8VPKSNTS1Q` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
@ -312,1 +320,4 @@
const onDefault = worktree.status?.branch.isDetached === false && worktree.status.branch.current === defaultBranch;
const stranded = worktree.strandedSubmodules ?? [];
if ((isMain || onDefault) && stranded.length > 0) {
const why = `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated`;

low — Report cell says "submodule on a commit no ref holds" in the singular while printing a comma-separated list of submodules
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined. The "Needs your decision" branch added to renderReport in skills/audit-git-checkouts/scripts/render-audit-report.ts, the listPaths helper in the same file, the neighbouring reason strings, and the producer of the data — list_submodules_with_local_work in skills/audit-git-checkouts/scripts/audit-checkouts.sh.

What the subject does. The new cell is built as `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated`. stranded is worktree.strandedSubmodules, a JSON array; listPaths renders it as paths.slice(0, 5).join(", ") with a (+N more) suffix, so it is explicitly designed for multiple entries. On the shell side, list_submodules_with_local_work loops over every Gitlink in HEAD and the index and recurses into each submodule with a path prefix, printing one line per stranded submodule — so two sibling submodules, or one stranded submodule containing a stranded nested one, both yield two entries. The rendered cell then reads "submodule on a commit no ref holds: dependency, dependency/inner; not updated": a singular head noun introducing a list.

Why it matters here. SKILL.md instructs the agent to "Use the report's plain wording" when reporting to the user and to work "Needs your decision" first, item by item, so this string is quoted to a human rather than read by the agent alone. A singular label in front of a list makes the reader parse the second path as something other than a second submodule — plausibly as a sub-path or an elaboration of the first — which is exactly the wrong impression when each entry is a separate checkout the user must inspect. Every sibling string in this file already avoids the problem: KEPT_REASON_BY_OUTCOME gives the corresponding removal outcome as "submodules hold work that exists only here", formatNotes writes "ignored files only here: …", and keptReason writes "ignored files not found in the primary checkout: …". This one line is the outlier.

Proposed correction. Match the plural-neutral form the rest of the file uses: `submodules on a commit no ref holds: ${listPaths(stranded)}; not updated`, or, if the singular reads better for the common one-entry case, `submodule commits no ref holds: ${listPaths(stranded)}; not updated`. Either preserves the two facts the cell carries — which submodules are stranded, and that the checkout was left alone — while making the list read as a list.

What would settle it. Rendering a report for a checkout with two stranded submodules and reading the cell. I read the renderer and the shell producer and confirmed the multi-entry path exists; I did not execute the renderer against such a fixture.

claim 01M3BGTKESPB052Q5S1Y8DMECT of review 01M3BGFRYK9R81EWETEHWA9X9E

<!-- review:claim:01M3BGTKESPB052Q5S1Y8DMECT --> **low** — Report cell says "submodule on a commit no ref holds" in the singular while printing a comma-separated list of submodules lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > **What I examined.** The "Needs your decision" branch added to `renderReport` in `skills/audit-git-checkouts/scripts/render-audit-report.ts`, the `listPaths` helper in the same file, the neighbouring reason strings, and the producer of the data — `list_submodules_with_local_work` in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`. > > **What the subject does.** The new cell is built as `` `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated` ``. `stranded` is `worktree.strandedSubmodules`, a JSON array; `listPaths` renders it as `paths.slice(0, 5).join(", ")` with a `(+N more)` suffix, so it is explicitly designed for multiple entries. On the shell side, `list_submodules_with_local_work` loops over every Gitlink in HEAD and the index and recurses into each submodule with a path prefix, printing one line per stranded submodule — so two sibling submodules, or one stranded submodule containing a stranded nested one, both yield two entries. The rendered cell then reads "submodule on a commit no ref holds: dependency, dependency/inner; not updated": a singular head noun introducing a list. > > **Why it matters here.** `SKILL.md` instructs the agent to "Use the report's plain wording" when reporting to the user and to work "Needs your decision" first, item by item, so this string is quoted to a human rather than read by the agent alone. A singular label in front of a list makes the reader parse the second path as something other than a second submodule — plausibly as a sub-path or an elaboration of the first — which is exactly the wrong impression when each entry is a separate checkout the user must inspect. Every sibling string in this file already avoids the problem: `KEPT_REASON_BY_OUTCOME` gives the corresponding removal outcome as "submodules hold work that exists only here", `formatNotes` writes "ignored files only here: …", and `keptReason` writes "ignored files not found in the primary checkout: …". This one line is the outlier. > > **Proposed correction.** Match the plural-neutral form the rest of the file uses: `` `submodules on a commit no ref holds: ${listPaths(stranded)}; not updated` ``, or, if the singular reads better for the common one-entry case, `` `submodule commits no ref holds: ${listPaths(stranded)}; not updated` ``. Either preserves the two facts the cell carries — which submodules are stranded, and that the checkout was left alone — while making the list read as a list. > > **What would settle it.** Rendering a report for a checkout with two stranded submodules and reading the cell. I read the renderer and the shell producer and confirmed the multi-entry path exists; I did not execute the renderer against such a fixture. claim `01M3BGTKESPB052Q5S1Y8DMECT` of review `01M3BGFRYK9R81EWETEHWA9X9E`
jercik marked this conversation as resolved
fix(audit-git-checkouts): a failed submodule query should stop removal
All checks were successful
commit-msg / commitlint (pull_request) Successful in 18s
Node tests / node:test (pull_request) Successful in 1m7s
Review / Review (pull_request_target) Successful in 29m15s
0cdf0815fc
The removal gate read each submodule query's empty output as "no work", so
a submodule whose `git status` failed, for example on a corrupt index,
passed as clean. With `ignore = all` the superproject's status stayed
silent too, and the worktree was removed with its uncommitted submodule
files. Every submodule query now fails closed, and removal becomes
`operational/gate-check-failed`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Replying to review summary comment #87417

Round-6 outcomes for review 01M3BGFRYK9R81EWETEHWA9X9E (head 7f0da38). From round 6 only data loss, data corruption, or a security hole gets a new push.

Fixed in 0cdf081:

  • high, a failed submodule git status reads as clean (87929): agreed; it is data loss. See the inline thread.

Real bug left unfixed (87934, medium). The removal gate counts tags inside a submodule as local work unless a remote-tracking branch holds their commit. An upstream tag on a commit that no remote branch contains therefore keeps a merged worktree forever. It errs toward keeping work, so nothing is lost, but such worktrees need manual removal until the follow-up lands. Fix, in scripts/audit-checkouts.sh submodule_holds_local_work: keep --branches in the reachability test, and count a local tag only when git ls-remote --tags origin lacks it or names a different object.

Acknowledged and deferred to a follow-up PR:

  • 87935, medium: the update walk discards its stderr. In audit_worktree, redirect the walk's stderr to a stranded-submodules.error file and carry it as strandedSubmodulesError. stepFailure should append its first line.
  • 87939, low: an ls-files failure is swallowed. In list_submodules_with_local_work, capture ls-files --stage -z separately with || return 1 before combining it with ls-tree. An unreadable superproject index already fails the superproject status gate, so removal is not exposed.
  • 87932, medium: "nested submodules follow their recorded commits". In references/checkout-updates.md, use the finding's wording: a nested selector is ignored, and a nested submodule stays where the last Gitlink sync put it.
  • 87938, low: "right before the status check" is ambiguous. In references/removal-gates.md, say "immediately before the second status check".
  • 87930, 87931, 87933, 87936, 87937, 87940, and "schema 5" in --help. These are the wording items deferred in rounds 4 and 5: "even when no fast-forward runs"; "holds" throughout; a shorter --help section and mode-table cell; splitting the 57-word stranded-submodule sentence; the plural "submodules on a commit no ref holds"; and removing the schema number from usage().
> Replying to review summary comment #87417 Round-6 outcomes for review `01M3BGFRYK9R81EWETEHWA9X9E` (head 7f0da38). From round 6 only data loss, data corruption, or a security hole gets a new push. Fixed in 0cdf081: - **high, a failed submodule `git status` reads as clean (87929):** agreed; it is data loss. See the inline thread. **Real bug left unfixed (87934, medium).** The removal gate counts tags inside a submodule as local work unless a remote-tracking branch holds their commit. An upstream tag on a commit that no remote branch contains therefore keeps a merged worktree forever. It errs toward keeping work, so nothing is lost, but such worktrees need manual removal until the follow-up lands. Fix, in `scripts/audit-checkouts.sh` `submodule_holds_local_work`: keep `--branches` in the reachability test, and count a local tag only when `git ls-remote --tags origin` lacks it or names a different object. Acknowledged and deferred to a follow-up PR: - **87935, medium: the update walk discards its stderr.** In `audit_worktree`, redirect the walk's stderr to a `stranded-submodules.error` file and carry it as `strandedSubmodulesError`. `stepFailure` should append its first line. - **87939, low: an `ls-files` failure is swallowed.** In `list_submodules_with_local_work`, capture `ls-files --stage -z` separately with `|| return 1` before combining it with `ls-tree`. An unreadable superproject index already fails the superproject status gate, so removal is not exposed. - **87932, medium: "nested submodules follow their recorded commits".** In `references/checkout-updates.md`, use the finding's wording: a nested selector is ignored, and a nested submodule stays where the last Gitlink sync put it. - **87938, low: "right before the status check" is ambiguous.** In `references/removal-gates.md`, say "immediately before the second status check". - **87930, 87931, 87933, 87936, 87937, 87940, and "schema 5" in `--help`.** These are the wording items deferred in rounds 4 and 5: "even when no fast-forward runs"; "holds" throughout; a shorter `--help` section and mode-table cell; splitting the 57-word stranded-submodule sentence; the plural "submodules on a commit no ref holds"; and removing the schema number from `usage()`.
@ -16,3 +16,3 @@
| Request | Flags | What changes |
| --- | --- | --- |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |
| Audit, clean up, "which are behind" | none | Fetch and prune `origin`; fast-forward eligible default checkouts, then initialize their submodules and check them out at the recorded commits; in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged; prune stale worktree registrations; remove proven-merged linked worktrees inside the root. |

low — The mode table's "What changes" cell now restates the submodule behavior that the bullet three lines below and the reference already give
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the "Choose the mode" table and the stricter-mode bullet list directly under it in skills/audit-git-checkouts/SKILL.md, plus the first-party bullet in references/checkout-updates.md.

What the subject says: the default-mode cell grew from "fast-forward eligible default checkouts" to two added clauses -- "then initialize their submodules and check them out at the recorded commits" and "in first-party default checkouts, check each submodule listed in the checkout's own .gitmodules out at its configured branch or tag, leaving the Gitlink change unstaged". Three lines later, the new bullet says the same two things again: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules". The reference says them a third time: "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move" and "checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit".

What goes wrong: the writing standard gives each instruction one home, says not to restate what an earlier sentence already says, and says to cut most aggressively from the content loaded most often -- SKILL.md body text loads on every invocation, the reference only when the agent opens it. Three costs follow. The cell is now a 60-word chain of five semicolon-joined clauses in a table whose job is letting a reader pick a mode at a glance, so the column no longer scans. The two copies are not word-for-word, so a reader must reconcile "initialize their submodules and check them out at the recorded commits" with "checks every submodule, third-party ones included, out at its recorded commit" and decide whether the third-party scope is a difference or a restatement. And the .gitmodules-listing and unstaged-Gitlink details are decision-time facts for someone already reconciling submodules, not mode-selection facts for someone choosing flags.

Correction: return the cell to the granularity of its neighbours -- "Fetch and prune origin; fast-forward eligible default checkouts and move their submodules (see below); prune stale worktree registrations; remove proven-merged linked worktrees inside the root." -- and let the bullet below carry the recorded-commit and first-party-selector detail it already carries. Nothing is lost: every fact stays in the file, once, where the reader needs it.

What would establish or refute it: the two passages are quoted above from the same file, eleven lines apart; the duplication is on the page. It would be refuted if the table cell were the only statement of either fact, which the quoted bullet shows it is not.

claim 01M3BJD43THCCSVM63RWPDHTDZ of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJD43THCCSVM63RWPDHTDZ --> **low** — The mode table's "What changes" cell now restates the submodule behavior that the bullet three lines below and the reference already give lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the "Choose the mode" table and the stricter-mode bullet list directly under it in `skills/audit-git-checkouts/SKILL.md`, plus the first-party bullet in `references/checkout-updates.md`. > > What the subject says: the default-mode cell grew from "fast-forward eligible default checkouts" to two added clauses -- "then initialize their submodules and check them out at the recorded commits" and "in first-party default checkouts, check each submodule listed in the checkout's own `.gitmodules` out at its configured branch or tag, leaving the Gitlink change unstaged". Three lines later, the new bullet says the same two things again: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules". The reference says them a third time: "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move" and "checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit". > > What goes wrong: the writing standard gives each instruction one home, says not to restate what an earlier sentence already says, and says to cut most aggressively from the content loaded most often -- SKILL.md body text loads on every invocation, the reference only when the agent opens it. Three costs follow. The cell is now a 60-word chain of five semicolon-joined clauses in a table whose job is letting a reader pick a mode at a glance, so the column no longer scans. The two copies are not word-for-word, so a reader must reconcile "initialize their submodules and check them out at the recorded commits" with "checks every submodule, third-party ones included, out at its recorded commit" and decide whether the third-party scope is a difference or a restatement. And the `.gitmodules`-listing and unstaged-Gitlink details are decision-time facts for someone already reconciling submodules, not mode-selection facts for someone choosing flags. > > Correction: return the cell to the granularity of its neighbours -- "Fetch and prune `origin`; fast-forward eligible default checkouts and move their submodules (see below); prune stale worktree registrations; remove proven-merged linked worktrees inside the root." -- and let the bullet below carry the recorded-commit and first-party-selector detail it already carries. Nothing is lost: every fact stays in the file, once, where the reader needs it. > > What would establish or refute it: the two passages are quoted above from the same file, eleven lines apart; the duplication is on the page. It would be refuted if the table cell were the only statement of either fact, which the quoted bullet shows it is not. claim `01M3BJD43THCCSVM63RWPDHTDZ` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
@ -23,3 +23,4 @@
- A repository whose work integrates somewhere other than `origin/<default>`, such as a fork with an `upstream`: `--no-remove`. Removal proves containment against `origin/<default>` only.
- A repository with a custom `origin` fetch refspec: `--no-fetch --no-remove`. The fetch prunes every `refs/remotes/origin/*` ref that no server branch supplies (see [references/checkout-updates.md](references/checkout-updates.md)).
- A checkout whose submodules must stay where they are: `--no-fetch --no-remove`. After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards, and `update = none` stops only that move.

medium — "moves branch- or tag-tracked submodules when nothing fast-forwards" understates when selector moves run: they also run right after a successful fast-forward
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new bullet under "Choose the mode" in skills/audit-git-checkouts/SKILL.md, the tracking_update_eligible gate in scripts/audit-checkouts.sh, and the matching paragraph in references/checkout-updates.md.

What the subject says: the bullet tells a user whose submodules must stay put to pass --no-fetch --no-remove, and describes what the default mode would otherwise do: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards".

What the driver does: in audit_worktree, the selector move is gated on

&& { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } \

which is true both when no fast-forward was attempted and when one was attempted and succeeded. synchronize_first_party_tracking_submodules then runs and checks each branch- or tag-selected first-party submodule out detached at the selector tip. So a checkout that does fast-forward gets its recorded Gitlinks checked out and then its first-party selector submodules moved off those Gitlinks in the same run.

What goes wrong: "when nothing fast-forwards" reads as a restriction -- the selector move is the fallback for checkouts that do not advance -- and a literal reader concludes that a checkout which is behind origin/<default> will only have its submodules returned to the recorded commits, never floated to a branch tip. That is the opposite of the actual sequence, and it is the reading that matters here, because the bullet exists to help the user decide whether to reach for the stricter mode. references/checkout-updates.md states the behavior without the restriction ("On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached"), so SKILL.md and its own reference now disagree.

Correction: drop the clause, leaving "In first-party checkouts it also moves branch- or tag-tracked submodules to their selector's tip, and update = none stops only that move." This keeps the two facts the bullet needs -- selector floating is first-party only, and update = none opts out of floating but not out of the Gitlink checkout -- and removes a condition that is not a condition.

What would establish or refute it: the { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } clause in the eligibility chain is decisive; it admits fast_forward_ok = true. The new test "a configured tag follows origin unless the local tag holds a commit no other ref holds" exercises the no-fast-forward path only, so no test in the subject pins the post-fast-forward ordering, but the gate text does not depend on one.

claim 01M3BJAY8E33N13G2MRZKFY5X3 of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJAY8E33N13G2MRZKFY5X3 --> **medium** — "moves branch- or tag-tracked submodules when nothing fast-forwards" understates when selector moves run: they also run right after a successful fast-forward lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new bullet under "Choose the mode" in `skills/audit-git-checkouts/SKILL.md`, the `tracking_update_eligible` gate in `scripts/audit-checkouts.sh`, and the matching paragraph in `references/checkout-updates.md`. > > What the subject says: the bullet tells a user whose submodules must stay put to pass `--no-fetch --no-remove`, and describes what the default mode would otherwise do: "After a fast-forward the driver checks every submodule, third-party ones included, out at its recorded commit. In first-party checkouts it also moves branch- or tag-tracked submodules when nothing fast-forwards". > > What the driver does: in `audit_worktree`, the selector move is gated on > > && { [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; } \ > > which is true both when no fast-forward was attempted and when one was attempted and succeeded. `synchronize_first_party_tracking_submodules` then runs and checks each branch- or tag-selected first-party submodule out detached at the selector tip. So a checkout that does fast-forward gets its recorded Gitlinks checked out and then its first-party selector submodules moved off those Gitlinks in the same run. > > What goes wrong: "when nothing fast-forwards" reads as a restriction -- the selector move is the fallback for checkouts that do not advance -- and a literal reader concludes that a checkout which is behind `origin/<default>` will only have its submodules returned to the recorded commits, never floated to a branch tip. That is the opposite of the actual sequence, and it is the reading that matters here, because the bullet exists to help the user decide whether to reach for the stricter mode. `references/checkout-updates.md` states the behavior without the restriction ("On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached"), so SKILL.md and its own reference now disagree. > > Correction: drop the clause, leaving "In first-party checkouts it also moves branch- or tag-tracked submodules to their selector's tip, and `update = none` stops only that move." This keeps the two facts the bullet needs -- selector floating is first-party only, and `update = none` opts out of floating but not out of the Gitlink checkout -- and removes a condition that is not a condition. > > What would establish or refute it: the `{ [ "$fast_forward_attempted" != true ] || [ "$fast_forward_ok" = true ]; }` clause in the eligibility chain is decisive; it admits `fast_forward_ok = true`. The new test "a configured tag follows origin unless the local tag holds a commit no other ref holds" exercises the no-fast-forward path only, so no test in the subject pins the post-fast-forward ordering, but the gate text does not depend on one. claim `01M3BJAY8E33N13G2MRZKFY5X3` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
@ -5,3 +5,3 @@
## What the driver already updates
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth) or first-party `.gitmodules`/Gitlink maintenance, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it synchronizes committed submodules to the recorded Gitlinks.
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth), a first-party `.gitmodules` edit, or a first-party Gitlink change on a submodule with a `branch` or `tag` selector, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it initializes committed submodules and checks them out at the recorded Gitlinks. When any populated submodule, at any depth, found from the Gitlinks rather than `.gitmodules`, is checked out at a commit that its superproject does not record and no ref holds, the driver skips the fast-forward and every selector move for that checkout, and the report lists it under "Needs your decision" with the submodule's path.

medium — The stranded-submodule rule is one 57-word sentence whose "found from the Gitlinks rather than .gitmodules" aside drops the consequence that makes it worth stating
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the added final sentence of "What the driver already updates" in skills/audit-git-checkouts/references/checkout-updates.md, and the comment the same change puts above list_submodules_with_local_work in scripts/audit-checkouts.sh.

What the subject says: one sentence carries four separate facts -- which submodules are examined, how they are discovered, what disqualifies one, and the two consequences (no fast-forward, no selector move, plus a report row) -- with two qualifiers ("at any depth", "found from the Gitlinks rather than .gitmodules") wedged between the subject and its verb, so "submodule ... is checked out" is split by nineteen words.

What goes wrong: the writing standard asks for the action and its object first, with conditions attached to the action they govern, and for detail spent where a plausible mistake would derail the task. Here the discovery mechanism is stated but its payload is not. The script's own comment spells out why discovery from Gitlinks matters -- "Submodules come from the Gitlinks in HEAD and the index, not from .gitmodules, so neither a deleted .gitmodules nor an ignore setting hides one" -- and the change adds two tests for exactly that ("a deleted .gitmodules does not hide a submodule commit no ref holds", "an ignore = all submodule commit no ref holds blocks the fast-forward"). The reference keeps the mechanism and drops the "so" clause, which inverts the value: an agent reading only this reference learns an implementation detail it cannot act on, and does not learn the actionable fact that ignore = all and a removed .gitmodules do not suppress the block. That is the fact a reader hunting for why a checkout went unupdated needs.

Correction: split into two sentences and move the aside's payload into the second, for example: "The driver skips the fast-forward and every selector move for a checkout holding a populated submodule, at any depth, that is checked out at a commit its superproject does not record and no ref holds; the report lists the checkout under "Needs your decision" with the submodule's path. Submodules are found from the Gitlinks in HEAD and the index, so neither a removed .gitmodules nor an ignore setting hides one." Nothing is lost: both consequences, the depth qualifier, and the discovery source survive.

What would establish or refute it: the claim rests on the sentence as written against the script comment and the two tests named above, all in this change; it would be refuted if the reference documented the .gitmodules/ignore consequence elsewhere. Grepping references/checkout-updates.md for "ignore" returns no other mention.

claim 01M3BJBFWMJRZTY6TQRHTN69G0 of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJBFWMJRZTY6TQRHTN69G0 --> **medium** — The stranded-submodule rule is one 57-word sentence whose "found from the Gitlinks rather than `.gitmodules`" aside drops the consequence that makes it worth stating lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the added final sentence of "What the driver already updates" in `skills/audit-git-checkouts/references/checkout-updates.md`, and the comment the same change puts above `list_submodules_with_local_work` in `scripts/audit-checkouts.sh`. > > What the subject says: one sentence carries four separate facts -- which submodules are examined, how they are discovered, what disqualifies one, and the two consequences (no fast-forward, no selector move, plus a report row) -- with two qualifiers ("at any depth", "found from the Gitlinks rather than `.gitmodules`") wedged between the subject and its verb, so "submodule ... is checked out" is split by nineteen words. > > What goes wrong: the writing standard asks for the action and its object first, with conditions attached to the action they govern, and for detail spent where a plausible mistake would derail the task. Here the discovery mechanism is stated but its payload is not. The script's own comment spells out why discovery from Gitlinks matters -- "Submodules come from the Gitlinks in HEAD and the index, not from .gitmodules, so neither a deleted .gitmodules nor an `ignore` setting hides one" -- and the change adds two tests for exactly that ("a deleted .gitmodules does not hide a submodule commit no ref holds", "an ignore = all submodule commit no ref holds blocks the fast-forward"). The reference keeps the mechanism and drops the "so" clause, which inverts the value: an agent reading only this reference learns an implementation detail it cannot act on, and does not learn the actionable fact that `ignore = all` and a removed `.gitmodules` do not suppress the block. That is the fact a reader hunting for why a checkout went unupdated needs. > > Correction: split into two sentences and move the aside's payload into the second, for example: "The driver skips the fast-forward and every selector move for a checkout holding a populated submodule, at any depth, that is checked out at a commit its superproject does not record and no ref holds; the report lists the checkout under \"Needs your decision\" with the submodule's path. Submodules are found from the Gitlinks in HEAD and the index, so neither a removed `.gitmodules` nor an `ignore` setting hides one." Nothing is lost: both consequences, the depth qualifier, and the discovery source survive. > > What would establish or refute it: the claim rests on the sentence as written against the script comment and the two tests named above, all in this change; it would be refuted if the reference documented the `.gitmodules`/`ignore` consequence elsewhere. Grepping `references/checkout-updates.md` for "ignore" returns no other mention. claim `01M3BJBFWMJRZTY6TQRHTN69G0` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
@ -7,3 +7,3 @@
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth) or first-party `.gitmodules`/Gitlink maintenance, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it synchronizes committed submodules to the recorded Gitlinks.
The driver fast-forwards a default-branch checkout only when its comparison is fresh, it has no local commits, and it is strictly behind `origin/<default>`. A dirty checkout qualifies only when every changed path is deferred guidance (`AGENTS.md`, `.agents/**`, at any depth), a first-party `.gitmodules` edit, or a first-party Gitlink change on a submodule with a `branch` or `tag` selector, and upstream did not touch those paths. The merge runs `--ff-only` with `merge.autostash=false`, because autostash would round-trip the tree through a stash and silently unstage staged guidance. After a fast-forward it initializes committed submodules and checks them out at the recorded Gitlinks. When any populated submodule, at any depth, found from the Gitlinks rather than `.gitmodules`, is checked out at a commit that its superproject does not record and no ref holds, the driver skips the fast-forward and every selector move for that checkout, and the report lists it under "Needs your decision" with the submodule's path.
A fast-forward, submodule sync, or selector update can fail after an earlier step succeeded. Inspect the actual HEAD, index, and submodule state before repairing; never describe a failed multi-step update as atomic, and never force a refused merge.

low — The reference's failure list omits the new "submodule check failed; checkout not updated" outcome and sends its reader to repair a state the driver never touched
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the "What the driver already updates" section of skills/audit-git-checkouts/references/checkout-updates.md, the stepFailure function in scripts/render-audit-report.ts, and the stranded_submodules handling in audit_worktree in scripts/audit-checkouts.sh.

What the change adds: the driver now runs list_submodules_with_local_work "$worktree_path" update before deciding eligibility, and when that walk fails it sets stranded_submodules=null. Both eligibility gates then test [ "$stranded_submodules" = '[]' ], so a null blocks the fast-forward and the selector move alike. The renderer turns that null into a new failure row: if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";.

What the prose says: the reference names four ways a checkout goes unupdated -- not fresh, local commits, not behind, or a dirty tree that is not deferred guidance or first-party selector maintenance -- plus the new stranded-submodule rule, and then says "A fast-forward, submodule sync, or selector update can fail after an earlier step succeeded. Inspect the actual HEAD, index, and submodule state before repairing". Those three names match the other three stepFailure strings exactly ("fast-forward failed", "submodule sync failed after fast-forward", "submodule selector update failed"). The fourth string has no entry anywhere in the reference; grepping it for "check failed" returns nothing.

What goes wrong: SKILL.md routes "a default checkout that was not fast-forwarded" to this reference, so this is where an agent looks after reading "submodule check failed; checkout not updated" in report.md. It finds a closed enumeration of three failures that does not include theirs, and the one instruction that seems to apply -- inspect HEAD, index, and submodule state "before repairing", on the premise that an earlier step succeeded -- is wrong for this case: nothing was attempted, so there is no half-applied update to repair. The remedy is the opposite kind of action, making the unreadable submodule readable (the walk emits cannot read submodule <path> or cannot inspect submodule <path>) and rerunning. Sending an agent to reconcile HEAD and index on an untouched checkout is the mistake the section exists to prevent.

Correction: add one sentence beside the stranded-submodule rule -- "When the submodule walk itself fails, the driver also skips the fast-forward and every selector move, changes nothing, and the report says the submodule check failed; the driver's stderr names the unreadable submodule. Make it readable and rerun." That preserves the existing paragraph and closes the enumeration against the fourth outcome.

What would establish or refute it: the claim rests on strandedSubmodules === null producing a distinct report string with no reference entry, and on the null path skipping both updates. It would be refuted if some other reference documented the string; references/removal-gates.md documents only the removal-side operational/... outcomes, and its judgment/submodule-local-work row covers removal, not updates.

claim 01M3BJFJHNJM26BM2SDNQ4X9VX of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJFJHNJM26BM2SDNQ4X9VX --> **low** — The reference's failure list omits the new "submodule check failed; checkout not updated" outcome and sends its reader to repair a state the driver never touched lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the "What the driver already updates" section of `skills/audit-git-checkouts/references/checkout-updates.md`, the `stepFailure` function in `scripts/render-audit-report.ts`, and the `stranded_submodules` handling in `audit_worktree` in `scripts/audit-checkouts.sh`. > > What the change adds: the driver now runs `list_submodules_with_local_work "$worktree_path" update` before deciding eligibility, and when that walk fails it sets `stranded_submodules=null`. Both eligibility gates then test `[ "$stranded_submodules" = '[]' ]`, so a null blocks the fast-forward and the selector move alike. The renderer turns that null into a new failure row: `if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";`. > > What the prose says: the reference names four ways a checkout goes unupdated -- not fresh, local commits, not behind, or a dirty tree that is not deferred guidance or first-party selector maintenance -- plus the new stranded-submodule rule, and then says "A fast-forward, submodule sync, or selector update can fail after an earlier step succeeded. Inspect the actual HEAD, index, and submodule state before repairing". Those three names match the other three `stepFailure` strings exactly ("fast-forward failed", "submodule sync failed after fast-forward", "submodule selector update failed"). The fourth string has no entry anywhere in the reference; grepping it for "check failed" returns nothing. > > What goes wrong: SKILL.md routes "a default checkout that was not fast-forwarded" to this reference, so this is where an agent looks after reading "submodule check failed; checkout not updated" in `report.md`. It finds a closed enumeration of three failures that does not include theirs, and the one instruction that seems to apply -- inspect HEAD, index, and submodule state "before repairing", on the premise that an earlier step succeeded -- is wrong for this case: nothing was attempted, so there is no half-applied update to repair. The remedy is the opposite kind of action, making the unreadable submodule readable (the walk emits `cannot read submodule <path>` or `cannot inspect submodule <path>`) and rerunning. Sending an agent to reconcile HEAD and index on an untouched checkout is the mistake the section exists to prevent. > > Correction: add one sentence beside the stranded-submodule rule -- "When the submodule walk itself fails, the driver also skips the fast-forward and every selector move, changes nothing, and the report says the submodule check failed; the driver's stderr names the unreadable submodule. Make it readable and rerun." That preserves the existing paragraph and closes the enumeration against the fourth outcome. > > What would establish or refute it: the claim rests on `strandedSubmodules === null` producing a distinct report string with no reference entry, and on the null path skipping both updates. It would be refuted if some other reference documented the string; `references/removal-gates.md` documents only the removal-side `operational/...` outcomes, and its `judgment/submodule-local-work` row covers removal, not updates. claim `01M3BJFJHNJM26BM2SDNQ4X9VX` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
@ -18,3 +18,3 @@
- **Under a `third-party/` path component:** `.gitmodules` and Gitlinks belong to upstream. Never add or change selectors, URLs, paths, update policies, or Gitlinks; only synchronize what upstream committed.
- **Everywhere else (first-party):** each submodule needs exactly one selector, `branch = <name>` for a moving line or `tag = <name>` for an exact release. On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit.
- **Everywhere else (first-party):** each submodule needs exactly one selector, `branch = <name>` for a moving line or `tag = <name>` for an exact release. On fresh eligible default checkouts the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged for the owner's next commit. Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits. A local tag of the configured name on the same commit is kept as it is. One on another commit is replaced only when the fetched tag's commit contains that commit or another ref holds it. Otherwise, or when the local tag does not point at a commit, that submodule stays where it is, the local tag is kept, and the selector update fails naming the tag.

medium — "nested submodules follow their recorded commits" names a checkout the selector path never performs, and reads as a guarantee
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the first-party bullet under "Submodule selectors" in skills/audit-git-checkouts/references/checkout-updates.md, and the two functions that move submodules in scripts/audit-checkouts.sh: synchronize_submodules (post-fast-forward) and synchronize_first_party_tracking_submodules (selector moves).

What the subject says: inside the bullet describing the selector move -- "the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged" -- the new sentence adds "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move; nested submodules follow their recorded commits."

What the driver does on that path: synchronize_first_party_tracking_submodules populates a submodule only when it is missing, and non-recursively --

if [ ! -e "$submodule_path/.git" ] \
  && ! git -C "$checkout_path" submodule update --init --checkout -- "$configured_path" ...

-- then runs git -C "$submodule_path" fetch ... and git -C "$submodule_path" checkout --detach "$target_sha". Neither command touches the submodule's own submodules. Only the post-fast-forward synchronize_submodules uses --recursive: git -C "$checkout_path" submodule update --init --recursive --checkout. So after a selector move -- which is exactly the paragraph's subject, and which runs on checkouts where nothing fast-forwarded as well as after one -- a nested submodule is left at whatever commit it already had, while the moved parent now records a different one. It follows neither the old nor the new recorded commit.

What goes wrong: the clause is placed as the complement of "Only ... move", so a literal reader takes it as a statement of what the driver did: nested submodules are not floated to a selector, they are at their recorded commits. An agent acting on this reference -- the one SKILL.md sends it to for "a submodule selector problem" -- will report a checkout as reconciled without checking the nested level, and will be wrong precisely when the parent just moved. The second, charitable reading (this is policy: nested submodules are governed by Gitlinks, not selectors) is also available, and the sentence gives the reader no way to choose, which is itself the defect the writing standard's "make claims verifiable" rule targets.

Correction: say what is and is not done, for example "Only submodules listed in the checkout's own .gitmodules that are Gitlinks in HEAD move. A selector never floats a nested submodule, and a selector move does not re-check-out the moved submodule's own submodules; after one, verify the nested level against the new Gitlinks by hand." That preserves the useful boundary -- selectors are top-level and first-party only -- and stops the sentence from promising a checkout that did not happen.

Proof gap: I read the code rather than running the driver. The conclusion assumes git checkout --detach does not recurse, which holds unless submodule.recurse is set; I found no submodule.recurse assignment anywhere in scripts/audit-checkouts.sh. No test in the change asserts nested submodule state after a selector move -- the nested fixture is used only by "a nested submodule commit no ref holds blocks the fast-forward", which asserts the move did not happen -- so a test that pins post-selector-move nested state would settle it either way.

claim 01M3BJCEANJFWKDH83C7BJ4JFV of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJCEANJFWKDH83C7BJ4JFV --> **medium** — "nested submodules follow their recorded commits" names a checkout the selector path never performs, and reads as a guarantee lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the first-party bullet under "Submodule selectors" in `skills/audit-git-checkouts/references/checkout-updates.md`, and the two functions that move submodules in `scripts/audit-checkouts.sh`: `synchronize_submodules` (post-fast-forward) and `synchronize_first_party_tracking_submodules` (selector moves). > > What the subject says: inside the bullet describing the selector move -- "the driver fetches that branch or tag, checks the submodule out detached, and leaves the Gitlink change unstaged" -- the new sentence adds "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move; nested submodules follow their recorded commits." > > What the driver does on that path: `synchronize_first_party_tracking_submodules` populates a submodule only when it is missing, and non-recursively -- > > if [ ! -e "$submodule_path/.git" ] \ > && ! git -C "$checkout_path" submodule update --init --checkout -- "$configured_path" ... > > -- then runs `git -C "$submodule_path" fetch ...` and `git -C "$submodule_path" checkout --detach "$target_sha"`. Neither command touches the submodule's own submodules. Only the post-fast-forward `synchronize_submodules` uses `--recursive`: `git -C "$checkout_path" submodule update --init --recursive --checkout`. So after a selector move -- which is exactly the paragraph's subject, and which runs on checkouts where nothing fast-forwarded as well as after one -- a nested submodule is left at whatever commit it already had, while the moved parent now records a different one. It follows neither the old nor the new recorded commit. > > What goes wrong: the clause is placed as the complement of "Only ... move", so a literal reader takes it as a statement of what the driver did: nested submodules are not floated to a selector, they are at their recorded commits. An agent acting on this reference -- the one SKILL.md sends it to for "a submodule selector problem" -- will report a checkout as reconciled without checking the nested level, and will be wrong precisely when the parent just moved. The second, charitable reading (this is policy: nested submodules are governed by Gitlinks, not selectors) is also available, and the sentence gives the reader no way to choose, which is itself the defect the writing standard's "make claims verifiable" rule targets. > > Correction: say what is and is not done, for example "Only submodules listed in the checkout's own `.gitmodules` that are Gitlinks in HEAD move. A selector never floats a nested submodule, and a selector move does not re-check-out the moved submodule's own submodules; after one, verify the nested level against the new Gitlinks by hand." That preserves the useful boundary -- selectors are top-level and first-party only -- and stops the sentence from promising a checkout that did not happen. > > Proof gap: I read the code rather than running the driver. The conclusion assumes `git checkout --detach` does not recurse, which holds unless `submodule.recurse` is set; I found no `submodule.recurse` assignment anywhere in `scripts/audit-checkouts.sh`. No test in the change asserts nested submodule state after a selector move -- the nested fixture is used only by "a nested submodule commit no ref holds blocks the fast-forward", which asserts the move did not happen -- so a test that pins post-selector-move nested state would settle it either way. claim `01M3BJCEANJFWKDH83C7BJ4JFV` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
@ -44,6 +45,7 @@ The outcome names the first blocker only; later gates may never have run. After
| `judgment/hidden-index-flags` | Have the owner clear the flags they set, prove the revealed tree clean, then rerun. In `git ls-files -v`, a lowercase letter is assume-unchanged (clear it with `--no-assume-unchanged`); an uppercase `S` outside a sparse cone is a hand-set skip-worktree bit (clear it with `--no-skip-worktree`). Leave a sparse cone's `S` entries alone. |
| `judgment/sparse-checkout` | Keep the cone's `skip-worktree` bits: clearing them turns absent files into apparent deletions. Confirm the cone with `git sparse-checkout list`, verify no file outside it is materialized, then remove manually once every other gate holds. |
| `judgment/precious-ignored-files` | Leave `preciousPaths` in place until the owner disposes of them or authorizes their deletion, then rerun. |
| `judgment/submodule-local-work` | The listed submodules hold work only this worktree has. Show the owner what each holds; rerun once it is pushed or they authorize discarding it. |

low — "rerun once ... they authorize discarding it" sends the agent back into the same gate: authorization alone does not change what the walk sees
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new judgment/submodule-local-work row in the outcome table of skills/audit-git-checkouts/references/removal-gates.md, the neighbouring rows in that table, and submodule_holds_local_work plus the gate that calls it in scripts/audit-checkouts.sh.

What the subject says: "Show the owner what each holds; rerun once it is pushed or they authorize discarding it."

What goes wrong: the two branches of that disjunction are not the same kind of thing. "once it is pushed" names a state change the gate can see -- pushing updates refs/remotes/origin/<branch>, and submodule_holds_local_work then finds the HEAD contained by for-each-ref --contains "$head" refs/remotes and returns 1. "once ... they authorize discarding it" names only permission. Nothing in the submodule changes when the owner says yes, so the rerun re-walks the same uncommitted files, the same stash, and the same unpushed commits, emits judgment/submodule-local-work again, and the worktree is kept a second time. An agent following the row literally loops, or reports to the user that the driver refuses to honour the authorization they just gave.

The adjacent rows get this right by naming the act, not the permission: judgment/precious-ignored-files says "Leave preciousPaths in place until the owner disposes of them or authorizes their deletion, then rerun" -- disposal happens first -- and judgment/hidden-index-flags says "Have the owner clear the flags they set, prove the revealed tree clean, then rerun."

Correction: make the second branch a state change too, for example "rerun once the work is pushed; if the owner authorizes discarding it instead, discard it in the submodule first, then rerun." The row's useful content -- show the owner what each submodule holds, and never discard without their say-so, which is SKILL.md's "Treat user work as untouchable" -- is preserved; only the trigger for the rerun becomes something the gate can observe.

What would establish or refute it: submodule_holds_local_work reads only the submodule's working tree, stash, refs and remote-tracking refs; it takes no authorization input, and no caller passes one. It would be refuted if some flag or environment variable let a rerun bypass the gate -- the change adds none, and --no-remove only makes the driver keep more worktrees, not fewer.

claim 01M3BJGHNSVNR7A66RVMKR11BX of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJGHNSVNR7A66RVMKR11BX --> **low** — "rerun once ... they authorize discarding it" sends the agent back into the same gate: authorization alone does not change what the walk sees lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new `judgment/submodule-local-work` row in the outcome table of `skills/audit-git-checkouts/references/removal-gates.md`, the neighbouring rows in that table, and `submodule_holds_local_work` plus the gate that calls it in `scripts/audit-checkouts.sh`. > > What the subject says: "Show the owner what each holds; rerun once it is pushed or they authorize discarding it." > > What goes wrong: the two branches of that disjunction are not the same kind of thing. "once it is pushed" names a state change the gate can see -- pushing updates `refs/remotes/origin/<branch>`, and `submodule_holds_local_work` then finds the HEAD contained by `for-each-ref --contains "$head" refs/remotes` and returns 1. "once ... they authorize discarding it" names only permission. Nothing in the submodule changes when the owner says yes, so the rerun re-walks the same uncommitted files, the same stash, and the same unpushed commits, emits `judgment/submodule-local-work` again, and the worktree is kept a second time. An agent following the row literally loops, or reports to the user that the driver refuses to honour the authorization they just gave. > > The adjacent rows get this right by naming the act, not the permission: `judgment/precious-ignored-files` says "Leave `preciousPaths` in place until the owner disposes of them or authorizes their deletion, then rerun" -- disposal happens first -- and `judgment/hidden-index-flags` says "Have the owner clear the flags they set, prove the revealed tree clean, then rerun." > > Correction: make the second branch a state change too, for example "rerun once the work is pushed; if the owner authorizes discarding it instead, discard it in the submodule first, then rerun." The row's useful content -- show the owner what each submodule holds, and never discard without their say-so, which is SKILL.md's "Treat user work as untouchable" -- is preserved; only the trigger for the rerun becomes something the gate can observe. > > What would establish or refute it: `submodule_holds_local_work` reads only the submodule's working tree, stash, refs and remote-tracking refs; it takes no authorization input, and no caller passes one. It would be refuted if some flag or environment variable let a rerun bypass the gate -- the change adds none, and `--no-remove` only makes the driver keep more worktrees, not fewer. claim `01M3BJGHNSVNR7A66RVMKR11BX` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
Lines 29-30
@ -27,0 +26,5 @@
echo " submodule selectors advanced outside third-party/ paths; removal of in-root linked"
echo " worktrees that pass every removal gate. A checkout with a submodule, at any depth, on a"
echo " commit neither recorded nor held by a ref gets no fast-forward or selector move; a local"
echo " selector tag is replaced only when the fetched tag or another ref keeps its commit; a"
echo " worktree whose submodules hold work no remote-tracking ref keeps is not removed."

low — The new help text says a ref "keeps" a commit where every other file says "holds", and "work no remote-tracking ref keeps is" garden-paths the reader
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the three help lines the change adds to usage() in skills/audit-git-checkouts/scripts/audit-checkouts.sh, and every other place in the skill that names the same relation: SKILL.md, references/removal-gates.md, references/checkout-updates.md, the new comment above list_submodules_with_local_work, and the report strings in scripts/render-audit-report.ts.

What the subject says: the help text uses "keeps" twice for "some ref contains this commit" -- "a local selector tag is replaced only when the fetched tag or another ref keeps its commit" and "a worktree whose submodules hold work no remote-tracking ref keeps is not removed".

Everywhere else the skill calls that relation "holds": SKILL.md "commits no other ref holds"; removal-gates.md "a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds"; checkout-updates.md "a commit that its superproject does not record and no ref holds" and, for the identical tag rule this help line restates, "another ref holds it"; the script's own comment "nor held by any ref in the submodule"; render-audit-report.ts "submodule on a commit no ref holds". The line immediately above even uses the right word -- "neither recorded nor held by a ref" -- so the two spellings sit three lines apart.

What goes wrong: the writing standard asks for one term per concept, and the collision here is not hypothetical. These same documents already use "keep" for a different relation: "A Gitlink names a commit without keeping it" and "A local tag ... is kept as it is" (retention), and "A submodule keeps the worktree" (blocks removal). A reader who meets "another ref keeps its commit" must decide which of those three senses applies before they can read the rule. The second sentence compounds it: "work no remote-tracking ref keeps is not removed" puts a reduced relative clause between subject and verb and lands "keeps is" adjacent, so the reader parses "keeps" as the main verb and has to restart. --help is the first thing SKILL.md tells a reader to run, which is the worst place for a sentence that needs a second pass.

Correction: use "holds" in both, and give the second one a verb it cannot be mistaken for: "... a local selector tag is replaced only when the fetched tag or another ref holds its commit; a worktree is not removed while a submodule holds work that no remote-tracking ref holds." The rules and their scope are unchanged; only the term and the clause order move.

What would establish or refute it: the quoted occurrences are from the subject tree as listed above. It would be refuted if "keeps" named a distinct relation from "holds" here -- but the tag clause is the same rule references/checkout-updates.md states with "holds", and both compile down to the same for-each-ref --contains test in update_submodule_tag and submodule_holds_local_work.

claim 01M3BJDYR88CZ3HMK9ZDZWETFH of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJDYR88CZ3HMK9ZDZWETFH --> **low** — The new help text says a ref "keeps" a commit where every other file says "holds", and "work no remote-tracking ref keeps is" garden-paths the reader lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the three help lines the change adds to `usage()` in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, and every other place in the skill that names the same relation: `SKILL.md`, `references/removal-gates.md`, `references/checkout-updates.md`, the new comment above `list_submodules_with_local_work`, and the report strings in `scripts/render-audit-report.ts`. > > What the subject says: the help text uses "keeps" twice for "some ref contains this commit" -- "a local selector tag is replaced only when the fetched tag or another ref keeps its commit" and "a worktree whose submodules hold work no remote-tracking ref keeps is not removed". > > Everywhere else the skill calls that relation "holds": SKILL.md "commits no other ref holds"; removal-gates.md "a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds"; checkout-updates.md "a commit that its superproject does not record and no ref holds" and, for the identical tag rule this help line restates, "another ref holds it"; the script's own comment "nor held by any ref in the submodule"; render-audit-report.ts "submodule on a commit no ref holds". The line immediately above even uses the right word -- "neither recorded nor held by a ref" -- so the two spellings sit three lines apart. > > What goes wrong: the writing standard asks for one term per concept, and the collision here is not hypothetical. These same documents already use "keep" for a different relation: "A Gitlink names a commit without keeping it" and "A local tag ... is kept as it is" (retention), and "A submodule keeps the worktree" (blocks removal). A reader who meets "another ref keeps its commit" must decide which of those three senses applies before they can read the rule. The second sentence compounds it: "work no remote-tracking ref keeps is not removed" puts a reduced relative clause between subject and verb and lands "keeps is" adjacent, so the reader parses "keeps" as the main verb and has to restart. `--help` is the first thing SKILL.md tells a reader to run, which is the worst place for a sentence that needs a second pass. > > Correction: use "holds" in both, and give the second one a verb it cannot be mistaken for: "... a local selector tag is replaced only when the fetched tag or another ref holds its commit; a worktree is not removed while a submodule holds work that no remote-tracking ref holds." The rules and their scope are unchanged; only the term and the clause order move. > > What would establish or refute it: the quoted occurrences are from the subject tree as listed above. It would be refuted if "keeps" named a distinct relation from "holds" here -- but the tag clause is the same rule `references/checkout-updates.md` states with "holds", and both compile down to the same `for-each-ref --contains` test in `update_submodule_tag` and `submodule_holds_local_work`. claim `01M3BJDYR88CZ3HMK9ZDZWETFH` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
Lines 499-502
@ -478,0 +497,7 @@
mode=$2
prefix=${3:-}
gitlink_paths=$({
git -C "$superproject_path" ls-files --stage -z
git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true
} | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1

low — A git ls-files failure in list_submodules_with_local_work cannot be detected, so the submodule safety gate fails open instead of closed
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: list_submodules_with_local_work (scripts/audit-checkouts.sh lines 494-525), its own contract comment two lines below ("A submodule the walk cannot read may hold anything; fail rather than report it empty"), set -o pipefail at line 3, and both call sites — the removal gate at line 1052 and the update check at line 1544.

What the code does: the walker's ref-discovery step is

gitlink_paths=$({
  git -C "$superproject_path" ls-files --stage -z
  git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true
} | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1

pipefail only inspects the exit status of each pipeline element, and the first element is the brace group. A brace group's status is that of its last command, which here is git ls-tree ... || true — always 0. So a git ls-files --stage -z failure cannot reach the || return 1: the union silently loses whatever the index would have contributed, and the walker reports success.

Observed, in this sandbox (git 2.47.3), against a superproject with one Gitlink and a deliberately truncated .git/index:

$ { git ls-files --stage -z; git ls-tree -r -z --full-tree HEAD 2>/dev/null || true; } \
    | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u; echo "pipe rc=$?"
fatal: .git/index: index file smaller than expected
dep
pipe rc=0

and calling the function itself returned rc=0 with the same fatal: on stderr — no return 1.

What goes wrong: the walker is a safety gate whose stated design is to fail closed, and this path fails open. When ls-files is the only source that would have named a Gitlink — a Gitlink present in the index but not in HEAD — a failed index read makes list_submodules_with_local_work print nothing and return 0. In removal mode that is indistinguishable from "no submodule holds work", so the gate passes and git worktree remove --force proceeds to delete the worktree's submodule git dirs; in update mode it yields strandedSubmodules: [], which un-gates the fast-forward and the selector move.

Honest limit on reachability, which is why I am filing this low rather than high: the trigger I could construct (a corrupt index) also breaks the git status --porcelain re-check that immediately follows the gate, so that specific path ends in operational/gate-check-failed rather than in a deletion, and I could not construct a case where ls-files --stage -z fails while status succeeds. I am claiming the masked failure as a defect in a gate whose own comment promises the opposite, not a demonstrated data-loss path.

Safe correction: capture the two listings separately and check each, e.g. index_paths=$(git -C "$superproject_path" ls-files --stage -z) || return 1 and then union the already-validated text, so neither source's failure can read as "no submodules".

claim 01M3BJYBGZFFQGBD05BS5YHCYM of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJYBGZFFQGBD05BS5YHCYM --> **low** — A git ls-files failure in list_submodules_with_local_work cannot be detected, so the submodule safety gate fails open instead of closed lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: `list_submodules_with_local_work` (scripts/audit-checkouts.sh lines 494-525), its own contract comment two lines below ("A submodule the walk cannot read may hold anything; fail rather than report it empty"), `set -o pipefail` at line 3, and both call sites — the removal gate at line 1052 and the update check at line 1544. > > What the code does: the walker's ref-discovery step is > > ``` > gitlink_paths=$({ > git -C "$superproject_path" ls-files --stage -z > git -C "$superproject_path" ls-tree -r -z --full-tree HEAD 2>/dev/null || true > } | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u) || return 1 > ``` > > `pipefail` only inspects the exit status of each *pipeline element*, and the first element is the brace group. A brace group's status is that of its last command, which here is `git ls-tree ... || true` — always 0. So a `git ls-files --stage -z` failure cannot reach the `|| return 1`: the union silently loses whatever the index would have contributed, and the walker reports success. > > Observed, in this sandbox (git 2.47.3), against a superproject with one Gitlink and a deliberately truncated `.git/index`: > > ``` > $ { git ls-files --stage -z; git ls-tree -r -z --full-tree HEAD 2>/dev/null || true; } \ > | tr '\0' '\n' | awk -F '\t' '$1 ~ /^160000 / {print $2}' | sort -u; echo "pipe rc=$?" > fatal: .git/index: index file smaller than expected > dep > pipe rc=0 > ``` > > and calling the function itself returned `rc=0` with the same `fatal:` on stderr — no `return 1`. > > What goes wrong: the walker is a safety gate whose stated design is to fail closed, and this path fails open. When `ls-files` is the only source that would have named a Gitlink — a Gitlink present in the index but not in HEAD — a failed index read makes `list_submodules_with_local_work` print nothing and return 0. In `removal` mode that is indistinguishable from "no submodule holds work", so the gate passes and `git worktree remove --force` proceeds to delete the worktree's submodule git dirs; in `update` mode it yields `strandedSubmodules: []`, which un-gates the fast-forward and the selector move. > > Honest limit on reachability, which is why I am filing this low rather than high: the trigger I could construct (a corrupt index) also breaks the `git status --porcelain` re-check that immediately follows the gate, so that specific path ends in `operational/gate-check-failed` rather than in a deletion, and I could not construct a case where `ls-files --stage -z` fails while `status` succeeds. I am claiming the masked failure as a defect in a gate whose own comment promises the opposite, not a demonstrated data-loss path. > > Safe correction: capture the two listings separately and check each, e.g. `index_paths=$(git -C "$superproject_path" ls-files --stage -z) || return 1` and then union the already-validated text, so neither source's failure can read as "no submodules". claim `01M3BJYBGZFFQGBD05BS5YHCYM` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
@ -1415,2 +1541,4 @@
submodule_metadata_ok=$(jq -r '.ok' "$submodule_metadata_path")
if [ "$outside_root" = false ]; then
if stranded_output=$(list_submodules_with_local_work "$worktree_path" update 2>/dev/null); then

medium — The update-mode submodule walk discards its own diagnostics, so a failed walk blocks every fast-forward with an unactionable "submodule check failed" row
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: list_submodules_with_local_work and submodule_holds_local_work (scripts/audit-checkouts.sh, ~lines 483-550), the call site in audit_worktree (line 1544), the two gates that consume stranded_submodules (the [ "$stranded_submodules" = '[]' ] conjuncts on the fast-forward and on tracking_update_eligible), and stepFailure in scripts/render-audit-report.ts.

What the code does: list_submodules_with_local_work is the only thing that ever names a submodule it cannot read. On an unreadable submodule it writes cannot read submodule <path> or cannot inspect submodule <path> to stderr and returns 1. The update-mode call redirects that stderr to /dev/null, so the only surviving signal is stranded_submodules=null. Nothing re-derives it: the removal-mode call (line 1052) does append its stderr to $gate_error_path, but that call only runs for in-root linked worktrees that reach the removal gates, so a primary or default-branch checkout never gets one.

What goes wrong: stranded_submodules=null fails both [ "$stranded_submodules" = '[]' ] conjuncts, so the checkout gets no fast-forward and no selector move. The renderer converts the null into exactly one string — "submodule check failed; checkout not updated" — with no submodule path and no error text, and the JSON record carries nothing more (strandedSubmodules is just null). A default checkout with one broken submodule therefore silently stops being updated on every later run, and the owner has no way to learn which submodule or why.

The trigger is not exotic: git -C "$submodule_path" rev-parse --verify --quiet HEAD fails whenever a populated submodule has a dangling .git gitdir pointer, a stale core.worktree, or an unborn HEAD (a git init-ed or empty-remote submodule). The change's own test "a merged worktree is kept while its submodules hold work that exists only there" builds exactly this state by pointing a submodule's core.worktree at a missing directory and asserts the shell reports cannot read submodule dependency — but only on the removal path. The update path throws the same message away.

Evidence: static trace of the shell and the renderer, plus direct runs of the walker against fixtures I built here (bash -c 'source audit-checkouts.sh; list_submodules_with_local_work <repo> update'), which returned rc=0 with empty output for repos with no gitlinks and rc=1 with the cannot read submodule line on stderr for an unreadable one. jq is not installed in this sandbox, so I could not run the whole driver and observe the rendered markdown end to end; the renderer string is quoted directly from scripts/render-audit-report.ts.

Safe correction: redirect that stderr to a file the way the removal call does, and carry its first line into the report (for example a strandedSubmodulesError field the renderer appends to "submodule check failed").

What would refute it: another consumer of the walker's stderr for checkouts that are not removal candidates, or a strandedSubmodules shape that already carries the failing path.

claim 01M3BJVR540DX0TTTF5985H5RH of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJVR540DX0TTTF5985H5RH --> **medium** — The update-mode submodule walk discards its own diagnostics, so a failed walk blocks every fast-forward with an unactionable "submodule check failed" row lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: `list_submodules_with_local_work` and `submodule_holds_local_work` (scripts/audit-checkouts.sh, ~lines 483-550), the call site in `audit_worktree` (line 1544), the two gates that consume `stranded_submodules` (the `[ "$stranded_submodules" = '[]' ]` conjuncts on the fast-forward and on `tracking_update_eligible`), and `stepFailure` in scripts/render-audit-report.ts. > > What the code does: `list_submodules_with_local_work` is the only thing that ever names a submodule it cannot read. On an unreadable submodule it writes `cannot read submodule <path>` or `cannot inspect submodule <path>` to stderr and returns 1. The `update`-mode call redirects that stderr to `/dev/null`, so the only surviving signal is `stranded_submodules=null`. Nothing re-derives it: the removal-mode call (line 1052) does append its stderr to `$gate_error_path`, but that call only runs for in-root linked worktrees that reach the removal gates, so a primary or default-branch checkout never gets one. > > What goes wrong: `stranded_submodules=null` fails both `[ "$stranded_submodules" = '[]' ]` conjuncts, so the checkout gets no fast-forward and no selector move. The renderer converts the null into exactly one string — `"submodule check failed; checkout not updated"` — with no submodule path and no error text, and the JSON record carries nothing more (`strandedSubmodules` is just `null`). A default checkout with one broken submodule therefore silently stops being updated on every later run, and the owner has no way to learn which submodule or why. > > The trigger is not exotic: `git -C "$submodule_path" rev-parse --verify --quiet HEAD` fails whenever a populated submodule has a dangling `.git` gitdir pointer, a stale `core.worktree`, or an unborn HEAD (a `git init`-ed or empty-remote submodule). The change's own test "a merged worktree is kept while its submodules hold work that exists only there" builds exactly this state by pointing a submodule's `core.worktree` at a missing directory and asserts the shell reports `cannot read submodule dependency` — but only on the removal path. The update path throws the same message away. > > Evidence: static trace of the shell and the renderer, plus direct runs of the walker against fixtures I built here (`bash -c 'source audit-checkouts.sh; list_submodules_with_local_work <repo> update'`), which returned rc=0 with empty output for repos with no gitlinks and rc=1 with the `cannot read submodule` line on stderr for an unreadable one. `jq` is not installed in this sandbox, so I could not run the whole driver and observe the rendered markdown end to end; the renderer string is quoted directly from scripts/render-audit-report.ts. > > Safe correction: redirect that stderr to a file the way the removal call does, and carry its first line into the report (for example a `strandedSubmodulesError` field the renderer appends to "submodule check failed"). > > What would refute it: another consumer of the walker's stderr for checkouts that are not removal candidates, or a `strandedSubmodules` shape that already carries the failing path. claim `01M3BJVR540DX0TTTF5985H5RH` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
Lines 1237-1240
@ -1123,0 +1277,7 @@
git(dependencyPath, "tag", "--no-sign", "--force", "v1");
git(dependencyPath, "checkout", "--quiet", "--detach", thirdCommit);
const refused = audit();
assert.equal(refused.trackingUpdate.attempted, true);
assert.equal(refused.trackingUpdate.ok, false);
assert.match(refused.trackingUpdate.error, /local tag v1 .* no other ref holds/);

low — The tag-selector test never exercises the "another ref holds it" half of update_submodule_tag, so that allowance can be deleted with the suite green
lens test-trimming · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new test a configured tag follows origin unless the local tag holds a commit no other ref holds in skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs, the new update_submodule_tag helper it drives in skills/audit-git-checkouts/scripts/audit-checkouts.sh, and the contract stated in references/checkout-updates.md.

What the subject says: checkout-updates.md states the rule as a disjunction -- "A local tag of the configured name on the same commit is kept as it is. One on another commit is replaced only when the fetched tag's commit contains that commit or another ref holds it." The helper implements exactly that, with two independent escape hatches:

  if ! git -C "$submodule_path" merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null \
    && [ -z "$(git -C "$submodule_path" for-each-ref --format='%(refname)' --contains "$local_commit" 2>/dev/null | grep -v -x -F "refs/tags/$tag")" ]; then
    echo "local tag $tag in $submodule_path holds commit $local_commit that no other ref holds; not replacing it with origin's $fetched_commit" >>"$error_path"
    return 1
  fi

What the test does: its two "followed" stages re-tag v1 in the upstream submodule at a descendant of the local tag's commit, so merge-base --is-ancestor succeeds and the first hatch alone permits the replacement. The anchored "refused" stage builds the opposite case -- commitDetached(dependencyPath) creates localCommit as a child of thirdCommit, then git tag --force v1 moves the local tag onto it, so localCommit is not an ancestor of the fetched commit and refs/tags/v1 is the only ref that holds it. Both branches of the && are false, and the refusal is asserted. No stage ever reaches the state where merge-base --is-ancestor fails but another ref does hold $local_commit -- the only state in which the second hatch decides the outcome.

What goes wrong: the for-each-ref --contains clause -- half of the documented predicate, and the half the test's own title names -- is asserted by nothing. I confirmed this by mutation. Baseline: node --test skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs gives 64 tests / 63 pass / 0 fail. Deleting the entire second clause, so the condition reduces to if ! git ... merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null; then, still gives 64 tests / 63 pass / 0 fail. Under that mutant the driver refuses every selector update whose local tag commit is not an ancestor of the fetched tag, even when a branch or another tag still holds it -- so a routine re-tag onto a sibling line reports trackingUpdate.ok: false with "no other ref holds", the submodule stops floating, and the report shows a failure the owner cannot act on. That regression ships green. For contrast, the self-exclusion grep -v -x -F "refs/tags/$tag" inside that same clause is protected: deleting it fails this test (1 failure), because without it the local tag itself would count as "another ref".

Suggested repair (not deletion -- this test protects the refusal correctly): extend it with one more stage that distinguishes the hatches. After the existing refused stage, point a second ref at localCommit inside the submodule -- git(dependencyPath, "branch", "keep", localCommit) -- and audit() again, asserting trackingUpdate.ok === true and git rev-parse v1^{commit} === thirdCommit (the tag now follows origin because refs/heads/keep keeps the local commit). That stage reuses the existing fixture and kills the surviving mutant above.

What would refute the claim: another test that makes merge-base --is-ancestor fail while a non-refs/tags/<tag> ref holds the local tag's commit and asserts the tag is replaced. I found none in either changed test file, and the mutation run above is the decisive evidence that none exists.

claim 01M3BKCTPMEA51K2Z53GH578NA of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BKCTPMEA51K2Z53GH578NA --> **low** — The tag-selector test never exercises the "another ref holds it" half of update_submodule_tag, so that allowance can be deleted with the suite green lens `test-trimming` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new test `a configured tag follows origin unless the local tag holds a commit no other ref holds` in `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs`, the new `update_submodule_tag` helper it drives in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, and the contract stated in `references/checkout-updates.md`. > > What the subject says: `checkout-updates.md` states the rule as a disjunction -- "A local tag of the configured name on the same commit is kept as it is. One on another commit is replaced only when the fetched tag's commit contains that commit or another ref holds it." The helper implements exactly that, with two independent escape hatches: > > ```sh > if ! git -C "$submodule_path" merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null \ > && [ -z "$(git -C "$submodule_path" for-each-ref --format='%(refname)' --contains "$local_commit" 2>/dev/null | grep -v -x -F "refs/tags/$tag")" ]; then > echo "local tag $tag in $submodule_path holds commit $local_commit that no other ref holds; not replacing it with origin's $fetched_commit" >>"$error_path" > return 1 > fi > ``` > > What the test does: its two "followed" stages re-tag `v1` in the upstream submodule at a descendant of the local tag's commit, so `merge-base --is-ancestor` succeeds and the first hatch alone permits the replacement. The anchored "refused" stage builds the opposite case -- `commitDetached(dependencyPath)` creates `localCommit` as a *child* of `thirdCommit`, then `git tag --force v1` moves the local tag onto it, so `localCommit` is not an ancestor of the fetched commit and `refs/tags/v1` is the only ref that holds it. Both branches of the `&&` are false, and the refusal is asserted. No stage ever reaches the state where `merge-base --is-ancestor` fails but another ref does hold `$local_commit` -- the only state in which the second hatch decides the outcome. > > What goes wrong: the `for-each-ref --contains` clause -- half of the documented predicate, and the half the test's own title names -- is asserted by nothing. I confirmed this by mutation. Baseline: `node --test skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs` gives 64 tests / 63 pass / 0 fail. Deleting the entire second clause, so the condition reduces to `if ! git ... merge-base --is-ancestor "$local_commit" "$fetched_commit" 2>/dev/null; then`, still gives 64 tests / 63 pass / 0 fail. Under that mutant the driver refuses every selector update whose local tag commit is not an ancestor of the fetched tag, even when a branch or another tag still holds it -- so a routine re-tag onto a sibling line reports `trackingUpdate.ok: false` with "no other ref holds", the submodule stops floating, and the report shows a failure the owner cannot act on. That regression ships green. For contrast, the self-exclusion `grep -v -x -F "refs/tags/$tag"` inside that same clause *is* protected: deleting it fails this test (1 failure), because without it the local tag itself would count as "another ref". > > Suggested repair (not deletion -- this test protects the refusal correctly): extend it with one more stage that distinguishes the hatches. After the existing refused stage, point a second ref at `localCommit` inside the submodule -- `git(dependencyPath, "branch", "keep", localCommit)` -- and `audit()` again, asserting `trackingUpdate.ok === true` and `git rev-parse v1^{commit} === thirdCommit` (the tag now follows origin because `refs/heads/keep` keeps the local commit). That stage reuses the existing fixture and kills the surviving mutant above. > > What would refute the claim: another test that makes `merge-base --is-ancestor` fail while a non-`refs/tags/<tag>` ref holds the local tag's commit and asserts the tag is replaced. I found none in either changed test file, and the mutation run above is the decisive evidence that none exists. claim `01M3BKCTPMEA51K2Z53GH578NA` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
Lines 1408-1412
@ -1123,0 +1405,8 @@
assert.match(unreadable.removal.error, /cannot read submodule dependency/);
execFileSync("git", ["config", "--file", join(dependencyGitDir, "config"), "--unset", "core.worktree"]);
// Nor must a submodule whose status query fails, even with uncommitted work the superproject cannot see.
writeFileSync(join(dependencyPath, "README.md"), "uncommitted\n");
const dependencyIndex = join(dependencyGitDir, "index");
const healthyIndex = readFileSync(dependencyIndex);
writeFileSync(dependencyIndex, "not an index");

medium — The new submodule removal-gate test stages uncommitted submodule work but only asserts the status-query-failure path, so the uncommitted-files and stash gates are unprotected
lens test-trimming · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new test a merged worktree is kept while its submodules hold work that exists only there in skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs, the new submodule_holds_local_work helper it drives in skills/audit-git-checkouts/scripts/audit-checkouts.sh, and the contract the change writes into references/removal-gates.md.

What the subject says: removal-gates.md enumerates four independent keeping conditions -- "A submodule keeps the worktree (judgment/submodule-local-work, paths in removal.error) when it has uncommitted files, a stash, a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds." The removal branch of the helper implements them in order:

  output=$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head" refs/remotes) || return 2
  [ -z "$output" ] && return 0
  output=$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all) || return 2
  [ -n "$output" ] && return 0
  git -C "$submodule_path" rev-parse --verify --quiet refs/stash >/dev/null && return 0
  output=$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes) || return 2
  [ -n "$output" ] && return 0

The test walks the submodule through five states and asserts the outcome each time: HEAD held by no remote-tracking ref, a wip tag on an unreachable commit, a recorded-but-unheld Gitlink, an unreadable submodule, and a submodule whose status query fails. The anchored stage is the only one that puts uncommitted content in the submodule -- and it simultaneously corrupts $GIT_DIR/index with writeFileSync(dependencyIndex, "not an index"), so git status exits non-zero and the helper returns 2. The assertions that follow are operational/gate-check-failed and /cannot inspect submodule dependency/, i.e. the error path, not the keeping path. Two lines later the index is restored and git(dependencyPath, "checkout", "--quiet", "--", "README.md") discards the uncommitted file, so the final assert.equal(removed.removal.outcome, "removed") stage runs against a clean submodule. No stage anywhere in the suite creates a stash in a submodule (grep -n 'stash' audit-checkouts.test.mjs finds only superproject stash tests unrelated to this gate).

What goes wrong: the two middle gates are asserted by nothing, while the test's title and the staged "uncommitted\n" write read as if they are covered. I confirmed this by mutation, running the whole file (node --test skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs, baseline 64 tests / 63 pass / 0 fail):

  • Deleting only [ -n "$output" ] && return 0 after the status --porcelain call (keeping the call so the || return 2 error path is unchanged): 64 tests, 63 pass, 0 fail.
  • Deleting the whole rev-parse --verify --quiet refs/stash ... && return 0 line: 64 tests, 63 pass, 0 fail.

So a regression that lets audit-checkouts.sh --remove delete a linked worktree whose submodule holds uncommitted edits, untracked files, or a stash -- work that git worktree remove --force destroys along with the submodule's Git directory, and which the superproject cannot even see because the fixture's .gitmodules sets ignore = all -- ships green. For contrast, the gates the test does assert are protected: removing the fast-forward stranded gate fails 4 tests, removing the local-tag self-exclusion fails 1.

Suggested repair (not deletion -- the test protects real behaviour): add two stages to this same test, before the index-corruption stage, while HEAD is held by refs/remotes/origin/main and no local branch or tag exists. First write an uncommitted README.md, writeResult(), and assert judgment/submodule-local-work with removal.error.trim() === "dependency"; then restore the file, git stash a change inside the submodule, and assert the same outcome, dropping the stash afterwards. Both stages reuse the existing fixture and runMaybeRemove helper, and each kills one of the two surviving mutants above.

What would refute the claim: another test that drives submodule_holds_local_work (or maybe_remove_worktree) with a dirty-but-readable submodule or a submodule stash and asserts a non-removal outcome. I found none, and the two mutation runs above are the decisive evidence that none exists.

claim 01M3BK8QQK8CCEE9262AAYWE8A of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BK8QQK8CCEE9262AAYWE8A --> **medium** — The new submodule removal-gate test stages uncommitted submodule work but only asserts the status-query-failure path, so the uncommitted-files and stash gates are unprotected lens `test-trimming` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new test `a merged worktree is kept while its submodules hold work that exists only there` in `skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs`, the new `submodule_holds_local_work` helper it drives in `skills/audit-git-checkouts/scripts/audit-checkouts.sh`, and the contract the change writes into `references/removal-gates.md`. > > What the subject says: `removal-gates.md` enumerates four independent keeping conditions -- "A submodule keeps the worktree (`judgment/submodule-local-work`, paths in `removal.error`) when it has uncommitted files, a stash, a branch or tag commit no remote-tracking ref holds, or a HEAD no remote-tracking ref holds." The removal branch of the helper implements them in order: > > ```sh > output=$(git -C "$submodule_path" for-each-ref --count=1 --contains "$head" refs/remotes) || return 2 > [ -z "$output" ] && return 0 > output=$(git -C "$submodule_path" status --porcelain --untracked-files=normal --ignore-submodules=all) || return 2 > [ -n "$output" ] && return 0 > git -C "$submodule_path" rev-parse --verify --quiet refs/stash >/dev/null && return 0 > output=$(git -C "$submodule_path" rev-list -n 1 --branches --tags --not --remotes) || return 2 > [ -n "$output" ] && return 0 > ``` > > The test walks the submodule through five states and asserts the outcome each time: HEAD held by no remote-tracking ref, a `wip` tag on an unreachable commit, a recorded-but-unheld Gitlink, an unreadable submodule, and a submodule whose `status` query fails. The anchored stage is the only one that puts uncommitted content in the submodule -- and it simultaneously corrupts `$GIT_DIR/index` with `writeFileSync(dependencyIndex, "not an index")`, so `git status` exits non-zero and the helper returns 2. The assertions that follow are `operational/gate-check-failed` and `/cannot inspect submodule dependency/`, i.e. the error path, not the keeping path. Two lines later the index is restored and `git(dependencyPath, "checkout", "--quiet", "--", "README.md")` discards the uncommitted file, so the final `assert.equal(removed.removal.outcome, "removed")` stage runs against a clean submodule. No stage anywhere in the suite creates a stash in a submodule (`grep -n 'stash' audit-checkouts.test.mjs` finds only superproject stash tests unrelated to this gate). > > What goes wrong: the two middle gates are asserted by nothing, while the test's title and the staged `"uncommitted\n"` write read as if they are covered. I confirmed this by mutation, running the whole file (`node --test skills/audit-git-checkouts/scripts/audit-checkouts.test.mjs`, baseline 64 tests / 63 pass / 0 fail): > > - Deleting only `[ -n "$output" ] && return 0` after the `status --porcelain` call (keeping the call so the `|| return 2` error path is unchanged): 64 tests, 63 pass, 0 fail. > - Deleting the whole `rev-parse --verify --quiet refs/stash ... && return 0` line: 64 tests, 63 pass, 0 fail. > > So a regression that lets `audit-checkouts.sh --remove` delete a linked worktree whose submodule holds uncommitted edits, untracked files, or a stash -- work that `git worktree remove --force` destroys along with the submodule's Git directory, and which the superproject cannot even see because the fixture's `.gitmodules` sets `ignore = all` -- ships green. For contrast, the gates the test does assert are protected: removing the fast-forward stranded gate fails 4 tests, removing the local-tag self-exclusion fails 1. > > Suggested repair (not deletion -- the test protects real behaviour): add two stages to this same test, before the index-corruption stage, while HEAD is held by `refs/remotes/origin/main` and no local branch or tag exists. First write an uncommitted `README.md`, `writeResult()`, and assert `judgment/submodule-local-work` with `removal.error.trim() === "dependency"`; then restore the file, `git stash` a change inside the submodule, and assert the same outcome, dropping the stash afterwards. Both stages reuse the existing fixture and `runMaybeRemove` helper, and each kills one of the two surviving mutants above. > > What would refute the claim: another test that drives `submodule_holds_local_work` (or `maybe_remove_worktree`) with a dirty-but-readable submodule or a submodule stash and asserts a non-removal outcome. I found none, and the two mutation runs above are the decisive evidence that none exists. claim `01M3BK8QQK8CCEE9262AAYWE8A` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
@ -143,6 +146,7 @@ function branchLabel(worktree: Worktree): string {
function stepFailure(worktree: Worktree, fetched: boolean): string | null {
if (worktree.statusError !== null) return `status query failed: ${firstLine(worktree.statusError)}`;
if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";

medium — stepFailure returns "submodule check failed; checkout not updated" for every worktree, hiding a linked worktree's real removal outcome and dropping it from all other report sections
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: stepFailure and the per-worktree loop in formatAuditReport (scripts/render-audit-report.ts), and the shell that produces the fields it reads (audit_worktree line 1544 and decide_removal_outcome lines 1051-1062 plus the case "$outcome" at line 1153 in scripts/audit-checkouts.sh).

What the code does: the new first line of stepFailure is if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";. It runs ahead of every other branch in that function, and formatAuditReport calls stepFailure for every in-root worktree, not just primaries and default-branch checkouts:

const failure = stepFailure(worktree, report.fetched);
if (failure !== null) { failures.push([path, failure]); continue; }

What goes wrong, for a linked worktree on a feature branch:

  1. The message is wrong on its face. A linked non-default worktree is never a fast-forward or selector-move candidate — stranded_submodules gates only the fast_forward_mode and tracking_update_eligible conditions, both of which additionally require branch.current == default_branch. "checkout not updated" tells the owner an update was withheld when none was ever pending.

  2. It hides the specific diagnosis the shell did capture. When a submodule is unreadable, both walker calls fail the same way, so decide_removal_outcome returns operational/gate-check-failed with cannot read submodule <path> in removal.error (the change's own test asserts exactly that: assert.match(unreadable.removal.error, /cannot read submodule dependency/)). Because the new check precedes if (outcome.startsWith("operational/")), the row reads submodule check failed; checkout not updated instead of removal check failed (gate-check-failed): cannot read submodule dependency. Before this change the specific message was what the report printed, so this is a straight loss of the actionable text.

  3. The continue drops the worktree from every other section. It never reaches keptReason, so a merged worktree also blocked by a lock or by precious ignored files loses that reason, and it never reaches the activity branches, so it vanishes from "Possibly abandoned", "Active work", and "Activity unknown" as well. The worktree count still includes it, so the Summary and the tables disagree about it.

Failure scenario, concretely: a linked worktree ~/dev/app-x on branch x whose populated submodule dep has a dangling .git gitdir pointer. The driver records strandedSubmodules: null and removal.outcome: "operational/gate-check-failed" with removal.error: "cannot read submodule dep\n". The report shows a single row | app-x | submodule check failed; checkout not updated |, and app-x appears in no other table — no path to the broken submodule, and no hint that it was a removal candidate at all.

Evidence: static trace of scripts/render-audit-report.ts against the JSON shape written by audit_worktree; jq is absent from this sandbox so I could not run the driver and render a real report, and the new render tests only cover the default-branch case (strandedSubmodules: null on a worktree they mark as the repository's checkout) — no test exercises a linked worktree with a null value.

Safe correction: move the null check after the removal-outcome branches, and scope its wording to checkouts that were actually update candidates (or drop the ", checkout not updated" clause and append the captured submodule path instead).

claim 01M3BJWCTMM2QXQWEPKZA7TSHJ of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJWCTMM2QXQWEPKZA7TSHJ --> **medium** — stepFailure returns "submodule check failed; checkout not updated" for every worktree, hiding a linked worktree's real removal outcome and dropping it from all other report sections lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: `stepFailure` and the per-worktree loop in `formatAuditReport` (scripts/render-audit-report.ts), and the shell that produces the fields it reads (`audit_worktree` line 1544 and `decide_removal_outcome` lines 1051-1062 plus the `case "$outcome"` at line 1153 in scripts/audit-checkouts.sh). > > What the code does: the new first line of `stepFailure` is `if (worktree.strandedSubmodules === null) return "submodule check failed; checkout not updated";`. It runs ahead of every other branch in that function, and `formatAuditReport` calls `stepFailure` for *every* in-root worktree, not just primaries and default-branch checkouts: > > ``` > const failure = stepFailure(worktree, report.fetched); > if (failure !== null) { failures.push([path, failure]); continue; } > ``` > > What goes wrong, for a linked worktree on a feature branch: > > 1. The message is wrong on its face. A linked non-default worktree is never a fast-forward or selector-move candidate — `stranded_submodules` gates only the `fast_forward_mode` and `tracking_update_eligible` conditions, both of which additionally require `branch.current == default_branch`. "checkout not updated" tells the owner an update was withheld when none was ever pending. > > 2. It hides the specific diagnosis the shell did capture. When a submodule is unreadable, both walker calls fail the same way, so `decide_removal_outcome` returns `operational/gate-check-failed` with `cannot read submodule <path>` in `removal.error` (the change's own test asserts exactly that: `assert.match(unreadable.removal.error, /cannot read submodule dependency/)`). Because the new check precedes `if (outcome.startsWith("operational/"))`, the row reads `submodule check failed; checkout not updated` instead of `removal check failed (gate-check-failed): cannot read submodule dependency`. Before this change the specific message was what the report printed, so this is a straight loss of the actionable text. > > 3. The `continue` drops the worktree from every other section. It never reaches `keptReason`, so a merged worktree also blocked by a lock or by precious ignored files loses that reason, and it never reaches the activity branches, so it vanishes from "Possibly abandoned", "Active work", and "Activity unknown" as well. The worktree count still includes it, so the Summary and the tables disagree about it. > > Failure scenario, concretely: a linked worktree `~/dev/app-x` on branch `x` whose populated submodule `dep` has a dangling `.git` gitdir pointer. The driver records `strandedSubmodules: null` and `removal.outcome: "operational/gate-check-failed"` with `removal.error: "cannot read submodule dep\n"`. The report shows a single row `| app-x | submodule check failed; checkout not updated |`, and `app-x` appears in no other table — no path to the broken submodule, and no hint that it was a removal candidate at all. > > Evidence: static trace of scripts/render-audit-report.ts against the JSON shape written by `audit_worktree`; `jq` is absent from this sandbox so I could not run the driver and render a real report, and the new render tests only cover the default-branch case (`strandedSubmodules: null` on a worktree they mark as the repository's checkout) — no test exercises a linked worktree with a null value. > > Safe correction: move the null check after the removal-outcome branches, and scope its wording to checkouts that were actually update candidates (or drop the ", checkout not updated" clause and append the captured submodule path instead). claim `01M3BJWCTMM2QXQWEPKZA7TSHJ` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
Lines 322-326
@ -311,2 +319,8 @@
const isMain = worktree.registration?.isMain === true;
const onDefault = worktree.status?.branch.isDetached === false && worktree.status.branch.current === defaultBranch;
const stranded = worktree.strandedSubmodules ?? [];
if ((isMain || onDefault) && stranded.length > 0) {
const why = `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated`;
decisions.push([path, branch, formatAheadBehind(worktree.defaultComparison), why]);
continue;
}

medium — The stranded-submodule row overrides primaryDecision for every primary checkout, so detached and off-default primaries lose their real reason and are compared against the wrong line
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: the new block in formatAuditReport (scripts/render-audit-report.ts, the if ((isMain || onDefault) && stranded.length > 0) branch), primaryDecision in the same file (cases current-pinned-reference, pinned-reference-needs-attention, and the default: arm), and the two gates in scripts/audit-checkouts.sh that stranded_submodules actually controls (fast_forward_mode, lines 1553-1565, and tracking_update_eligible, lines 1599-1614).

What the code does: the new branch fires on isMain || onDefault, i.e. on any primary checkout, regardless of what that primary is checked out at, and it continues so primaryDecision never runs. It renders formatAheadBehind(worktree.defaultComparison) in the Ahead/behind cell and the fixed phrase ... ; not updated in Why.

What the shell actually gates: both conjuncts guarded by [ "$stranded_submodules" = '[]' ] also require [ "$(jq -r '.branch.isDetached == false' "$status_path")" = true ] and [ "$(jq -r '.branch.current // empty' "$status_path")" = "$default_branch" ]. So for a primary checkout that is detached, or on a branch other than the default, the stranded list withheld nothing — that checkout was never a fast-forward or selector-move candidate in the first place.

What goes wrong, for a primary checkout that is a pinned reference (detached at a tag or commit) and has a stranded submodule:

  • The Why cell claims "not updated", inventing a suppressed update that was never possible. The correct decision text for that checkout is primaryDecision's pinned-reference-needs-attention / current-pinned-reference wording ("detached checkout is behind or off ", "detached checkout with N uncommitted files"), which the continue discards.
  • The Ahead/behind cell uses defaultComparison, while every other code path for a pinned reference deliberately uses checkoutComparison — the two primaryDecision cases above are explicit about it. The reader is shown the distance from origin/<default>, which is not the line a pinned checkout tracks, labelled as though it were.
  • A pinned reference that is genuinely behind its own line, or a primary checkout sitting on the wrong branch (default: arm: "primary checkout is on X, not main"), loses that actionable reason entirely and gets a submodule note instead.

Failure scenario: a primary checkout vendor/tool detached at tag v3.1 with a populated submodule dep on a local commit no ref holds, and upstream now at v4.0. Before the change the row read | vendor/tool | (detached) | +0/-N | detached checkout is behind or off refs/tags/v4.0 |. Now it reads | vendor/tool | (detached) | <defaultComparison> | submodule on a commit no ref holds: dep; not updated | — the behind-its-line signal is gone and the comparison shown is against a different target.

Safe correction: restrict the branch to the checkouts the shell gates — require onDefault (not isMain) — or, if primaries should still surface stranded submodules, append the submodule note to primaryDecision's result instead of replacing it, and keep the comparison primaryDecision chose for that classification.

Evidence and proof gap: static trace of the renderer against the gate conditions in the shell. jq is not installed in this sandbox, so I could not run the driver and diff two rendered reports; the two new render tests cover only an onDefault worktree with deferredOnly: true and a null-valued one, so neither exercises a detached or off-default primary.

claim 01M3BJZHXK440NSNFW2AJ720B6 of review 01M3BJ3S1CY1F1S8BW4PD0P9FC

<!-- review:claim:01M3BJZHXK440NSNFW2AJ720B6 --> **medium** — The stranded-submodule row overrides primaryDecision for every primary checkout, so detached and off-default primaries lose their real reason and are compared against the wrong line lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: the new block in `formatAuditReport` (scripts/render-audit-report.ts, the `if ((isMain || onDefault) && stranded.length > 0)` branch), `primaryDecision` in the same file (cases `current-pinned-reference`, `pinned-reference-needs-attention`, and the `default:` arm), and the two gates in scripts/audit-checkouts.sh that `stranded_submodules` actually controls (`fast_forward_mode`, lines 1553-1565, and `tracking_update_eligible`, lines 1599-1614). > > What the code does: the new branch fires on `isMain || onDefault`, i.e. on *any* primary checkout, regardless of what that primary is checked out at, and it `continue`s so `primaryDecision` never runs. It renders `formatAheadBehind(worktree.defaultComparison)` in the Ahead/behind cell and the fixed phrase `... ; not updated` in Why. > > What the shell actually gates: both conjuncts guarded by `[ "$stranded_submodules" = '[]' ]` also require `[ "$(jq -r '.branch.isDetached == false' "$status_path")" = true ]` and `[ "$(jq -r '.branch.current // empty' "$status_path")" = "$default_branch" ]`. So for a primary checkout that is detached, or on a branch other than the default, the stranded list withheld nothing — that checkout was never a fast-forward or selector-move candidate in the first place. > > What goes wrong, for a primary checkout that is a pinned reference (detached at a tag or commit) and has a stranded submodule: > > - The Why cell claims "not updated", inventing a suppressed update that was never possible. The correct decision text for that checkout is `primaryDecision`'s `pinned-reference-needs-attention` / `current-pinned-reference` wording ("detached checkout is behind or off <target>", "detached checkout with N uncommitted files"), which the `continue` discards. > - The Ahead/behind cell uses `defaultComparison`, while every other code path for a pinned reference deliberately uses `checkoutComparison` — the two `primaryDecision` cases above are explicit about it. The reader is shown the distance from `origin/<default>`, which is not the line a pinned checkout tracks, labelled as though it were. > - A pinned reference that is genuinely behind its own line, or a primary checkout sitting on the wrong branch (`default:` arm: "primary checkout is on X, not main"), loses that actionable reason entirely and gets a submodule note instead. > > Failure scenario: a primary checkout `vendor/tool` detached at tag `v3.1` with a populated submodule `dep` on a local commit no ref holds, and upstream now at `v4.0`. Before the change the row read `| vendor/tool | (detached) | +0/-N | detached checkout is behind or off refs/tags/v4.0 |`. Now it reads `| vendor/tool | (detached) | <defaultComparison> | submodule on a commit no ref holds: dep; not updated |` — the behind-its-line signal is gone and the comparison shown is against a different target. > > Safe correction: restrict the branch to the checkouts the shell gates — require `onDefault` (not `isMain`) — or, if primaries should still surface stranded submodules, append the submodule note to `primaryDecision`'s result instead of replacing it, and keep the comparison `primaryDecision` chose for that classification. > > Evidence and proof gap: static trace of the renderer against the gate conditions in the shell. `jq` is not installed in this sandbox, so I could not run the driver and diff two rendered reports; the two new render tests cover only an `onDefault` worktree with `deferredOnly: true` and a null-valued one, so neither exercises a detached or off-default primary. claim `01M3BJZHXK440NSNFW2AJ720B6` of review `01M3BJ3S1CY1F1S8BW4PD0P9FC`
jercik marked this conversation as resolved
Author
Owner

Replying to review summary comment #87417

Round-7 outcomes for review 01M3BJ3S1CY1F1S8BW4PD0P9FC (head 0cdf081). From round 6, only data loss, data corruption, or a security hole gets a new push. None of these findings is in that class, so all are acknowledged, and nothing is pushed for this round.

Real report and usability bugs left unfixed, for a follow-up PR. None of them loses data. Each either keeps a worktree or mislabels a row.

  • 88014: a failed submodule check (strandedSubmodules: null) turns any worktree into a failure row, including a linked worktree, whose removal outcome then disappears. In render-audit-report.ts stepFailure, apply the null check only to primary and default-branch checkouts.
  • 88015: the stranded-submodule row runs before primaryDecision, so a detached or off-default primary loses its own reason. In formatAuditReport, run the stranded check only on the current-default, default-needs-attention and manual-review classifications, or append its note to the primaryDecision reason.
  • --force needs a .gitmodules file (summary only): a merged worktree with a populated Gitlink but no .gitmodules is refused by Git on every run. In decide_removal_outcome, choose --force when the walk found any populated Gitlink, not when .gitmodules exists.
  • 88012 (same as 87935): the update walk discards its stderr, so the failure row cannot say why.
  • 87934 (from round 6): upstream tags inside a submodule can block removal. The fix is in the round-6 reply.

Checked for data loss and ruled out:

  • 88020: a swallowed ls-files failure. An unreadable superproject index also fails the final superproject status check, so removal stops as operational/gate-check-failed.

Deferred wording and test items, fixes as in the round-4 to 6 replies: 88009, 88010, 88011, 88016, 88017, 88018, 88019, 88013, 88021, and "schema 5" in --help. For 88013, the removal test should add a case with uncommitted files and one with a stash, each on a readable submodule. For 88021, a tag-selector case should cover a local tag whose commit another ref holds.

> Replying to review summary comment #87417 Round-7 outcomes for review `01M3BJ3S1CY1F1S8BW4PD0P9FC` (head 0cdf081). From round 6, only data loss, data corruption, or a security hole gets a new push. None of these findings is in that class, so all are acknowledged, and nothing is pushed for this round. **Real report and usability bugs left unfixed, for a follow-up PR.** None of them loses data. Each either keeps a worktree or mislabels a row. - **88014:** a failed submodule check (`strandedSubmodules: null`) turns any worktree into a failure row, including a linked worktree, whose removal outcome then disappears. In `render-audit-report.ts` `stepFailure`, apply the null check only to primary and default-branch checkouts. - **88015:** the stranded-submodule row runs before `primaryDecision`, so a detached or off-default primary loses its own reason. In `formatAuditReport`, run the stranded check only on the `current-default`, `default-needs-attention` and `manual-review` classifications, or append its note to the `primaryDecision` reason. - **`--force` needs a `.gitmodules` file (summary only):** a merged worktree with a populated Gitlink but no `.gitmodules` is refused by Git on every run. In `decide_removal_outcome`, choose `--force` when the walk found any populated Gitlink, not when `.gitmodules` exists. - **88012 (same as 87935):** the update walk discards its stderr, so the failure row cannot say why. - **87934 (from round 6):** upstream tags inside a submodule can block removal. The fix is in the round-6 reply. **Checked for data loss and ruled out:** - **88020:** a swallowed `ls-files` failure. An unreadable superproject index also fails the final superproject status check, so removal stops as `operational/gate-check-failed`. **Deferred wording and test items**, fixes as in the round-4 to 6 replies: 88009, 88010, 88011, 88016, 88017, 88018, 88019, 88013, 88021, and "schema 5" in `--help`. For 88013, the removal test should add a case with uncommitted files and one with a stash, each on a readable submodule. For 88021, a tag-selector case should cover a local tag whose commit another ref holds.
jercik merged commit 23be83a417 into main 2026-09-25 06:44:53 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
j4k-oss/agent-skills!78
No description provided.