Skip to content

fix(standardize-repo): require a staged change before reporting staged state - #409

Merged
evanharmon1 merged 3 commits into
mainfrom
fix/staged-state-attribution
Aug 11, 2026
Merged

fix(standardize-repo): require a staged change before reporting staged state#409
evanharmon1 merged 3 commits into
mainfrom
fix/staged-state-attribution

Conversation

@evanharmon1

Copy link
Copy Markdown
Owner

What

Follow-up to #400, closing the two cloud-review findings that landed on its
final head after it was already merged, plus two more the local gates found
while fixing them. One invariant runs through all four:

A finding that says "the next commit carries it" must be true of the next
commit — so the index has to be compared against HEAD, per dimension, and
a blob has to be read with its mode.

400 taught diff-template.sh to inspect the index. It compared the index only

against the render, never against HEAD, so committed state was reported as
staged; and it reasoned about blobs without their modes, so a staged type change
slipped through in both directions.

The four fixes

Fix Was
A staged dimension is reported only when that dimension moved vs HEAD (7e44403) A committed customization whose worktree was edited back to the template staged nothing, yet reported staged content differs … the next commit carries it and gated. Same for an unstaged chmod and the MODE line.
The prose-clobber gate keys on staged bytes (7e44403) Mode and content stage independently, so a staged chmod on a file whose committed bytes already matched the template claimed a prose clobber no commit performs.
A staged type change reinterprets an inherited blob (d44864e, 4c903a3) The same bytes are a path string under 120000 and file content under 100644. Both directions were missed: file→symlink (blob unchanged, mode moves — and the exec-bit branch exempts symlinks) and symlink→file (index and render are both regular files, so nothing looked structural, while committing yields a file whose content is the old link target).
An unborn index is entirely staged (d44864e) load_staged_entries stopped at the absent HEAD, making a pre-first-commit repo the one place staged divergence went unreported.

has_staged_change is deleted rather than patched: "is anything staged" was the
wrong question, and the per-dimension (mode, blob) comparison answers the right
one exactly. The clobber gate additionally requires a committed blob to lose,
which is #400's unborn-HEAD declination now enforced by a condition instead of by
the absence of a probe.

Why

Refs #400 — the two originating findings, both replied to in-thread:

Verification

  • 11 new assertions, all in the existing diff-template families. Negative
    control on the two originating findings: stash only diff-template.sh,
    re-run — 6 fail, one reproducing the reported message verbatim
    (MODE scripts/status.sh (staged mode differs … the next commit carries it)
    for a chmod that was never staged).
  • task challenge: 3 rounds, clean at R3. task review: 1 round, clean —
    it ran the suite hermetically at 1068 tests, all passing, which is
    independent confirmation that the local reds below are environmental.
  • task ci reproduces main's pre-existing local failure set exactly (53
    assertions, diffed both ways, byte-identical) and nothing new.

Local test:skills note: those 53 macOS failures are the known #370 family
plus a guarded-update rehearsal family that also fails on unmodified main
here. Separately, one of them was caused by a full disk mid-session: the suite
leaks its whole temp tree per run and a fixture's git add failed with
No space left on device, surfacing as an ordinary red assertion. Filed as
#407 with the cleanup shape.

Adjudicated without a code change

  • Intent-to-add entries and reading the staged mode from the index rather
    than test -x
    — both already filed as standardize-repo: index-state and mode-detection follow-ups from the 400 review rounds #401 and untouched by this branch.
  • scripts/test-skills.sh:1030 (trigger_phrase_present,   entities in
    the trigger guard) — off-diff: that guard is pre-existing on main and
    git diff origin/main shows no hits in it. Declined for scope rather than
    fixed under an unrelated change; recorded here so it is not lost.

evanharmon1 and others added 3 commits August 11, 2026 06:40
…d state

Two P2s from the cloud review of #400's final head, both confirmed against the
code and both reproduced by the negative control below. They share one defect:
the staged-state findings added in #400 compare the index against the RENDER and
never check that the index differs from HEAD, so they report committed state as
though it were staged.

- A committed customization whose worktree copy is edited BACK to the template
  stages nothing, yet its index entry still differs from the render. The audit
  reported `staged content differs … the next commit carries it` and gated,
  turning an ordinary unstaged reconciliation into a finding — and an ordinary
  `git commit` writes no such entry, so the message was false as well as noisy.
  The same held for an unstaged `chmod` and the MODE line.
- Mode and content stage INDEPENDENTLY: `git update-index --chmod` records a
  mode with the bytes untouched. For a co-owned file whose committed bytes
  already match the template, staging only a mode correction satisfied every
  other condition of the prose-clobber gate — index bytes equal to the render,
  something staged — and claimed a clobber that no commit performs.

`load_staged_entries` now reads the (mode, blob) pair from HEAD and from the
index through the existing fail-closed probes, and `index_diverges` keeps a
divergence only on the dimension that actually changed: bytes staged for the
content and structural verdicts, mode staged for the MODE verdict. The clobber
gate keys on `index_bytes_staged` rather than "anything staged", which is what
`has_staged_change` had meant — that helper is deleted, since the blob
comparison answers its question exactly and per dimension.

