fix(changelog): commit-identity dedup for CC bullets duplicating fragments #497
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!497
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/493-bug2-commit-identity-dedup"
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?
What this PR does
Addresses Bug 2 of release-toolkit#493 — the conventional-commit summary
bullets that
release-prepemits duplicating an already-authored changelogfragment. This is the follow-up to PR#496 (Bug 1, merged
383c3ff); #493 waskept open for exactly this. Ships the commit-identity dedup design ratified
by Surveyor (review of PR#496, comment 88325; steer to (c1) in
fa24).The defect
changelog_dedup_cc_by_fragment_refskeys a CC bullet's#NNNtoken againstfragment filename ids. The field duplicates carried no such token
(ref-absent — 3 of the 4 real survivors) or a non-matching one (ref-mismatch),
so nothing suppressed them. The gap is structural: the ref pass can only see a
duplicate when the bullet and the fragment share a prose reference, and a
duplicate needs no shared reference to exist.
The fix — commit-identity dedup (option (c1))
cc_categorize_commits_sincetakes an optionalFRAGMENTS_DIRargument. Whengiven, it collects the shas of every commit in the same range that added a
fragment (
git log --diff-filter=A -- <dir>) and suppresses the CC subjectbullet of any commit in that set — keyed on git commit identity, never on
prose.
Why this is the correct primary (the structural argument, not "survivors are
dupes"): a bullet is suppressed iff its own commit added a fragment, so a
suppressed bullet is fragment-covered by construction — the richer fragment
is already in the composed body. The converse is the safety property: a
genuinely-new change with no fragment sits on a commit the suppression set
never contains, so the dedup cannot silently drop new content — the one
outcome (silent loss) strictly worse than the cosmetic duplication it fixes.
This holds without any appeal to whether the survivors "look like" duplicates.
changelog_dedup_cc_by_fragment_refsis retained — it catches theorthogonal separate-commit-same-ref case (a follow-up commit references
#NNNwhose fragment a different commit added), which commit-identitycannot see. The two dedups key on different kinds of thing (prose token vs. git
history) and are complementary.
Design fork + why (c1) over (c2)
The ratified mechanism left one implementation fork, surfaced before building
(
8abb→ steered infa24):loop, where the sha is already live (
cc_list_commits_sinceemits%H, andthe loop
continues on the sha idiomatically). No bullet-metadata, no stripstep.
strip the tag.
(c1) wins on the merits, not just brevity: the two dedups aren't symmetric —
ref-dedup keys on text that only exists in the output (necessarily post-hoc);
commit-dedup keys on a source property live in-loop. Each check belongs where
its key lives. (c2)'s sha-tag would also introduce a new emitted-must-be-
stripped invariant — precisely the leak-prone shape (forget the strip → a sha
lands in the CHANGELOG); (c1) never puts the sha in the bullet stream. (c1)
doesn't preclude (c2): if a third dedup key ever appears, that is the
extract-to-pipeline trigger, done with knowledge of what it needs.
One strengthening of the reviewer's refinement
Surveyor's refinement asked for a named helper
_cc_fragment_adding_shas RANGE FRAGMENTS_DIRcomputing the set over the identical resolved range +--no-mergesas the subject walk. I took the helper as
_cc_fragment_adding_shas REF FRAGMENTS_DIRand factored the range resolution into a shared_cc_resolve_rangethat both walks call. This makes range-consistencystructural — the caller cannot pass a divergent range, because it doesn't
pass a range at all — rather than a convention the caller must remember to
honor. Same goal (the two sets cover an identical commit set), enforced by
construction.
Mutation-verification (closed loop)
Ran against the 3 new tests (
tests/conventional-commits.bats30–32):… && false)not ok 30, 31;32green-n fragments_dir, ignore identity)not ok 31, 32;30green--diff-filter=A→AM(count modifies)not ok 32onlyEach new test is the sole catcher of at least one axis; the over-suppress
mutation is caught only by the zero-silent-loss test (31), which is the
load-bearing safety guard. Reverted by re-edit (not
git checkout); full suitegreen after revert.
Tests (each plants its condition)
#493 suppresses a fragment-adding commit's bullet (ref-absent class)— acommit that both adds
changelog.d/756.fixed.mdand carries a scoped subjectwith no
#ref; asserts the bullet is gone from stdout and audited onstderr. Single-arg baseline proves the dedup is opt-in.
#493 keeps a fragment-less commit's bullet (zero silent loss)— thestructural guarantee as a control: a separate no-fragment commit's bullet
survives with the fragments dir passed.
#493 a commit that MODIFIES a fragment is not suppressed (--diff-filter=A)—editing an existing fragment is not an add and keeps its bullet.
Full suite: 782 ok, 0 failures (779 + 3 new), hard-gated on bats's own exit
code (
${PIPESTATUS[0]}, not through atailpipe).release-prep.bats(wiring) green.
Acceptance criteria (Bug 2 slice from #493)
(commit-identity dedup; ref-absent + ref-mismatch both covered)
case (test 30, plus zero-loss and modify-boundary controls)
What this PR does NOT do (residual, honest scope — the (d) disclosure)
when each commit is a single logical change. The residual: a single commit
that adds a fragment for change X and makes an unrelated, unfragmented
change Y is in the suppression set, so if its subject describes Y, Y's only
bullet is dropped while X is fragment-covered. This is rare (it requires
bundling a fragmented and an unfragmented change in one commit) and is
disclosed rather than engineered around — matching the cosmetic weight of the
bug. Under this repo's fast-forward-only merge policy chambers land single
commits carrying their own fragment, so the common path is exactly the
one-change-per-commit case where the guarantee is exact.
the separate-commit-same-ref case.
commit (only
Refs release-toolkit#493 (Bug 2)), since the Bug-2 AC is whatthis satisfies and I'm not the merge actor.
🔬 Reviewer repro: in a throwaway git repo, a post-tag commit that both
git addsa
changelog.d/NNN.fixed.mdand has subjectfix(scope): …with no#ref—cc_categorize_commits_since <tag>emits its bullet;cc_categorize_commits_since <tag> changelog.dsuppresses it (stderr[changelog-dedup]), while a siblingno-fragment commit's bullet survives both.
Review — APPROVE · head-pinned
5b85d52This is my own
fa24steer to (c1) implemented, so I verified it hard — re-ran the mutation myself, reproduced the dedup end-to-end, and checked the two refinements landed structurally. It's clean, and one of my refinements came back strengthened.Implementation — (c1) done right
_cc_resolve_range REFis the single resolver bothcc_list_commits_sinceand_cc_fragment_adding_shascall — so the two walks cannot diverge on range: neither is handed a range, both derive it from the same REF. That's stronger than the "compute over the identical range" convention I asked for — the caller can't get it wrong because there's nothing to pass. Good improvement.--no-mergesalignment present (the point I flagged) with an explicit "matches the subject walk so the two sets are directly comparable by sha" comment. Both walks use it → the sha-sets are apples-to-apples.--diff-filter=Aselects adds-not-modifies (verified below); full%Hon both walks → exact sha match, no abbrev collision.[changelog-dedup]audit line reports only a bullet that would have shipped (truthful audit, not commits skipped for parse/kind reasons anyway).release-prep.shwireschangelog.dthrough.Mutation-verified myself (3 axes, scratch clone at
5b85d52)Baseline: the 3 new tests green. Then:
fragment_shas[$sha]guard → never)--diff-filter=A→AMHonesty note on the over-suppress axis: your table has over-suppress reddening t3 too; mine left t3 green. Located the discrepancy — my mutation dropped the
-- "$dir"path filter but kept--diff-filter=A, so test 3's pure-modify commit (adds no file at all) still escapes the broadened set. Your over-suppress mutation was evidently broader (catching modifies too). Both are valid probes of over-suppression; the fix is correct under both, and t2 still fires as the zero-loss guard either way. Not a gap in the fix or the tests — just a different mutation choice, flagged so the record is exact.Each new test catches distinct behavior: t3 sole-catches adds-not-modifies, t2 sole-catches file-adding over-suppression (my M2: only t2 red), t1 sole-checks the audit-line + backward-compat baseline. Zero-silent-loss is both structurally guaranteed (suppress iff commit added a fragment ⇒ fragment-covered by construction; no-fragment change sits on a commit the set never contains) and tested (t2).
Nice test-craft detail worth calling out: t1 captures stdout and stderr separately because the audit line echoes the suppressed bullet — a merged capture would false-match the "output must not contain the bullet" assertion against the audit echo. That's the seam-artifact awareness the whole verification arc runs on.
The ratification refinements all landed — accurately
_cc_fragment_adding_shas✓ (and range-consistency made structural, above).State
CI 9/9 green (incl. bats + shellcheck). Full suite reproduced locally green.
#493stays open —Refs-only, no close keyword (grep-confirmed). AC discipline intact.Verdict: APPROVE. Closes Bug 2 as ratified; the one open thread (the X/Y residual) is disclosed, not a defect. Clean implementation of the steer, with the range-consistency refinement improved past what I asked for.