Skip to content

fix(git-submodules): a bump that would drop a nested checkout's work refuses (plugin-task#54) - #55

Merged
github-actions[bot] merged 1 commit into
mainfrom
feat/nested-work-refusal
Oct 26, 2026
Merged

github-actions[bot] merged 1 commit into
mainfrom
feat/nested-work-refusal

Conversation

@aitrawog

Copy link
Copy Markdown
Contributor

Summary

charly task sync (the verb:git-submodules bump) rolls a materialized path and then reconciles that path's OWN nested checkouts with git submodule update --recursive. That update moves a nested checkout onto the gitlink the parent records, and it does not ask: when the nested checkout's HEAD carries a commit that NO branch, tag or remote-tracking ref holds, the update takes the checkout off that commit, exits 0, reports nothing, and leaves the operator's commit in the reflog alone.

This PR makes the bump REFUSE that move — naming the nested checkout, the commit and the recorded pin — and it refuses BEFORE anything moves, so the tree is left exactly as it was.

Head 6b9ed301d3ff25516264a523d5dd2ee471301e98 — 4 files changed, 336 insertions(+), 13 deletions(-).

file what it carries
candy/plugin-task/verbs.go the judgement — treeGitlinks, judgeNestedLevel, judgeNestedCheckout, pinNote — and pinSubmodule split into fetch → judge → switch → stage → reconcile
candy/plugin-task/prune.go the reader's one other call site
candy/plugin-task/nested_work_test.go the gate: the refusal, and the branch-held work that must still go through
candy/plugin-task/verbs_test.go the one live mention of the reader's old name, re-keyed

THE MEASUREMENT — four shapes, git 2.56, a real umbrella → outer → inner fixture over file:// remotes:

nested checkout at the update the local commit
the recorded pin, behind exit 0 nothing to lose
the recorded pin + a commit on a branch exit 0 kept by that branch
the recorded pin + an ORPHAN commit exit 0 DROPPED, reported by nothing
the recorded pin + a dirty file exit 1 git refuses, loudly

So the loss is the update's blind spot, not git's doing: the same update refuses a DIRTY nested checkout loudly, and a branch keeps its commit across the move. The rule that follows is local and needs no fetch — a nested checkout may be moved when the commit it is at will still be reachable: when the move goes FORWARD onto it (merge-base --is-ancestor), or when a ref in that checkout still contains it (for-each-ref --contains).

THE FIX. Before the switch and before the pin is staged, the nested checkouts are judged. A refusal stops the whole bump with the tree untouched: the nested checkout still holds the operator's commit, the superproject's pin was not staged, and the submodule's own checkout was not moved. The fetch and the switch are two commands for exactly that reason.

The judgement reads the gitlinks the TARGET commit records, and falls back to the checkout's own .gitmodules only when that tree cannot be read here — a tree that records no submodule is a fact about the tree, and a tree that cannot be read is a different fact.

The reader of a .gitmodules is now gitmodulesPaths(dir): the same reader serves the project's own .gitmodules and the one a submodule declares for its own nested checkouts, so there is one reader and one shape. The old name is gone in the same cut, and the live sweep is in How tested below.

Closes #54.

How tested

The change class is a runtime change in a compiled-in plugin's verb, so the gate is this repo's own gate list, run on the committed tree this PR publishes. Every gate below ran on that tree, and every one exited 0.

The head this body describes

$ git rev-parse HEAD
6b9ed301d3ff25516264a523d5dd2ee471301e98
$ git diff --shortstat origin/main HEAD
 4 files changed, 336 insertions(+), 13 deletions(-)
$ git status --short --untracked-files=no
exit=0

R7 — the coverage fails without the change. The pre-change verbs.go and prune.go are restored into a scratch copy of the package, and the new tests are run against them: the refusal test FAILS, because the bump goes through and the nested checkout is moved off the operator's commit.