Verification: 7 new assertions across the three shapes (inherited content,
inherited mode, mode-only staging). Negative-controlled by stashing only
`diff-template.sh` — 6 of them fail against the pre-fix script, one reproducing
the reported message verbatim:

    MODE     scripts/status.sh  (staged mode differs from the template though
                                 the worktree matches — the next commit carries it)

for a chmod that was never staged.

Refs #400.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAX9K1JuEUVk6TW2KAP5ge
…t an unborn index

Challenge round 1 on this branch. Both fixes are about the same blind spot in
the previous commit's staged-ness filter: it asked only whether the BYTES moved.

P1 (confirmed): a type change is staged in the MODE, not in the bytes. git
records a regular file as 100644 and a symlink as 120000, and a file whose
contents are exactly the link's target text has the SAME blob under both — so an
index-only file↔symlink conversion moves the mode while the blob stands still.
Clearing the structural verdict on unchanged bytes dropped it, and the exec-bit
branch cannot catch it either because it exempts symlinks by design. Structure
now survives whenever EITHER dimension moved; only a wholly unstaged entry is
cleared, and a mode-only staging still drops an inherited byte divergence.

P2 (confirmed): `load_staged_entries` ended the inspection at an unborn HEAD,
making a pre-first-commit repo the one place staged divergence went unreported.
There is no committed state before the first commit, so every index entry is
staged by definition and the initial commit carries all of it. The HEAD fields
are now simply left empty.

That interacts with the clobber gate, and the interaction is the point: with an
empty HEAD blob the gate would have claimed a prose clobber for every unborn
repo. It now requires `staged_head_blob` — "there was a COMMITTED customization
to lose" — which is the same argument declined on #400's unborn-HEAD thread,
except enforced by a condition instead of by the absence of a probe.

Adjudicated without code change:
- intent-to-add entries, and reading the staged mode from the index rather than
  `test -x` — both already filed as #401, unchanged by this branch.
- `scripts/test-skills.sh:1030` (`trigger_phrase_present` and `&#32;` entities)
  — OFF-DIFF. That guard is pre-existing on main and untouched here; noted in
  the PR body rather than fixed under an unrelated change.

Verification: 4 new assertions (index-only type conversion, unborn staged
divergence, and the two unborn clobber controls). Zero regressions against the
main baseline.

Refs #400.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAX9K1JuEUVk6TW2KAP5ge
…he blob

Challenge round 2, P1, confirmed — and the mirror image of round 1's P1, which
is the tell that I had been patching directions instead of naming the concept.

Round 1 fixed file→symlink: same blob, mode moves 100644→120000, so a
bytes-only test missed it. Round 2 is symlink→file: HEAD holds a symlink, the
index stages that unchanged blob as a regular file, the worktree holds the
template's bytes. `index_structural` is 0 because index and render are both
regular files; `index_bytes_staged` is 0 because the blob never moved — so the
whole content divergence was cleared as "inherited", while committing produces a
file whose CONTENT is the old link target rather than the template's.

The concept both cases share: **a blob has no meaning without its mode.** The
same bytes are a path string under 120000 and file content under 100644, so a
staged TYPE change reinterprets an inherited blob into a genuinely new artifact
and "the bytes did not move" stops being evidence of anything. An ordinary
100644→100755 chmod is emphatically not that — the bytes still mean what they
meant — which is why `index_type_staged` is a separate question from
`index_mode_staged`, and why the chmod case still drops an inherited byte
divergence exactly as #400's review required.

So the rule is now stated once, in both directions, instead of per-direction:
clear the content verdict only when the bytes are inherited AND no type change
reinterprets them AND the types agree with the render.

Verification: a regression for each direction. The reverse case deliberately
uses renovate.json rather than AGENTS.md — it is a CONTENT verdict, and a
co-owned path's staged content stays exempt by the documented contract, which
would have masked the mechanism under test. (That masking is what the first
draft of this test hit, and it is a policy rather than a defect.)

Refs #400.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAX9K1JuEUVk6TW2KAP5ge
@evanharmon1

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 4c903a340e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@evanharmon1
evanharmon1 marked this pull request as ready for review August 11, 2026 13:08
@evanharmon1

Copy link
Copy Markdown
Owner Author

Readiness gate — passed, promoted once

Evaluated against 4c903a340e8c442b55663414fe7da11a96ca54c8:

Condition State
Required checks concluded successfully ✅ guard ×2, lint, security, verify
Current-head Codex cycle terminal and clean Reviewed commit: 4c903a340e — "Didn't find any major issues"
Every review finding fixed, declined, or filed ✅ 3 P1 fixed, 1 declined (off-diff), 2 pre-filed as #401
Every inline review comment has a per-thread reply ✅ none on this PR; the two originating threads on #400 are answered
Deferred findings ticked ✅ none open
reviewDecision not CHANGES_REQUESTED ✅ empty
mergeStateStatus ✅ CLEAN
No newer push invalidated the results ✅ head unchanged, tree clean

Local gates: task challenge 3 rounds (clean at R3), task review 1 round
(clean — it ran the suite hermetically at 1068 passing tests), task ci
matching main's baseline exactly.

Ready for human review. Merging remains a human decision.

@evanharmon1
evanharmon1 merged commit 5c4b798 into main Aug 11, 2026
5 checks passed
@evanharmon1
evanharmon1 deleted the fix/staged-state-attribution branch August 11, 2026 13:21
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.

1 participant