fix(audit-git-checkouts): submodule gates should keep what origin lacks and remove what it holds #81
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/audit-submodule-followups"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes the five submodule bugs deferred on #78, and applies the wording and test items deferred on #78, #79, and #80.
It also closes five paths by which removal deleted a submodule's unique content without inspecting it: ignored files inside the submodule, a Git directory left by
git submodule deinit, the submodule's own linked worktrees, a tag on a blob or tree, and files under a Gitlink path with no checkout. Each now keeps the worktree as a decision.--forcenow follows Git's own refusal (a populated Gitlink or amodules/directory) instead of the presence of.gitmodules. Upstream tags inside a submodule no longer block removal; only a tag that origin lacks, or names for another object, does.🤖 Generated with Claude Code
Review
01M3CM491QHBEFY37DG68PZJCJ— head5a8ae47b63ed2dafd3a08bc8efb6ed25cd34a1b8Review — j4k-oss/agent-skills @
19897a019bScope: diff against base tree
08872ffc9b56Status: dispatched — coverage complete (3/3 slots terminal)
Facts: current review-wide projection
Computed under:
Findings (2)
low —
(not checked out)resolution uses<modules>and<path>placeholders the entry doesn't supply, and gives no way to map the listed name to a checkout path01M3CM8KYQHXHR3WX1YYHYJTA5skills/audit-git-checkouts/references/removal-gates.md(snippet)low — --help says submodule Git directories are deleted only once every commit is held by a remote-tracking ref, but the gate also accepts commits that only an origin tag holds
01M3CMGDAWQGRCQQGWEHE4R0MGskills/audit-git-checkouts/scripts/audit-checkouts.sh(snippet)Other claims
01M3CM81NTXYWX1GTSFFCSKDD4low — Default-mode "What changes" cell ties submodule moves to the fast-forward and no longer says Gitlink changes are left unstaged01M3CM8MB3E5S2SCKBHJK8EFNJlow — Resolution forjudgment/submodule-local-workdoesn't cover(files without a checkout)entries: those files can't be pushed or "discarded in the submodule"Coverage
Coverage pass: 01M3CM494T6QVACD7TVNYQXJCM
Accounting: complete
Slot health: healthy
@ -5,3 +5,3 @@## What the driver already updatesThe 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.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. 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 in the submodule 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. When that submodule check itself fails, the driver changes nothing in the checkout, and its failure row says the submodule check failed and names the submodule it could not read: make that submodule readable, then rerun.medium — checkout-updates.md promises the failure row always names an unreadable submodule, but several failure paths show a Git error or the superproject index instead
lens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C1GV4Z8640Q8HW35Y6SF16of review01M3C1D15AZRH3JXFPN27W5W94Fixed in
dc5750e.references/checkout-updates.mdnow says the row quotes the first line of the error, which names the submodule when one could not be read and otherwise quotes Git, and points atstrandedSubmodulesErrorfor the full text.@ -19,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. 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.Git refuses to remove a worktree with a populated Gitlink or a submodule Git directory under the worktree's own `modules/`, with or without `.gitmodules`, so the driver removes such a worktree with `--force`; the repeated status check replaces the check `--force` disables. Removal also deletes each submodule's Git directory, local branches and stash included, so immediately before the second 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 HEAD or branch commit no remote-tracking ref holds, or a tag on such a commit that origin lacks or names for another object. A Gitlink names a commit without holding it, so a recorded HEAD needs a remote-tracking ref too. A submodule Git directory with no checkout, such as one `git submodule deinit` leaves, cannot be inspected, so it keeps the worktree too, listed as `<name> (not checked out)`. Ignored files inside submodules are not scanned.low — removal-gates.md's submodule tag clause ("a tag on such a commit that origin lacks or names for another object") is hard to parse and can be read too narrowly
lens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C1J0848CBQ4B86RAC5K4CZof review01M3C1D15AZRH3JXFPN27W5W94Fixed in
dc5750ewith the proposed wording, extended for the tag cases this round added (a tag that names no commit; a lightweight tag matching origin's tag on its commit).@ -46,3 +46,3 @@| `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. || `judgment/submodule-local-work` | The listed submodules hold work only this worktree has. Show the owner what each holds; rerun once the work is pushed. If the owner authorizes discarding it instead, discard it in the submodule first, then rerun. For a `(not checked out)` entry, run `git submodule update --init` in the worktree first, so the next run can inspect it. |medium —
(not checked out)remedy fails for a submodule removed withgit rm, so the worktree stays kept on every runlens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C1MZJYWWWCG1R79GZRSW8Jof review01M3C1D15AZRH3JXFPN27W5W94Fixed in
dc5750e. Reproduced: aftergit rm -fthe worktree'smodules/<name>stays andgit submodule update --initrepopulates nothing. The gate stays conservative; thejudgment/submodule-local-workrow inreferences/removal-gates.mdnow covers that case: show the owner what the Git directory holds (git --git-dir <dir> --work-tree <dir> log --oneline --branches --tags --not --remotes, andstash list; plain--git-dirfails because the directory'score.worktreenames the deleted checkout), delete it only on their authorization, then rerun.@ -491,3 +503,4 @@# remote-tracking ref, and the submodule must have no uncommitted# files, no stash, and no branch or tag commit that no# files, no stash, no branch commit that no remote-tracking ref# holds, and no tag that origin lacks whose commit no# remote-tracking ref holds.low — Header of list_submodules_with_local_work states the removal tag rule without the "origin names another object" case that the code and docs enforce
lens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C1HZT9GR87D52JE69SHKTWof review01M3C1D15AZRH3JXFPN27W5W94Fixed in
dc5750e. The header now states the full rule, and the inner comment keeps only the reason.@ -1553,6 +1644,7 @@ function createActivityFixture(context, headActionDate = "2001-09-09T01:46:40Z")branch: { current: "feature", isDetached: false },workingTree: { isClean: false, files: { staged: [], unstaged: ["sample.txt"], untracked: [] } },}));// A shim ending in a semicolon yields `;;` and a bash parse error, not a test failure.low — Shim comment in createActivityFixture says a trailing
;causes "not a test failure", but the bash parse error does fail the test, and the rule is no longer statedlens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C1HBWP6ZA0NK30ET1WR7YQof review01M3C1D15AZRH3JXFPN27W5W94Fixed in
dc5750ewith the proposed comment: passshimwithout a trailing;, since;;makes bash fail to parse before any assertion runs.@ -19,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. 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.Git refuses to remove a worktree with a populated Gitlink or a submodule Git directory under the worktree's own `modules/`, with or without `.gitmodules`, so the driver removes such a worktree with `--force`; the repeated status check replaces the check `--force` disables. Removal also deletes each submodule's Git directory, local branches and stash included, so immediately before the second 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 linked worktree of its own, a HEAD or branch commit no remote-tracking ref holds, or a tag that names no commit or whose commit no remote-tracking ref holds, unless origin has the same tag on the same object (a lightweight tag also matches origin's tag on its commit). A Gitlink names a commit without holding it, so a recorded HEAD needs a remote-tracking ref too. Files under a Gitlink path with no checkout keep the worktree as `<path> (files without a checkout)`, because status never lists them. A submodule Git directory with no checkout, such as one `git submodule deinit` or `git rm` leaves under `$(git -C <worktree> rev-parse --path-format=absolute --git-path modules)`, is not inspected, so it keeps the worktree too, listed as `<name> (not checked out)`. Ignored files inside submodules are not scanned.low — The
(not checked out)guidance never ties the reported<name>to the<dir>its commands act onlens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C301QE717D7BGXPB4QHAAQof review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6. The paragraph defines<name>as the directory's path relative tomodules(the submodule's name, not its checkout path), and the row acts on<modules>/<name>.@ -46,3 +46,3 @@| `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. || `judgment/submodule-local-work` | The listed submodules hold work only this worktree has. Show the owner what each holds; rerun once the work is pushed. If the owner authorizes discarding it instead, discard it in the submodule first, then rerun. For a `(not checked out)` entry whose submodule still has a Gitlink, as after `git submodule deinit`, run `git submodule update --init` in the worktree so the next run can inspect it. When no Gitlink is left, as after `git rm`, show the owner what the Git directory holds (`git --git-dir <dir> --work-tree <dir> log --oneline --branches --tags --not --remotes` and `… stash list`; `--work-tree` overrides the deleted checkout its config names), delete the directory only on their authorization, then rerun. |medium — The
(not checked out)resolution hides or moves a detached HEAD before the owner sees it, so the prescribed cleanup can lose the work the gate protectslens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C2ZN0Y2DAMM8YJHGS8XTPCof review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6. Confirmedlogwith explicit revisions omits HEAD. The(not checked out)row now inspects<modules>/<name>first withlog --oneline HEAD --branches --tags --not --remotesandstash list, and only then, on the owner's decision, restores that one path withgit submodule update --init -- <path>or deletes the directory.@ -31,0 +29,4 @@echo " a fast-forward or selector move while a submodule, at any depth, sits on a commit"echo " that its superproject does not record and no ref in the submodule holds;"echo " replacing a local selector tag unless the fetched tag or another ref holds its commit;"echo " removing a worktree while a submodule holds a commit, stash, or edit that origin lacks,"medium —
--helpand the removal comment promise origin holds submodule work, but the gate accepts any remote-tracking reflens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C2Z5Z5PX6K1VFMXRFZR2N3of review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6.--helpand the walker's comment now say a commit no remote-tracking ref holds, or a tag origin lacks, matching the checks.@ -508,3 +525,5 @@continuefi# 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); thenprintf 'cannot read submodule %s\n' "$prefix$gitlink_path" >&2low — A failed submodule walk puts Git's
fatal:line first, so the report row never names the unreadable submodule, contrary to checkout-updates.mdlens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C30G287WV92PRMJP0EZ9FRof review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6. Reproduced: Git'sfatal:line came first. The walk now printscannot read submodule <path>: <Git's message>(andcannot inspect submodule <path>: …) as one line, and the driver tests assert on the first line.@ -1424,0 +1483,7 @@git(dependencyPath, "tag", "--no-sign", "--force", "-a", "v0.9", "-m", "local v0.9", "v0.9^{commit}");assert.equal(remove().removal.outcome, "judgment/submodule-local-work");git(dependencyPath, "update-ref", "refs/tags/v0.9", upstreamTag);const blob = execFileSync("git", ["-C", dependencyPath, "hash-object", "-w", "--stdin"], { input: "private note\n", encoding: "utf8" }).trim();git(dependencyPath, "tag", "--no-sign", "note", blob);assert.equal(remove().removal.outcome, "judgment/submodule-local-work");low — Blob-tag step in the submodule tag test cannot catch loss of the non-commit-tag guard, because v0.9 already forces the origin comparison
lens
test-trimming· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C2YN0C0SA5RJ9VDKEMWXRXof review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6. The blob-tag step now runs withv0.9deleted, so only the blob tag can reach the origin comparison; removing the non-commit guard now fails the test.@ -201,3 +207,3 @@if (comparison.ahead > 0) return `${count(comparison.ahead, "local commit")} not pushed${dirtyNote}`;if (dirty > 0 && comparison.behind > 0) {return `behind; uncommitted changes (${count(dirty, "file")}) block the fast-forward. A dirty checkout fast-forwards only when every changed path is AGENTS.md or under .agents/, at any depth, or first-party submodule metadata, and upstream changed none of those paths.`;return `behind; uncommitted changes (${count(dirty, "file")}) block the fast-forward (see references/checkout-updates.md)`;low — User-facing report cell points to
references/checkout-updates.md, a skill-internal path that the report's reader cannot resolvelens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C30H25YYX0RHFA1116NG7Rof review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6. The cell ends at "block the fast-forward"; SKILL.md already routes that decision to the reference.@ -335,2 +341,2 @@if ((isMain || onDefault) && stranded.length > 0) {const why = `submodule on a commit no ref holds: ${listPaths(stranded)}; not updated`;if (onDefault && stranded.length > 0) {const why = `submodules on a commit no ref holds: ${listPaths(stranded)}; not updated`;low — "Needs your decision" row always says "submodules", even when it lists one path
lens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3C30HGPK6WN4VMZW092SAYEof review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6. One path reads "submodule on a commit no ref holds", several read "submodules on commits no ref holds"; the render test covers both.@ -46,3 +46,3 @@| `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. || `judgment/submodule-local-work` | The listed submodules hold work only this worktree has. Show the owner what each holds; rerun once the work is pushed. If the owner authorizes discarding it instead, discard it in the submodule first, then rerun. For a `(not checked out)` entry whose submodule still has a Gitlink, as after `git submodule deinit`, run `git submodule update --init` in the worktree so the next run can inspect it. When no Gitlink is left, as after `git rm`, show the owner what the Git directory holds (`git --git-dir <dir> --work-tree <dir> log --oneline --branches --tags --not --remotes` and `… stash list`; `--work-tree` overrides the deleted checkout its config names), delete the directory only on their authorization, then rerun. |medium — Removal guidance asserts unique submodule work where the driver has not inspected it
lens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CJ43ZDW3V7V9B1W24Y8JESof review01M3C2TJ98PWK7BAJMKTCHQMQ1medium — Reinitialization instruction updates every submodule in the worktree
lens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CJ9FW2QGN1ES7CNTGMRJW7of review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6. The report reads "submodules may hold work that exists only here", and the reference row says the listed submodules hold such work or content the driver could not inspect.Fixed in
f1da8d6. The row now restores one path,git submodule update --init -- <path>, and only after inspecting it.@ -31,1 +31,4 @@echo " replacing a local selector tag unless the fetched tag or another ref holds its commit;"echo " removing a worktree while a submodule holds a commit, stash, or edit that origin lacks,"echo " or while files or a submodule Git directory in it have no checkout to inspect."echo "Branch refs and stashes are never deleted."medium — Help promises branch refs survive although submodule refs can be removed
lens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CJ67MC4VNGTTDF0E34WPDEof review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6.--helpnow limits the promise to the audited repositories' branch refs and stashes, and says a removed worktree takes its submodules' Git directories, local branches included, once every commit there is held by a remote-tracking ref.@ -1099,5 +1193,6 @@# is-it-clean safeguard inside worktree remove. Never -f -f: that also# bypasses Git's unclean refusal.# Git refuses a populated Gitlink or a modules/ directory, not .gitmodules.worktree_remove_args=(worktree remove)if [ -e "$worktree_path/.gitmodules" ]; thenif [ -n "$populated_git_dirs" ] || [ -d "$modules_path" ]; thenworktree_remove_args+=(--force)high — Forced removal deletes ignored files inside a Gitlink checkout
lens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CJAVKF798P7SV0A4EHP3BAof review01M3C2TJ98PWK7BAJMKTCHQMQ1Fixed in
f1da8d6. Reproduced with a plain-clone Gitlink holding an ignored.env. The removal gate now scans ignored files in each populated submodule, at any depth, with the same rules as the worktree's own scan, against the primary checkout's copy of that submodule; a precious one keeps the worktree asjudgment/precious-ignored-files(for exampledependency/.env). New test: "ignored files inside a submodule are scanned like the worktree's own".@ -5,3 +5,3 @@## What the driver already updatesThe 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.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. 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 in the submodule 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. When that submodule check itself fails, the driver changes nothing in the checkout, and its failure row says the submodule check failed, followed by the first line of the error. That line names the submodule when one could not be read and otherwise quotes Git; `strandedSubmodulesError` in the JSON record holds the full text. Repair what it names, then rerun.low — checkout-updates.md narrates the wording of the submodule-check failure row that the agent already sees in the report
lens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CK2F90945GX05Z9MBEAA3Fof review01M3CJXYC691YPN10BHMK7PR00Fixed in
88273cdwith the proposed wording.@ -19,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. 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.Git refuses to remove a worktree with a populated Gitlink or a submodule Git directory under the worktree's own `modules/`, with or without `.gitmodules`, so the driver removes such a worktree with `--force`; the repeated status check replaces the check `--force` disables. Removal also deletes each submodule's Git directory, local branches and stash included, so immediately before the second 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 linked worktree of its own, a HEAD or branch commit no remote-tracking ref holds, or a tag that names no commit or whose commit no remote-tracking ref holds, unless origin has the same tag on the same object (a lightweight tag also matches origin's tag on its commit). A Gitlink names a commit without holding it, so a recorded HEAD needs a remote-tracking ref too. Files under a Gitlink path with no checkout keep the worktree as `<path> (files without a checkout)`, because status never lists them. A submodule Git directory with no checkout, such as one `git submodule deinit` or `git rm` leaves under `$(git -C <worktree> rev-parse --path-format=absolute --git-path modules)`, is not inspected, so it keeps the worktree too, listed as `<name> (not checked out)`: `<name>` is the directory's path relative to that `modules` directory, which is the submodule's name, not its checkout path. Ignored files inside each populated submodule are scanned like the worktree's own, against the primary checkout's copy of that submodule, and precious ones keep the worktree as `judgment/precious-ignored-files` with paths relative to the worktree.low — Submodule removal-gate paragraph packs five blockers, two outcomes, and three
removal.errorentry forms into one run of proselens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CK25TEKJN30QYDF0Z4SGXTof review01M3CJXYC691YPN10BHMK7PR00Fixed in
88273cd. The--forcerationale is its own paragraph, the blockers are one bullet perremoval.errorform with the tag exception on the tag item, and ignored files in submodules get their own sentence.@ -32,0 +29,6 @@echo " a fast-forward or selector move while a submodule, at any depth, sits on a commit"echo " that its superproject does not record and no ref in the submodule holds;"echo " replacing a local selector tag unless the fetched tag or another ref holds its commit;"echo " removing a worktree while a submodule holds a stash, an edit, a commit no remote-tracking"echo " ref holds, or a tag origin lacks, or while files or a submodule Git directory in it"echo " have no checkout to inspect."medium —
--helpsays removal refuses any submodule tag origin lacks, but a local tag on a pushed commit is deleted without refusallens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CK1S2686KAF9CGEEVDCTN3of review01M3CJXYC691YPN10BHMK7PR00Fixed in
88273cdwith the proposed wording:--helpnow names a linked worktree, precious ignored files, and a tag on a commit no remote-tracking ref holds that origin lacks.@ -552,0 +617,4 @@if [ -n "$primary_path" ] && [ -e "$primary_path/$gitlink_path/.git" ]; thensubmodule_primary="$primary_path/$gitlink_path"fiif ! list_precious_ignored_paths "$submodule_path" "$submodule_primary" "$scratch.status" "$scratch.error" >"$scratch.precious"; thenlow — The submodule ignored-file scan matches AUDIT_CHECKOUTS_REGENERABLE_IGNORED against submodule-relative paths, not the documented worktree-relative ones
lens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CK8MTB5JR5H0N24X0JEH1Aof review01M3CJXYC691YPN10BHMK7PR00Fixed in
88273cd. Reproduced:.tool-cache/exempteddependency/.tool-cache/. The submodule scan now classifies each path with the submodule's path as a prefix, so built-in andAUDIT_CHECKOUTS_REGENERABLE_IGNOREDglobs see worktree-relative paths; the test covers both the root-only glob (still precious) anddependency/.tool-cache/(exempt).@ -22,0 +26,4 @@- `<path> (files without a checkout)`: files under a Gitlink path that has no checkout, which status never lists.- `<name> (not checked out)`: a submodule Git directory that no checkout uses, such as one `git submodule deinit` or `git rm` leaves under `$(git -C <worktree> rev-parse --path-format=absolute --git-path modules)`. The driver does not inspect it. `<name>` is its path relative to that `modules` directory, which is the submodule's name, not its checkout path.Ignored files inside each populated submodule are scanned like the worktree's own, against the primary checkout's copy of that submodule, with paths and `AUDIT_CHECKOUTS_REGENERABLE_IGNORED` globs relative to the worktree; precious ones keep the worktree as `judgment/precious-ignored-files`.low — Submodule ignored-file scanning is described as "like the worktree's own", but it runs only in the removal gate and never appears in the diagnostic
ignoredScanlens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CKNMSKPYRCBY73KKT1ZJM4of review01M3CKGAA518KBQ3S8DYGAZZKF@ -32,0 +34,5 @@echo " origin lacks, or while files or a submodule Git directory in it have no checkout to"echo " inspect."echo "The audited repositories' branch refs and stashes are never deleted. Removing a"echo "worktree deletes its submodules' Git directories, local branches included, once every"echo "commit there is held by a remote-tracking ref."low —
--helpsays submodule Git directories are deleted only once every commit is held by a remote-tracking ref, but a commit held only by an origin tag also qualifieslens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CKMB3NGD4D3W36QRR7MD57of review01M3CKGAA518KBQ3S8DYGAZZKF@ -493,1 +505,9 @@# remote-tracking ref holds.# directories, so only commits remote-tracking refs hold, and tags# origin has, survive; a recorded Gitlink names a commit without# holding it. HEAD must be held by a remote-tracking ref, and the# submodule must have no uncommitted# files, no stash, no linked worktree, no branch commit that no# remote-tracking ref holds, and no tag on a non-commit or on a# commit no remote-tracking ref holds unless origin has that tag# on the same object. A Gitlink path without a checkout must be# empty.low —
list_submodules_with_local_workheader comment misses the lightweight-tag exception, contradicts its own "populated submodule" scope, and is broken mid-sentencelens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CKMPYNTF6MYKABB37GYB9Yof review01M3CKGAA518KBQ3S8DYGAZZKF@ -1065,0 +1192,5 @@echo operational/gate-check-failedreturnfiif [ -d "$modules_path" ]; thenif ! unchecked_git_dirs=$(list_submodule_git_dirs_without_checkout "$modules_path" "$populated_git_dirs" 2>>"$gate_error_path"); thenmedium — Removal gate only looks for checkout-less submodule Git dirs under the worktree's own modules/, missing ones inside a non-absorbed submodule's .git, which --force then deletes
lens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CKTQ0NCBZ4S0CZ69KDDDEAof review01M3CKGAA518KBQ3S8DYGAZZKFFixed in
5a8ae47. Reproduced end to end throughmaybe_remove_worktree: the worktree was removed and the nestedwipbranch with it. The gate now also checks themodulesdirectory inside every populated submodule's Git directory that lies outside the worktree's ownmodules/, and lists such an entry by its worktree-relative path (dependency/.git/modules/inner (not checked out)). New test: "a nested submodule Git directory without a checkout keeps the worktree when its parent is not absorbed".Round-4 outcomes for review
01M3CKGAA518KBQ3S8DYGAZZKF(head88273cd). From round 4 only clear, severe bugs get a push; 89341 (data loss) is fixed in5a8ae47. The other three are real but change only wording, so they are acknowledged and deferred to a follow-up PR:references/removal-gates.md, say the removal gate scans ignored files inside submodules, that precious ones joinremoval.ignoredScan.preciousPathsas worktree-relative paths, and that the diagnosticignoredScancovers the worktree's own files only.scripts/audit-checkouts.shusage(), end the closing sentence at "…deletes its submodules' Git directories, local branches and tags included" and let the Refuses list carry the conditions.scripts/audit-checkouts.sh, open thelist_submodules_with_local_workheader with "each submodule path at any depth … in removal mode a Gitlink path with files but no checkout prints as<path> (files without a checkout)", add the lightweight-tag exception to its tag clause, and reflow the paragraph.@ -46,3 +54,3 @@| `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. || `judgment/submodule-local-work` | The listed submodules hold work only this worktree has, or content the driver could not inspect. Show the owner what each holds; rerun once the work is pushed. If the owner authorizes discarding it instead, discard it in the submodule first, then rerun. For a `(not checked out)` entry, first show the owner what `<modules>/<name>` (or the listed worktree-relative path) holds, before anything checks it out: `git --git-dir <modules>/<name> --work-tree <modules>/<name> log --oneline HEAD --branches --tags --not --remotes` and `… stash list` (`--work-tree` overrides the deleted checkout its config names). Then, on their decision, restore the checkout with `git submodule update --init -- <path>` while a Gitlink remains, or delete the directory, and rerun. |low —
(not checked out)resolution uses<modules>and<path>placeholders the entry doesn't supply, and gives no way to map the listed name to a checkout pathlens
writing-quality· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CM8KYQHXHR3WX1YYHYJTA5of review01M3CM491QHBEFY37DG68PZJCJ@ -32,0 +34,5 @@echo " origin lacks, or while files or a submodule Git directory in it have no checkout to"echo " inspect."echo "The audited repositories' branch refs and stashes are never deleted. Removing a"echo "worktree deletes its submodules' Git directories, local branches included, once every"echo "commit there is held by a remote-tracking ref."low — --help says submodule Git directories are deleted only once every commit is held by a remote-tracking ref, but the gate also accepts commits that only an origin tag holds
lens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M3CMGDAWQGRCQQGWEHE4R0MGof review01M3CM491QHBEFY37DG68PZJCJRound-5 outcomes for review
01M3CM491QHBEFY37DG68PZJCJ(head5a8ae47). From round 5 only clear, severe bugs get a push. Both findings are real but change only wording, so nothing is pushed; they are acknowledged and deferred to a follow-up PR:references/removal-gates.md, name<modules>once in the(not checked out)bullet as the output ofgit -C <worktree> rev-parse --path-format=absolute --git-path modules, and in the resolution row say<path>isgit config -f .gitmodules submodule.<name>.path, run inside the enclosing submodule for a nested entry.scripts/audit-checkouts.shusage(), end the closing sentence at "…deletes its submodules' Git directories, local branches and tags included", so it no longer claims every deleted commit is on a remote-tracking ref; the Refuses list already carries the tag condition.