$ git show HEAD~1:candy/plugin-task/verbs.go > /tmp/pt54-r7b/pkg/verbs.go
exit=0
$ git show HEAD~1:candy/plugin-task/prune.go > /tmp/pt54-r7b/pkg/prune.go
exit=0
$ cd /tmp/pt54-r7b/pkg && go test -count=1 -run "TestGitSubmodules_BumpRefusesToDropNestedWork|TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds" .
--- FAIL: TestGitSubmodules_BumpRefusesToDropNestedWork (0.33s)
    nested_work_test.go:96: the bump must REFUSE a nested checkout whose commit the update would drop, got pass: pinned 0 pin_map entr(y|ies) () after rolling 1 submodule(s) to their default-branch HEAD and advancing 0 pin(s) in the index
FAIL
FAIL	github.com/opencharly/plugin-task/candy/plugin-task	0.711s
FAIL
exit=1

And passes with it — the second half is the DISCRIMINATING half: the same work held by a branch is not lost by the update, so the bump must go through, and a verb that refused every nested checkout would not pass this.

$ go test -count=1 -v -run "TestGitSubmodules_BumpRefusesToDropNestedWork|TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds" .
=== RUN   TestGitSubmodules_BumpRefusesToDropNestedWork
--- PASS: TestGitSubmodules_BumpRefusesToDropNestedWork (0.25s)
=== RUN   TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds
--- PASS: TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds (0.32s)
PASS
ok  	github.com/opencharly/plugin-task/candy/plugin-task	0.584s
exit=0

The repo's Go gates, on the committed tree

$ go build ./...
go build exit=0

$ gofmt -l .
gofmt exit=0

$ go vet ./...
go vet exit=0

$ go test ./...
ok  	github.com/opencharly/plugin-task/candy/plugin-task	4.575s
?   	github.com/opencharly/plugin-task/candy/plugin-task/cmd/serve	[no test files]
?   	github.com/opencharly/plugin-task/candy/plugin-task/params	[no test files]
go test exit=0

The linter, at the version CI pins (v2.13.2, read from .github/workflows/ci.yml), under the toolchain this module's go.mod declares:

$ GOTOOLCHAIN=go1.26.8 golangci-lint run
0 issues.
LINT_RC=0

The structural check

$ charly box validate
charly box validate: OK — checked 1 candy, 7 distros, 7 builders; 0 warnings, 0 errors
exit=0

The live gate — scripts/gate-task.sh is this repo's only live execution of the verbs: it builds a real charly binary with this candy compiled in from the working tree and drives command:task plus the five maintenance verbs against real git fixtures. Its whole output is pasted here.

== fetching charly main (compiles candy/plugin-task in) ==
Cloning into '/tmp/plugin-task-gate.fLwlOn/charly'...
coherence OK: charly main's sdk/spec pins lead this module's requires
== building charly with candy/plugin-task compiled in from the working tree ==
== charly task list ==
task failtask: 1 step(s), 1 failed, 0 skipped
  [fail] run — this fails
      exit=1, want 0 (stderr: )
charly: error: command "task": task "failtask" failed (1 step(s) failed)
== a step that ran nothing is counted in its own bucket ==
   | task skipped-step: 1 step(s), 0 failed, 1 skipped
   |   [skip] run — this step ran nothing
   |       skip: true
   a step that ran nothing is counted, disclosed, and non-fatal
== a SILENT task still discloses the step that ran nothing ==
   | task silent-skip: 2 step(s), 0 failed, 1 skipped
   |   [skip] run — the silent step that ran nothing
   |       skip: true
   the silent path keeps its meaning and still discloses the skip
