Repository navigation
bump: the nested reconcile moves a nested checkout that carries LOCAL WORK — the operator's commit is displaced, silently #54
Description
What
charly task sync (verb:git-submodules bump) rolls a MATERIALIZED path and then reconciles that path's OWN nested checkouts:
// plugin-task v0.2026297.2108, candy/plugin-task/verbs.go:1479
func reconcileNestedCheckouts(projDir, path string) (spec.Status, string) {
...
hostCapture(ctx, fmt.Sprintf("git -C %s submodule update --recursive", shellQuote(abs)))
}git submodule update --recursive — the --init-less form, deliberately, so a bump does not fetch nested remotes — moves every ALREADY-INITIALIZED nested checkout onto the gitlink the parent records for it, unconditionally. It never asks whether that nested checkout carries its own work.
So a nested checkout that carries a LOCAL COMMIT is moved off that commit, with exit 0 and no report: the operator's commit is no longer at HEAD, is reachable by no branch, and survives only in the reflog.
The measurement
A fixture of the same shape the umbrella uses (file:// remotes, three levels: a superproject, a path, a nested twin), with the nested twin carrying one local commit, and the product's OWN sequence run over it.
core at: 769c6d86b668c42bdc80c47a72ac6a8b184c0a7f (record: 465a60dbd5f0f84e365e9cc67d96c65f286528f1)
core's own view:[ M inner;]
twin at: 362d0bb460bced931486edd6fef280a798d0f98e (local work: 362d0bb460bced931486edd6fef280a798d0f98e)
--- 1/2: pinSubmodule's fetch + switch --detach
FETCH_RC=0
SWITCH_RC=0
core at now: 465a60dbd5f0f84e365e9cc67d96c65f286528f1
twin at: 362d0bb460bced931486edd6fef280a798d0f98e
twin's view: []
--- 3: reconcileNestedCheckouts: git -C core submodule update --recursive
Submodule path 'inner': checked out '4bb08081d550e0fb9c86c2b764067b3b00908dde'
RECONCILE_RC=0
twin at now: 4bb08081d550e0fb9c86c2b764067b3b00908dde
local work still at HEAD: NO
local work reachable by branch:
local work in reflog: 1
core's view after: []
Read the last lines: the local commit left HEAD, no branch carries it, and the run reported success. The commit is still in the nested checkout's reflog, so nothing is destroyed — but the checkout no longer carries the operator's work, and nothing told them.
The mechanism
pinSubmodule(candy/plugin-task/verbs.go:1400) fetches andgit switch --quiet --detach <ref>s the path's own checkout. That succeeds: a nested checkout's divergence does not make the path's own switch fail, and the nested checkout is not touched by it.SWITCH_RC=0.reconcileNestedCheckouts(candy/plugin-task/verbs.go:1479) then runsgit -C <path> submodule update --recursive, and that is the step that moves the nested checkout carrying the local work onto the recorded gitlink.RECONCILE_RC=0, with one line of output naming the move.
The two steps together are the bump's path for every MATERIALIZED path (the unmaterialized path takes advancePinInIndex, which touches no checkout at all).
The missed control
Nothing asks the nested checkout for its own work before the reconcile moves it. The org already holds that check — in the umbrella's own initializer, scripts/init-submodules.sh refuses to move a nested checkout that carries its own work and REPORTS it ("a divergent nested twin is reported, not clobbered"), and that is the policy opencharly/opencharly#711 exists to extend one level up. So two implementations of the same act disagree, and the looser one is in the product: the initializer refuses and reports, the bump moves silently.
The --init-less form is not the defect — it is deliberate (a bump must not fetch nested remotes). The missing piece is the work check.
The root fix
Before the reconcile moves a nested checkout, ask the question the initializer asks — does this nested checkout carry its own work? — and if it does, refuse with the path named instead of moving it. The refusal is the same shape the initializer uses: report the path, leave the work where it is, and let the operator resolve it.
Blast radius
Every materialized path in a pin wave whose nested checkout carries local work. On the umbrella this state is NEW and reachable: the nested level was only recently materialized (opencharly/opencharly#647), so before that a nested checkout with work was rare.
The fixture
The measurement is a self-contained script over file:// remotes (no network, no product binary): a twin, a path that records it, a superproject that records the path, a local commit in the nested twin, and then the product's own three commands. It is attached to this issue's evidence.
Found while fixing opencharly/opencharly#711 — the umbrella's initializer leg — where the same asymmetry is visible from the other side.
Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
Activity
aitrawog commentedon Oct 26, 2026
CLAIM
#54 is mine, and it is MEASURED, not speculative: it was found while fixing opencharly/opencharly#711 (the umbrella's initializer leg), where the same act — moving a nested checkout onto the gitlink the parent records — is done by two implementations that DISAGREE about an operator's local work.
What I measured. The product's own sequence, over a
file://fixture of three levels, with the nested twin carrying one local commit:pinSubmodule'sgit switch --quiet --detachsucceeds (SWITCH_RC=0) — a nested checkout's divergence does not make the path's own switch fail, and the switch does not touch the nested checkout.reconcileNestedCheckouts'sgit -C <path> submodule update --recursivethen moves the nested checkout that carried the local work:Submodule path 'inner': checked out '4bb08081…',RECONCILE_RC=0.The missed control. Nothing asks the nested checkout for its own work before the reconcile moves it. The umbrella's own initializer asks exactly that and REPORTS instead of moving ("a divergent nested twin is reported, not clobbered") — so the initializer refuses and reports where the product moves silently. The
--init-less form is not the defect; it is deliberate. The missing piece is the work check.What I am NOT doing here. I am not fixing it in this session: the defect is in this repo's verb, the change I have in hand is the umbrella's script, and neither depends on the other. I am not taking the umbrella's leg either — it is a separate cutover with its own branch, its own gate and its own PR, and it will be claimed when it starts.
The next update is due when this issue's fix is cut over, or when the umbrella's #711 PR lands and this issue's evidence is quoted beside it.
Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
aitrawog commentedon Oct 26, 2026
STATUS
#54 — the measurement was re-taken on the umbrella's PR head (opencharly/opencharly#718, head
5a50ca6604052e9e0a230198ba39e9d13b412375) and the behaviour is unchanged: same shape, same verdicts. The fixture's SHAs are generated per run, so they differ from the ones in the issue above; every VERDICT is the same.What the lines say, read in order.
pinSubmodule's fetch andswitch --detachsucceed (SWITCH_RC=0) and do NOT touch the nested checkout — the twin's HEAD is still the local commit and its own view is clean, so the parent sees nothing.reconcileNestedCheckoutsis the step that moves it:RECONCILE_RC=0, one line of output naming the move, the local commit off HEAD, no branch carrying it, and the parent's own view clean afterwards. Nothing tells the operator.Where it stands. Filed and CLAIMED by this session, from the umbrella's leg opencharly/opencharly#711 → PR #718. That leg is NON-BLOCKING for this issue — the umbrella's initializer now REFUSES that checkout at the level above, and its own gate is green without this issue being fixed — so the fix belongs here, in the product, and not in that PR. The umbrella's side of the same act is the policy this issue names: the initializer asks the nested checkout for its own work before it moves anything, and refuses with the path named.
Next update. when the umbrella's PR #718 lands, and when a session takes this issue's fix.
Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
fix(init): a nested path's divergence must not hold its parent back (#…
aitrawog commentedon Oct 26, 2026
STATUS
#54 is now open as #55 — #55 — head
6b9ed301d3ff25516264a523d5dd2ee471301e98, branchfeat/nested-work-refusal.What is done. The bump now judges its nested checkouts BEFORE it moves anything. A nested checkout whose HEAD carries a commit that no branch, tag or remote-tracking ref holds is REFUSED, with the path, the commit and the recorded pin named, and the tree left 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 judgement is one rule, computed from git's own reachability facts (
merge-base --is-ancestorfor a forward move,for-each-ref --containsfor a ref that still holds the commit), and it recurses through the levels.The measurement behind it is in the PR body: four shapes over a real umbrella → outer → inner fixture — behind (nothing to lose), on a branch (kept by that branch), an ORPHAN commit (DROPPED by the update, reported by nothing), and a dirty tree (git itself refuses, loudly). The loss is the update's blind spot, and the new gate proves the fix in both directions: it FAILS on the pre-change sources and PASSES on the committed tree, and the branch-held half is what a verb that refused every nested checkout could not pass.
The gates, all on the committed tree this PR publishes:
go build,gofmt -l,go vet,go test,golangci-lintat CI's pinnedv2.13.2(0 issues),charly box validate(0 warnings, 0 errors), and the repo's live gatescripts/gate-task.sh— the only live execution of these verbs — which passes against charlymainwith this candy compiled in. The whole gate log is pasted in the body.One finding from the sweep is fixed in the same change. The R5 sweep for the deleted reader's name found one LIVE mention — a comment in
candy/plugin-task/verbs_test.go— and it is re-keyed in this change. The only surviving mention is a historicalCHANGELOG/entry, which R5 permits.Next update. when the org validator's verdict lands on #55, and at the end of each review round.
Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
fix(git-submodules): a bump that would drop a nested checkout's work …
aitrawog commentedon Oct 26, 2026
RESOLVED
#54 is resolved by #55, merged as
7d3b373with the tagv2026.299.2250; the record written at merge time isCHANGELOG/2026.299.2250.md.What landed.
charly task sync(theverb:git-submodulesbump) now judges a nested checkout's own work BEFORE it moves anything. A nested checkout whose HEAD carries a commit that no branch, tag or remote-tracking ref holds is REFUSED, with the path, the commit and the recorded pin named, and the refused submodule is left exactly as it was: its checkout is not switched, its pin is not staged, and the nested checkout still holds the operator's commit. The judgement is one rule, computed from git's own reachability facts —merge-base --is-ancestorfor a forward move,for-each-ref --containsfor a ref that still holds the commit — and it recurses through the levels.The measurement behind it is in the merged record: four shapes over a real umbrella → outer → inner fixture — behind (nothing to lose), on a branch (kept by that branch), an ORPHAN commit (DROPPED by the update, reported by nothing), and a dirty tree (git itself refuses, loudly). The loss was the update's blind spot, not git's doing, and the gate proves the fix in both directions: it FAILS on the pre-change sources and PASSes on the committed tree.
One correction continues in #56. The review of #55 found the claim's SCOPE was wider than the product's: it said the refusal "leaves the tree exactly as it was" and "stops the whole bump", while the bump's failure contract is per-submodule — a failed path is collected and the walk continues (#13) — so a path that sorts earlier is already rolled and staged when the refusal is reported. The refusal is real and the work survives; the sentence around it was too wide. #56 corrects the code comments and the record, and adds the coverage that makes the scope mechanical instead of a prose assertion.
The issue is closed by hand, because this org's merges are performed by
github-actions[bot]— a GitHub App (type: "Bot") — and under those merges the automatic close-on-merge path has never run: 89 org pull requests carry a closing keyword and 0 of their targets auto-closed. The manual close is the only close there is here, and this is the class opencharly/opencharly#700 asks to be closed mechanically.The next update is when #56's correction lands.
Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
fix(git-submodules): the scope's control must discriminate — the refu…
fix(git-submodules): the nested-work refusal's scope is the REFUSED S…