== verb:task nested hop (a composite must carry the leaf step's own reason) ==
   | task composite: 1 step(s), 1 failed, 0 skipped
   |   [fail] run — the outer step (a nested task step)
   |       task "leafreason" failed (1 step(s) failed, 0 skipped
   |         [fail] run — the leaf step (its Message is the reason)
   |             exit=3, want 0 (stderr: leaf-reason: boom on stderr))
   | charly: error: command "task": task "composite" failed (1 step(s) failed)
   the composite carried the leaf step's reason AND its path across the hop
== a param reaches a step as DATA (the shell must not parse it) ==
   | task datachannel: 1 step(s), 0 failed, 0 skipped
   |   [pass] run — the param arrives in the step's process environment, verbatim
   |       exit=0
   the param arrived as data: verbatim, and unexecuted
== verb:git-submodules verify (stale -> must FAIL) ==
task pins-verify: 1 step(s), 1 failed, 0 skipped
  [fail] check — the pin holds
      verify: 1 failure(s): pin mismatch: target (9c92ff42f33ab4577f487c107cd0a96658cf636c) != ../src's target (d9342862a9f25925148dd50a49f2c1c5336f17ac)
charly: error: command "task": task "pins-verify" failed (1 step(s) failed)
== verb:git-submodules bump (must move the pin to src's twin) ==
== verb:git-submodules verify (now current -> must PASS) ==
== verb:task nested hop (a composite must carry the nested run's OWN report) ==
   | task pins-verify-nested: 1 step(s), 0 failed, 0 skipped
   |   [pass] run — the outer step (a nested task step)
   |       verify: 1 policy pin(s) hold across 1 submodule(s); 1 checkout(s) clean at their gitlink
   the composite carried the nested pin report across the hop
== verb:git-submodules verify (missing twin -> must FAIL loudly, never vacuous) ==
task missing-pin: 1 step(s), 1 failed, 0 skipped
  [fail] check — the missing twin fails
      verify: 1 failure(s): no gitlink for "nope/absent" in "../src"
charly: error: command "task": task "missing-pin" failed (1 step(s) failed)
== verb:git-submodules bump (unmaterialized submodule -> the pin advances IN THE INDEX, no checkout) ==
   | task bump-all: 1 step(s), 0 failed, 0 skipped
   |   [pass] run — bump
   |       pinned 0 pin_map entr(y|ies) () after rolling 0 submodule(s) to their default-branch HEAD and advancing 1 pin(s) in the index
   unmaterialized pin advanced in the index (9c92ff42f33ab4577f487c107cd0a96658cf636c -> d9342862a); no checkout created; umbrella untouched (main @ 2b92d1ede)
== verb:git-submodules status (uninitialized submodule -> '- (uninitialized)', never the umbrella HEAD) ==
   status reported '- (uninitialized)'; umbrella HEAD (2b92d1e) not leaked as a pin
== verb:git-submodules bump (pinned_from with NO CHECKOUT and no 'branch=' -> the refusal names the missing checkout) ==
   | task bump-from-source: 1 step(s), 1 failed, 0 skipped
   |   [fail] run — bump
   |       1 pin(s) FAILED: refused to pin 1 pin_map entr(y|ies): pinned_from "src" has no checkout, so its twin gitlinks cannot be read — materialize it with `git submodule update --init src`; rolled 0 submodule(s) to their default-branch HEAD; advanced 0 pin(s) in the index; pinned 0 pin_map entr(y|ies): 
   | charly: error: command "task": task "bump-from-source" failed (1 step(s) failed)
   the refusal named the missing checkout and the materialize command; the stale diagnosis is gone
== verb:git-submodules bump ORDER (pinned_from is itself rolled first) ==
== verb:git-submodules currency (after the ordered bump every pin must be CURRENT) ==
== verb:git-submodules currency (an edge over an edge must be REFUSED, never answered) ==
== verb:git-submodules currency (an EXPLICIT edge over an edge must be REFUSED too) ==
== verb:git-submodules currency (stale_after: a pin NEGLECTED past the bound must FAIL) ==
   | task currency-stale: 1 step(s), 1 failed, 0 skipped
   |   [fail] check — no pin is neglected past the bound
   |       currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 9c92ff42f3; served d9342862a9 (branch); 1 STALE pin(s) (neglected for more than 168h): gap: behind, and this repo has not advanced it for 59783h (bound 168h) — advance it: mode=bump advances every pin to the ref its intent names; FAILED on the STALE pin(s) above; producer-first legs: gap (9c92ff42f3 -> d9342862a9)
   | charly: error: command "task": task "currency-stale" failed (1 step(s) failed)
== verb:git-submodules currency (stale_after: the SAME pin, advanced recently, must PASS) ==
   the same behind pin, advanced recently, passed the bound
== verb:git-submodules currency (stale_after: a NON-POSITIVE bound must be REFUSED) ==
   | task currency-nonpositive: 1 step(s), 1 failed, 0 skipped
   |   [fail] check — no pin is neglected past the bound
   |       git-submodules: stale_after "0s" is not a POSITIVE duration — a bound of zero or less marks every not-current pin stale, which is the permanently-red gate this bound exists to avoid
   | charly: error: command "task": task "currency-nonpositive" failed (1 step(s) failed)
   a bound of zero was refused, and no staleness was judged
== verb:git-submodules currency (an UNREADABLE history is not neglect) ==
   the fixture's history read genuinely fails, and the healthy fixture's succeeds
   | task currency-unreadable: 1 step(s), 1 failed, 0 skipped
   |   [fail] check — no pin is neglected past the bound
   |       currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 9c92ff42f3; served d9342862a9 (branch); 1 STRUCTURAL failure(s): gap: the age of this repo's own last advance of it could not be read (fatal: bad object refs/heads/main) — the staleness of this pin cannot be judged; producer-first legs: gap (9c92ff42f3 -> d9342862a9)
   | charly: error: command "task": task "currency-unreadable" failed (1 step(s) failed)
   an unreadable history was reported as unreadable, not as neglect
== verb:file-parity (identical must PASS, drift must FAIL) ==
task parity: 1 step(s), 1 failed, 0 skipped
  [fail] check — the pair is identical
      parity drift in 1 pair(s): r != r.copy
charly: error: command "task": task "parity" failed (1 step(s) failed)
== verb:splice-region (stale -> check must FAIL, sync -> current) ==
task splice-check: 1 step(s), 1 failed, 0 skipped
  [fail] check — the region is current
      splice-region: TARGET is stale (the marked region differs)
charly: error: command "task": task "splice-check" failed (1 step(s) failed)
== verb:module-pins (stale -> must FAIL, adopt -> must PASS) ==
task mods-check: 1 step(s), 1 failed, 0 skipped
  [fail] check — the pins hold
      stale pins: tools/m1: github.com/opencharly/sdk != v1.2.3
charly: error: command "task": task "mods-check" failed (1 step(s) failed)
== verb:prune (removes merged worktree+branch, keeps unmerged) ==
== verb:prune (uninitialized submodule -> the SUPERPROJECT is never attributed to it) ==
   uninitialized submodule contributed nothing; the project's own targets still reported
== verb:prune mode=prune with an uninitialized submodule present (must complete cleanly) ==
   mode=prune completed cleanly; unmerged kept, merged reaped, uninitialized dir intact
gate-task: PASS — command:task + all five maintenance verbs executed live against charly main
GATE_EXIT=0

R5 — the old name is gone. The live tree carries no mention of it; the one surviving hit is a historical CHANGELOG/ entry, which R5 permits.

$ git grep -n "submodulePaths" -- ":!CHANGELOG/"
grep exit=1

R4 — no doc in this repo describes the reconcile as a silent move, so the code and its comments are the only surfaces that carry the behaviour, and they were updated in the same cut:

$ git grep -n -i "reconcile" -- "*.md" ":!CHANGELOG/"
grep exit=1

Rulebook compliance

  • R0 — skills first. /charly-internals:git-workflow, /charly-internals:root-cause-analyzer, /charly-internals:strict-policy, /charly-internals:plugin, /charly-internals:go and /charly-internals:go-quality were loaded before the branch was cut and before any edit in this repo, and this repo's own AGENTS.md was read first.
  • R1 — RCA, then fix. The defect's RCA — mechanism, missed control, blast radius, root fix — is on bump: the nested reconcile moves a nested checkout that carries LOCAL WORK — the operator's commit is displaced, silently #54, filed and measured before the fix; the four-shape measurement is pasted above. The R5 sweep below found one LIVE mention of the deleted reader's name, in a test comment; that is a blocking finding under R1 and is fixed in this change. This change files no new issue.
  • R2 — no duplication. One reader serves both .gitmodules shapes: gitmodulesPaths(dir) reads the project's own and a submodule's own, and the judgement recurses through that same reader.
  • R3 — no workaround. No sleep, no retry, no fallback branch: the refusal is the product's own answer, computed from git's own reachability facts.
  • R4 — the product first. The verb's behaviour is what changed; no doc in this repo describes the old behaviour, and the code comments that describe the reconcile's guards were updated in the same cut.
  • R5 — legacy deleted. The reader's old name is gone: the live sweep above returns exit=1, and the only surviving mention is a historical CHANGELOG/ entry.
  • R6 — preserve work and git safety. git status was stated before every action; the change lives on feat/nested-work-refusal in its own worktree, off fresh origin/main; no force-push, no hook bypass, no push to main, and no other session's file, branch or worktree was touched.
  • R7 — proof, not compilation. The new test FAILS on the pre-change sources and PASSES on the committed tree — both transcripts are above — and the branch-held half of the test is what a freeze could not pass.
  • R8 — live, never mocked. The live gate builds a real charly binary with this candy compiled in and drives the real verbs against real git repositories; no boundary is stubbed.
  • R9 — the binary equals the source. The charly binary used for charly box validate was built in this session from the local checkout (-ldflags "-X main.BuildCalVer=plugin-task-pt54"), not taken from a shared install.
  • R10 — the gate on the committed tree. Every gate above ran on head 6b9ed301d3ff25516264a523d5dd2ee471301e98, the tree this PR publishes, with zero warnings.

Change classification

  • What changed: the behaviour of verb:git-submodules (the bump mode) in the compiled-in plugin-task plugin, and the scope of the reader that lists a .gitmodules' submodule paths.
  • Change class: a RUNTIME change — a plugin's verb behaviour. The diff adds one new file, the test, and modifies three: the verb's implementation, the reader's other call site, and the re-keyed comment. go.mod, go.sum, the candy's charly.yml and the CUE schema are not among them.
  • The gate this class requires: this repo's own gate list, run on the committed tree — go build, gofmt -l, go vet, go test, golangci-lint at CI's pinned version, charly box validate, and the live scripts/gate-task.sh, which is the only live execution of these verbs. Every one is pasted under How tested above, and all of them exit 0.
  • Attribution: fully tested and validated — the change-class gate passed on the final tree, and the changed path executed live against real git fixtures.

Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)

…refuses (plugin-task#54)

`git submodule update --recursive` moves a nested checkout onto the gitlink the
new commit records, and it does not ask: the checkout is taken off the commit it
is at even when that commit carries work NOTHING ELSE REFERENCES. The move exits
0, reports nothing, and the operator's commit survives only in the reflog.

MEASURED (git 2.56, a real umbrella -> outer -> inner fixture, four shapes):

    nested checkout at                      the update   the local commit
    -----------------------------------------------------------------------
    the recorded pin, behind                exit 0       nothing to lose
    the recorded pin + a commit on a branch exit 0       kept by that branch
    the recorded pin + an ORPHAN commit     exit 0       DROPPED, reported by nothing
    the recorded pin + a dirty file         exit 1       git refuses, loudly

So the loss is the update's blind spot, not git's doing: the same update refuses
a DIRTY nested checkout loudly, and a branch keeps its commit across the move.

THE FIX: the nested checkouts are JUDGED before anything moves. A checkout may
be moved when the commit it is at will still be reachable — when the move goes
forward onto it (it is an ancestor of the recorded gitlink) or when a ref in that
checkout still contains it. Otherwise the bump REFUSES, naming the path, the
commit and the recorded pin.

The judgement runs BEFORE the switch and BEFORE the pin is staged, so a refusal
leaves the tree exactly as it was: the nested checkout still holds the
operator's commit, the superproject's pin was not staged, and the submodule's
own checkout was not moved. The fetch and the switch are two commands for that
reason.

The judgement reads the gitlinks the TARGET commit records, and falls back to
the checkout's own .gitmodules only when that tree cannot be read here — a tree
that records no submodule is a fact, and a tree that cannot be read is a
different fact.

The old name is gone in the same cut: the reader is `gitmodulesPaths` now, and
the one LIVE mention of the old name -- a comment in
`candy/plugin-task/verbs_test.go` -- is re-keyed in this commit.

Closes #54.

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
@github-actions

Copy link
Copy Markdown

Review — PASS

What the diff does

candy/plugin-task/verbs.go splits pinSubmodule's combined fetch+switch into fetch → judgeNestedLevel → switch → stage → reconcileNestedCheckouts, and adds the judgement machinery (treeGitlinks, judgeNestedLevel, judgeNestedCheckout, pinNote). The .gitmodules reader is renamed submodulePaths → gitmodulesPaths(dir) and reused for both the project's own .gitmodules and a submodule's nested declaration. prune.go and the one test comment are re-keyed. nested_work_test.go adds the discriminating gate pair.

Correctness re-derived against the diff

  • The rule matches the code. judgeNestedCheckout passes when rec == head (no-op), when git merge-base --is-ancestor head rec succeeds (forward move — head stays reachable from rec), or when git for-each-ref --contains head is non-empty (a ref still holds it). Otherwise it fails, naming label and the pin. This is exactly the body's stated rule, and the direction of --is-ancestor is right: head ancestor-of rec is the safe forward case; a backward move (rec ancestor of head) correctly falls through to the ref arm and refuses unless a ref holds head.
  • Only HEAD is at risk — git submodule update moves the working HEAD and never deletes branches, so judging the unreferenced-HEAD case is the complete loss surface. No false positives found: rec == head and forward moves are short-circuited.
  • Ordering is load-bearing and honoured. Fetch touches only .git; the judge runs before the switch and before staging, so a refusal leaves the working tree, index, and the submodule checkout untouched — exactly what the test asserts (HEAD == work, gitlink(umb,"outer") == o1, outer HEAD == o1).
  • Tree-read vs unreadable is kept distinct (ok=true empty tree vs ok=false fallback to .gitmodules), as the body claims. Recursion reads the recorded commit's tree at each level, which is what --recursive will move to.
  • strings.Cut(line, "\t") correctly handles paths with spaces; sort is already in use in this file.

Tests

TestGitSubmodules_BumpRefusesToDropNestedWork sets up a real umbrella→outer→inner file:// fixture, commits an orphan in the nested checkout, asserts the fixture truly leaves it unreferenced, then requires StatusFail, the outer/inner name, and an untouched tree. TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds is the genuine discriminating half — the same work held by a branch must go through and the checkout must follow to L2, so a blanket freeze cannot pass. The R7 transcript (new test against HEAD~1 sources → FAIL; against committed tree → PASS) is internally consistent with the diff.

Honesty / consistency

  • 140 + 1 + 194 + 1 = 336 insertions; 0 + 1 + 11 + 1 = 13 deletions — matches the body's shortstat exactly.
  • Named functions and the fetch/judge/switch/stage/reconcile split all exist in the diff.
  • Both old-name call sites and the comment are re-keyed; no submodulePaths remains in the changed hunks, consistent with the R5 grep.
  • One minor imprecision, non-blocking: "the tree is left exactly as it was" is scoped to the refused submodule — with multiple submodules the bump continues past per-submodule failures (existing TestGitSubmodules_BumpContinuesPastFailure), so an earlier submodule's staged pin would remain. The test exercises the single-submodule case the claim describes.

Security / scope

No secrets, no new dependencies, no injection surface beyond the existing shellQuote-guarded git invocations; the new commands are read-only git queries. Scope is coherent: one reader, one shape, no dead code.

Comments

No comments were provided (empty thread). No maintainer sign-off is available, so the change is assessed solely on its own evidence — which is sufficient: a real-fixture test that fails without the change, a discriminating positive case, and consistent gate transcripts.

Verdict: PASS


ollama-cloud/deepseek-v4.1-flash — action-review.

View action run

@aitrawog

Copy link
Copy Markdown
Contributor Author

RESOLVED

#55 is merged as 7d3b373 and carries the tag v2026.299.2250; the record written at merge time is CHANGELOG/2026.299.2250.md. The scope it opened — a bump that would drop a nested checkout's work now refuses, naming the path, the commit and the recorded pin — is landed.

The review's one finding is not filed away; it is fixed, in #57 (for #56). The review said, in full:

One minor imprecision, non-blocking: "the tree is left exactly as it was" is scoped to the refused submodule — with multiple submodules the bump continues past per-submodule failures (existing TestGitSubmodules_BumpContinuesPastFailure), so an earlier submodule's staged pin would remain. The test exercises the single-submodule case the claim describes.

The review was right about the claim, and the mechanism is the product's own per-path failure contract (#13): a failed path is collected and the walk continues, so a path that sorts earlier has already been rolled and staged by the time the refusal is reported. The tree is not untouched; the REFUSED SUBMODULE is. The review was also right that the #54 gate could not see it: its fixture carried ONE submodule, so the scope was a prose assertion with nothing behind it.

What #56 changes — the sentence, not the behaviour, and the scope becomes mechanical:

  • the two code comments in candy/plugin-task/verbs.go (pinSubmodule and judgeNestedLevel) and the third one inside pinSubmodule's body, which the claim-keyed sweep found, now name the refused SUBMODULE;
  • the record CHANGELOG/2026.299.2250.md carries the correction beside the claim it corrects, with the measurement;
  • the bump: the nested reconcile moves a nested checkout that carries LOCAL WORK — the operator's commit is displaced, silently #54 fixture carries a SIBLING that sorts first, and the new TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule asserts the scope in both directions at once: the refused submodule's pin is not staged, its checkout is not switched and the nested checkout still holds the operator's commit, while the sibling's pin advances to the origin's tip.

The measurement, on a real two-submodule fixture — the paths sorted, so aaa-leaf is rolled FIRST and outer is refused SECOND:

bump: fail: 1 pin(s) FAILED: the nested checkout outer/inner carries a commit the update would drop — its HEAD c816a42e78bbec10ca29bf4306afa7a05d6ffd50 is in no branch, tag or remote-tracking ref of that checkout (the recorded pin is 1ee4571b7ca0263f7acc309da9983d135bb093d9), so the update would move it off that commit and leave it in the reflog alone; push it, branch it, or stash it, then re-run; rolled 1 submodule(s) to their default-branch HEAD; advanced 0 pin(s) in the index; pinned 0 pin_map entr(y|ies):

rolled 1 submodule(s) and one refusal. The refusal is real and the work survives; the sentence around it was too wide, and the wide sentence is what the follow-up removes.

The R7 proof, both ways. The new test FAILS on the pre-#54 sources — the bump PASSes, both submodules roll, the nested work is dropped and nothing is reported — and PASSes on the fixed tree. The transcript is in #57's body.

The next update is when #57 lands.

Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bump: the nested reconcile moves a nested checkout that carries LOCAL WORK — the operator's commit is displaced, silently

1 participant