From 9b8000a6424c7b0d6471d1637f5abce283c1966d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 17:28:17 -0800 Subject: [PATCH] v1.94.29.0 fix(stamp): leave third-party versions in stamp files alone; land restamps after a reset stamp (GST-1245) (#3968) * fix(stamp): leave third-party versions in stamp files alone; land restamps after a reset stamp (GST-1245) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (train merges read the pin's own claims; a never-pushed stamp resumes) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(stamp): a stamp_patterns slot vouches only for its own match, not a built-in form of the same version (GST-1245 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../capy-gst1245-stamp-foreign-versions.md | 2 + docs/ship-policy.md | 7 +- lib/restamp.ts | 47 ++++-- lib/ship-queue-land.ts | 5 +- lib/stamp-only.ts | 32 ++-- test/restamp-foreign-version.test.ts | 137 ++++++++++++++++++ test/ship-queue-land.test.ts | 63 ++++++++ test/ship-queue-train-stamp.test.ts | 20 +++ 8 files changed, 285 insertions(+), 28 deletions(-) create mode 100644 changelog.d/capy-gst1245-stamp-foreign-versions.md create mode 100644 test/restamp-foreign-version.test.ts diff --git a/changelog.d/capy-gst1245-stamp-foreign-versions.md b/changelog.d/capy-gst1245-stamp-foreign-versions.md new file mode 100644 index 000000000..9aadfd462 --- /dev/null +++ b/changelog.d/capy-gst1245-stamp-foreign-versions.md @@ -0,0 +1,2 @@ +- **The land stamp no longer rewrites other projects' versions.** A PR that wrote `QM v0.1.15` into a README listed in `stamp_files` landed as `QM v0.0.115`, because the stamper read any `vX` newer than the branch's fork point on a line the PR added as the PR's own tentative stamp, and the tree receipt called the rewrite stamp-only. Built-in `vX`, `since: X` and `(was X)` mentions above the version being stamped now move only when the branch itself recorded that version, so a third-party version above it survives byte for byte and a rewrite of one reads as a product change. A declared `stamp_patterns` slot keeps the old bound but moves only inside the slot. (GST-1245, reported by HARN-7) +- **`land` restamps after a stamp is reset.** When a PR branch was reset to its reviewed pin after land stamped it, the rerun resumed the old journal and refused with `QUEUE_TRAIN_MOVED` until the journal was deleted by hand. Land now discards that journal, says so on a `JOURNAL:` line, and restamps the pin. (GST-1245) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 40948b900..6473c7e48 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1791,7 +1791,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`: 1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so. 2. **release_tool**: the policy command, under the contract below. 3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under ``, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once. -4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was `, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), the /ship receipt carry, and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION. A branch line is named by its position in the gated file, so a base line whose text duplicates a line the branch added keeps the gated head's bound (GST-979). When the stamp keeps the VERSION (a slot that keeps its claim), only the branch's own lines may move, so a base line moved by hand is refused. The carry follows a stamp on the branch's own rows in a stamp file the branch also changed as product when every changed line of that file sits at a branch-added position, the file keeps its mode, the base did not touch it and the rest of the product diff is unchanged (`a stamp on the branch's own rows` in the carry reason, GST-980). +4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was `, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), with one limit on the built-in forms: a version newer than the one being stamped moves only when the branch itself recorded it (its version source at the fork point or at any commit in `..HEAD`, every parent walked, so a train or land merge of the pin still reads the pin's own claims), because no queue handed the branch a version above the one it gets now, so a third party's `QM v0.1.15` on a line the branch added to a README in `stamp_files` stays exactly as written (GST-1245). A `stamp_patterns` slot keeps the plain fork-point bound (#3512), and a version only a slot vouches for moves inside that slot alone, never in a built-in form beside it. A third-party version between the fork point and the version being stamped is still read as a tentative stamp: keep such references out of stamp files, or out of a stamp form. So does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), the /ship receipt carry, and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION; in a built-in form a moved version above the new one must also be one the writer moves there (the gated head's VERSION on any line, a version the branch recorded on its own lines), so a rewritten third-party version reads as product (GST-1245). A branch line is named by its position in the gated file, so a base line whose text duplicates a line the branch added keeps the gated head's bound (GST-979). When the stamp keeps the VERSION (a slot that keeps its claim), only the branch's own lines may move, so a base line moved by hand is refused. The carry follows a stamp on the branch's own rows in a stamp file the branch also changed as product when every changed line of that file sits at a branch-added position, the file keeps its mode, the base did not touch it and the rest of the product diff is unchanged (`a stamp on the branch's own rows` in the carry reason, GST-980). 5. **mirror**: tier 2; declared and validated, reported as not run. 6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage). @@ -1922,7 +1922,10 @@ checks green, Version Gate included. lane's stamp round trip. Nobody stamps by hand; the tool makes and pushes the stamp commit. Steps, each journaled in `.gstack/tmp/ship-queue/land/.json` and resumable by rerunning the same -command: +command. A journal that pushed a stamp whose branch was then reset to the reviewed +pin is discarded and the pin is restamped +(`JOURNAL: #'s head is the reviewed pin …; discarding the journal and +restamping`, GST-1245), so a reset stamp needs no hand cleanup: 1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a base policy with `changelog_fragments` off (`QUEUE_NOT_READY`), a pin diff --git a/lib/restamp.ts b/lib/restamp.ts index 9eba5f88e..dbe9ecf8b 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -160,7 +160,7 @@ export function correctedOrder(order: readonly string[], mine: string | null, ac interface Plan { policy: ShipPolicy; policyLabel: string; queueMode: QueueMode; oldVersion: string; newVersion: string; versionRel: string; - baseVersion: string; branchStamp: string | null; forkVersion: string; baseSha: string; baseRef: string; claims: string; level: Bump; predecessor?: { pr: string; head: string }; + baseVersion: string; branchStamp: string | null; forkVersion: string; recorded: ReadonlySet; baseSha: string; baseRef: string; claims: string; level: Bump; predecessor?: { pr: string; head: string }; } function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: string): Plan { @@ -214,7 +214,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri } // The branch's own tentative stamp: its VERSION when it moved past base; else nothing (mentions come from the lines it added). const branchStamp = oldVersion !== atBase.version ? oldVersion : null; - return { policy, policyLabel, queueMode, oldVersion, newVersion, versionRel: atBase.rel, baseVersion: atBase.version, branchStamp, forkVersion: forkVersion(o.repoRoot, baseRef, atBase.version), baseSha: baseSha.out, baseRef, claims, level, predecessor }; + return { policy, policyLabel, queueMode, oldVersion, newVersion, versionRel: atBase.rel, baseVersion: atBase.version, branchStamp, forkVersion: forkVersion(o.repoRoot, baseRef, atBase.version), recorded: recordedVersions(o.repoRoot, baseRef, atBase.version), baseSha: baseSha.out, baseRef, claims, level, predecessor }; } /** The version writer's own files: the version source, the manifest (root or pinned) and the npm lockfiles beside it. */ @@ -337,10 +337,13 @@ function templateMention(template: string): RegExp { * added itself, newer than the version at its fork point and not the * allocated one. A released version is never newer than the fork point of a * branch written against it, and the repo's own version width must match, so - * a 3-digit dependency pin in a 4-digit repo is never a candidate. + * a 3-digit dependency pin in a 4-digit repo is never a candidate. With + * `recorded`, a built-in form above the allocated version counts only when the + * branch recorded it (GST-1245); a `stamp_patterns` slot keeps the fork bound. */ -export function tentativeMentions(added: Map>, forkVersion: string, newVersion: string, templates: readonly string[] = []): string[] { +export function tentativeMentions(added: Map>, forkVersion: string, newVersion: string, templates: readonly string[] = [], recorded?: ReadonlySet): string[] { const fork = parseVersion(forkVersion); + const ceiling = parseVersion(newVersion); if (!fork) return []; const width = versionWidth(forkVersion); const scanners = [...BUILT_IN_MENTIONS, ...templates.map(templateMention)]; @@ -352,6 +355,7 @@ export function tentativeMentions(added: Map>, forkVersion: const raw = m.groups!.v!; const v = parseVersion(raw); if (!v || versionWidth(raw) !== width || raw === newVersion || cmpVersion(v, fork) <= 0) continue; + if (recorded && !recorded.has(raw) && BUILT_IN_MENTIONS.includes(re) && (!ceiling || cmpVersion(v, ceiling) > 0)) continue; found.add(raw); } } @@ -360,10 +364,10 @@ export function tentativeMentions(added: Map>, forkVersion: return [...found]; } -/** The versions one line mentions in a stamp form (the built-in forms and the policy's `stamp_patterns` templates). */ -export function stampMentions(text: string, templates: readonly string[] = []): Set { +/** The versions one line mentions in a stamp form (the built-in forms unless `builtIn` is false, and the policy's `stamp_patterns` templates). */ +export function stampMentions(text: string, templates: readonly string[] = [], builtIn = true): Set { const found = new Set(); - for (const re of [...BUILT_IN_MENTIONS, ...templates.map(templateMention)]) for (const m of text.matchAll(re)) found.add(m.groups!.v!); + for (const re of [...(builtIn ? BUILT_IN_MENTIONS : []), ...templates.map(templateMention)]) for (const m of text.matchAll(re)) found.add(m.groups!.v!); return found; } @@ -375,6 +379,19 @@ export function forkVersion(repoRoot: string, baseRef: string, fallback: string, return 'version' in v ? v.version : fallback; } +/** + * The versions the branch itself recorded (GST-1245): its version source at its fork point and at each commit in + * `baseRef..head`, every parent walked, so a train or land merge (`-p -p `) still reads the pin's own claims. + */ +export function recordedVersions(repoRoot: string, baseRef: string, fallback: string, head = 'HEAD'): Set { + const out = new Set([forkVersion(repoRoot, baseRef, fallback, head)]); + for (const c of git(repoRoot, ['rev-list', `${baseRef}..${head}`]).out.split('\n').filter(Boolean)) { + const v = versionAtRef(repoRoot, c); + if ('version' in v) out.add(v.version); + } + return out; +} + /** * Exact `(was X)`, `since: X`, `vX` rewrites plus the policy's literal * `stamp_patterns` templates (one `{version}` slot, matched at a version @@ -384,7 +401,7 @@ export function forkVersion(repoRoot: string, baseRef: string, fallback: string, * X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form); `vX` * already accepts a quote before the `v`. */ -export function rewriteStamps(text: string, oldVersion: string, newVersion: string, templates: readonly string[] = []): { text: string; count: number } { +export function rewriteStamps(text: string, oldVersion: string, newVersion: string, templates: readonly string[] = [], builtIn = true): { text: string; count: number } { const v = escapeRegExp(oldVersion); const patterns: Array<[RegExp, (...m: string[]) => string]> = [ [new RegExp(`\\(was (['"]?)${v}\\1\\)`, 'g'), (_, q) => `(was ${q}${newVersion}${q})`], @@ -394,7 +411,7 @@ export function rewriteStamps(text: string, oldVersion: string, newVersion: stri ]; let count = 0; let out = text; - for (const [re, rep] of patterns) { + for (const [re, rep] of builtIn ? patterns : patterns.slice(3)) { count += (out.match(re) ?? []).length; out = out.replace(re, rep); } @@ -505,7 +522,8 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string * `--was`) is rewritten on every line of every stamp file; a tentative one * (a mention newer than the fork version, or of the fork version itself: a * row written at the version the branch forked from instead of a next one, - * GST-963) only on lines the branch added. + * GST-963) only on lines the branch added; above the new version an unrecorded + * one moves only inside a `stamp_patterns` slot (GST-1245): a third party's `vX` stays. * With no explicit version the branch's VERSION equals the base's: either it * never stamped, or it stamped X and the base reached X on its own (a PR that * lost its slot and merged the base). Then a stamp-form mention of the base @@ -525,10 +543,11 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since const baseAdded = branchAddedLines(o.repoRoot, since, files, plan.baseSha); const added = branchAddedLines(staging, since, files, undefined, baseAdded); const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion)); - const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v))); + const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates, plan.recorded).filter(v => !explicit.has(v))); const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null; if (equalBase) tentative.add(equalBase); if (plan.forkVersion !== plan.newVersion && !explicit.has(plan.forkVersion)) tentative.add(plan.forkVersion); + const slotOnly = new Set([...tentative].filter(v => !plan.recorded.has(v) && cmpVersion(parseVersion(v)!, parseVersion(plan.newVersion)!) > 0)); const moved = new Map(); const baseSide: string[] = []; let touched = 0; @@ -541,8 +560,8 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since let count = 0; let ownBase = 0; let keptBase = 0; - const rewrite = (text: string, old: string): string => { - const r = rewriteStamps(text, old, plan.newVersion, templates); + const rewrite = (text: string, old: string, builtIn = true): string => { + const r = rewriteStamps(text, old, plan.newVersion, templates, builtIn); count += r.count; if (r.count) moved.set(old, (moved.get(old) ?? 0) + r.count); return r.text; @@ -555,7 +574,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since } let text = line; for (const old of explicit) text = rewrite(text, old); - if (mine) for (const old of tentative) text = rewrite(text, old); + if (mine) for (const old of tentative) text = rewrite(text, old, !slotOnly.has(old)); return text; }).join('\n'); if (ownBase || keptBase) baseSide.push(`${rel}: ${ownBase} moved, ${keptBase} kept`); diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index a564072ee..7bd7c09bd 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -333,11 +333,12 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { const mergedResume = info.state === 'MERGED' && !!j && ['pushed', 'merged', 'landed'].includes(j.phase) && j.stamp_commit === info.headRefOid; if (info.state !== 'OPEN' && !mergedResume) return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` }, run); if (info.baseRefName && info.baseRefName !== ctx.base) return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${info.baseRefName}, not ${ctx.base}; land merges only PRs on the base it stamps (retarget it, or land its parent first)` }, run); - if (j && !mergedResume && (j.phase === 'landed' || ![j.reviewed_pin, j.last_pushed_head, j.stamp_commit].includes(info.headRefOid))) j = null; + const reset = j && !mergedResume && j.stamp_commit && j.last_pushed_head && j.last_pushed_head !== j.reviewed_pin && info.headRefOid === j.reviewed_pin ? `JOURNAL: #${o.pr}'s head is the reviewed pin ${j.reviewed_pin.slice(0, 12)}, not the stamp commit ${j.stamp_commit.slice(0, 12)} (phase ${j.phase}); discarding the journal and restamping` : null; + if (j && !mergedResume && (reset || j.phase === 'landed' || ![j.reviewed_pin, j.last_pushed_head, j.stamp_commit].includes(info.headRefOid))) j = null; if (j && mergedResume) j.phase = 'merged'; const recovered = j ? null : stampPin(root, info.headRefName, info.headRefOid, o.pr); const pin = j?.reviewed_pin ?? recovered ?? info.headRefOid; - const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`]; + const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`, ...(reset ? [reset] : [])]; git(root, ['fetch', '-q', 'origin', ctx.base]); const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin], mergedResume); lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : [])); diff --git a/lib/stamp-only.ts b/lib/stamp-only.ts index 53b2b5231..9f56a0e3b 100644 --- a/lib/stamp-only.ts +++ b/lib/stamp-only.ts @@ -4,7 +4,7 @@ * lines the branch wrote itself. Split from restamp.ts (module-size ratchet). */ import { spawnSync } from 'node:child_process'; -import { branchAddedLines, escapeRegExp, forkVersion, rewriteStamps, stampMentions } from './restamp'; +import { branchAddedLines, escapeRegExp, forkVersion, recordedVersions, rewriteStamps, stampMentions } from './restamp'; import { VERSION_SLOT } from './ship-policy'; import { cmpVersion, parseVersion, versionWidth } from './version-source'; @@ -33,21 +33,28 @@ import { cmpVersion, parseVersion, versionWidth } from './version-source'; * `stamp_patterns` template with literal text around its slot, never a built-in * form: with at least one such rewrite, the line must then be the added line, * or one whose remaining stamp-form moves pass the fork-version bound. + * Within either bound a built-in form (`vX`, `since: X`, `(was X)`) moves a + * version above `newV` only when the writer would: `oldV` on any line, one the + * branch recorded (`side.recorded`) on its own lines. Any other version moves + * only inside a `stamp_patterns` slot (#3512), so a third party's `QM v0.1.15` + * rewritten on a line the branch added, even beside such a slot, is product (GST-1245). */ export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number, placeholders: readonly string[] = []): boolean { if (removed === added) return false; - if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates)) return true; + if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates, new Set([oldV]))) return true; if (!side || at === undefined || !side.at.has(at) || !side.added.has(removed)) return false; - if (stampOnlyWithin(removed, added, side.fork, newV, templates)) return true; + const recorded = side.recorded ?? new Set([side.fork]); + if (stampOnlyWithin(removed, added, side.fork, newV, templates, recorded)) return true; const filled = fillPlaceholders(removed, newV, templates, placeholders); - return filled.count > 0 && (filled.text === added || stampOnlyWithin(filled.text, added, side.fork, newV, templates)); + return filled.count > 0 && (filled.text === added || stampOnlyWithin(filled.text, added, side.fork, newV, templates, recorded)); } /** * Lines the branch added to one file (since its merge base with the base, less the lines the base added since): their - * text and their 1-based positions in the side's file, and its fork point's version. + * text and their 1-based positions in the side's file, its fork point's version, and the versions it recorded + * (recordedVersions; the fork version alone when absent). */ -export interface BranchSide { fork: string; added: ReadonlySet; at: ReadonlySet } +export interface BranchSide { fork: string; added: ReadonlySet; at: ReadonlySet; recorded?: ReadonlySet } /** * Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to` @@ -58,6 +65,7 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal const sides = new Map(); let since: string | undefined; let fork: string | undefined; + let recorded: Set | undefined; return rel => { if (sides.has(rel)) return sides.get(rel); since ??= (spawnSync('git', ['merge-base', baseRef, head], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }).stdout ?? '').trim(); @@ -65,7 +73,7 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal if (since) { try { const own = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel); - if (own?.text.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added: own.text, at: own.at }; + if (own?.text.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added: own.text, at: own.at, recorded: (recorded ??= recordedVersions(repoRoot, baseRef, fallback, head)) }; } catch {} } sides.set(rel, side); @@ -88,14 +96,18 @@ function fillPlaceholders(removed: string, newV: string, templates: readonly str return { text, count }; } -function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean { +function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[], recorded: ReadonlySet): boolean { const floor = parseVersion(oldV); const width = versionWidth(oldV); + const ceiling = parseVersion(newV); + const free = (v: string) => { const p = parseVersion(v); return recorded.has(v) || (!!p && !!ceiling && cmpVersion(p, ceiling) <= 0); }; const after = stampMentions(added, templates); - const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v)); + const afterSlots = stampMentions(added, templates, false); + const slotted = stampMentions(removed, templates, false); + const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && (free(v) ? !after.has(v) : slotted.has(v) && !afterSlots.has(v))); if (!moved.length || !floor) return false; if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false; let text = removed; - for (const v of moved) text = rewriteStamps(text, v, newV, templates).text; + for (const v of moved) text = rewriteStamps(text, v, newV, templates, free(v)).text; return text === added; } diff --git a/test/restamp-foreign-version.test.ts b/test/restamp-foreign-version.test.ts new file mode 100644 index 000000000..708720320 --- /dev/null +++ b/test/restamp-foreign-version.test.ts @@ -0,0 +1,137 @@ +/** + * A foreign version on a line the PR added to a stamp file is not the PR's stamp (GST-1245, HARN-7 on gstack + * 1.94.19.0): harness-evals#235 added three README.md lines saying "QM v0.1.15", README.md was in stamp_files, + * and the land stamp turned all three into "QM v0.0.115" (the repo's new VERSION), then "QM v0.0.116" on the + * next stamp, because the stamp_paths stage read any built-in `vX` mention newer than the fork point on a + * branch-added line as the branch's tentative stamp. The tree receipt called the rewrite stamp-only for the same + * reason. The stamper and every stamp-only check now move a built-in form above the version being stamped + * only when the branch itself recorded it (its version source at its fork point or at any of its own + * commits); a declared stamp_patterns slot keeps the fork-point bound (#3512). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { tentativeMentions } from '../lib/restamp'; +import { stampOnlyLine } from '../lib/stamp-only'; +import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, readFile, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], stamp_files: ['VERSION', 'package.json', 'package-lock.json', 'CHANGELOG.md', 'README.md'] }; +const BASE_README = '# harness\n\nScored with qm v0.1.15 since the start.\n'; +const QM = ['QM v0.1.15 grades every run.', 'Pin QM v0.1.15 in CI.', 'Upgrade notes for QM v0.1.15 are below.']; + +/** harness-evals' shape: a three-part repo at 0.0.114 whose README.md (a stamp file) already mentions qm v0.1.15. */ +function harness(): FixtureRepo { + const repo = makeFixtureRepo({ prefix: 'restamp-foreign-', policy: POLICY, version: '0.0.114' }); + repos.push(repo); + fs.writeFileSync(path.join(repo.work, 'README.md'), BASE_README); + repo.git('add', '-A'); repo.git('commit', '-qm', 'readme'); repo.git('push', '-q', 'origin', 'main'); + return repo; +} +const addReadme = (repo: FixtureRepo, lines: string[]) => fs.writeFileSync(path.join(repo.work, 'README.md'), BASE_README + lines.join('\n') + '\n'); + +describe('a foreign version on a PR-added stamp_files line (GST-1245)', () => { + // Value: protects=a third-party version a PR writes into a stamp file survives the stamp byte for byte (HARN-7's + // "QM v0.1.15" -> "QM v0.0.115"); fails_when=the stamp_paths stage reads any vX newer than the fork point on a + // branch-added line as the branch's own stamp; why_new=no case put a foreign same-width version on a branch line; + // seam=lib/restamp.ts rewriteStampPaths; no_claim=versions of another width (already never candidates) + test('the stamp leaves three PR-added "QM v0.1.15" lines and the base\'s qm line exactly as written', () => { + const repo = harness(); + makePr(repo, 'pr-235'); + addReadme(repo, QM); + repo.git('commit', '-qam', 'docs: QM v0.1.15'); + const pinned = readFile(repo, 'README.md'); + const r = restamp(repo, ['--next']); + expect(r.status, r.err).toBe(EXIT.ok); + expect(readFile(repo, 'VERSION')).toBe('0.0.115\n'); + expect(readFile(repo, 'README.md')).toBe(pinned); + expect(r.out).not.toContain('0.1.15 -> 0.0.115'); + }); + + // Value: protects=the PR's own recorded version still moves on its own lines while the foreign one beside it stays + // (the negative control; harness-evals' second stamp d6801432 moved the QM lines again); fails_when=the fix stops + // moving the branch's own tentative stamp after it lost its slot; why_new=the lost-slot cases carry no foreign + // version; seam=lib/restamp.ts recordedVersions + tentativeMentions; no_claim=none beyond protects + test('control: after a lost slot the PR\'s own v0.0.115 line moves to v0.0.116 and the QM lines beside it do not', () => { + const repo = harness(); + makePr(repo, 'pr-235'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '0.0.115\n'); + addReadme(repo, [...QM, 'harness v0.0.115 adds QM grading.']); + repo.git('commit', '-qam', 'docs and my tentative stamp'); + makePr(repo, 'pr-other'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-235'); + mergeMain(repo); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '0.0.115\n'); + const r = restamp(repo, ['--next']); + expect(r.status, r.err).toBe(EXIT.ok); + expect(readFile(repo, 'VERSION')).toBe('0.0.116\n'); + expect(readFile(repo, 'README.md')).toBe(BASE_README + [...QM, 'harness v0.0.116 adds QM grading.'].join('\n') + '\n'); + }); + + // Value: protects=the tree receipt (stamp, verify, gate-ahead replay) reads a rewritten foreign version as product, + // so a corrupting stamp can no longer pass as stamp-only; fails_when=stampOnlyLine accepts any moved version at or + // above the gated head's or the fork point's; why_new=no receipt case moved a foreign same-width version; + // seam=lib/stamp-only.ts stampOnlyWithin; no_claim=none beyond protects + test('the tree receipt calls the corrupt stamp a product change in README.md and the honest stamp stamp-only', () => { + const repo = harness(); + makePr(repo, 'pr-235'); + addReadme(repo, QM); + repo.git('commit', '-qam', 'docs: QM v0.1.15'); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'honest stamp'); + const honest = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(honest.out, honest.err).toContain('TREE: same-modulo-stamps'); + fs.writeFileSync(path.join(repo.work, 'README.md'), readFile(repo, 'README.md').replace(/QM v0\.1\.15/g, 'QM v0.0.115')); + repo.git('commit', '-q', '--allow-empty', '-am', 'the 1.94.19.0 stamp: QM rewritten'); + const corrupt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(corrupt.out).toMatch(/^TREE: changed \(README\.md\)$/m); + expect(corrupt.out).toContain('changed README.md (stamp_files: "QM v0.1.15 grades every run." -> "QM v0.0.115 grades every run.")'); + }); + + // Value: protects=a declared stamp_patterns slot moves only inside its own match, so the same foreign version in a + // built-in form beside it (same line or another PR line) survives, and the receipt agrees (GST-1245 outside voice); + // fails_when=a template match marks the version tentative and every form of it moves; why_new=no case mixed a slot + // and a foreign built-in mention of one version; seam=lib/restamp.ts slotOnly + lib/stamp-only.ts free; no_claim=none + test('a template slot at an unrecorded version moves alone; the same version as "QM v0.1.15" beside it stays', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-foreign-slot-', policy: { ...POLICY, stamp_patterns: ["_KEY_VERSION = '{version}'"] }, version: '0.0.114' }); + repos.push(repo); + fs.writeFileSync(path.join(repo.work, 'README.md'), BASE_README); + repo.git('add', '-A'); repo.git('commit', '-qm', 'readme'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-slot'); + addReadme(repo, ["export const T_KEY_VERSION = '0.1.15'; // QM v0.1.15", QM[0]!]); + repo.git('commit', '-qam', 'a key at a guessed version and a QM line'); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + expect(readFile(repo, 'README.md')).toBe(BASE_README + ["export const T_KEY_VERSION = '0.0.115'; // QM v0.1.15", QM[0]!].join('\n') + '\n'); + repo.git('commit', '-qam', 'stamp'); + const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(receipt.out, receipt.err).toContain('TREE: same-modulo-stamps'); + }); + + // Value: protects=the pure seams agree: a built-in form moves only a recorded version, a declared template slot keeps + // the fork bound (#3512's next-minor key); fails_when=either seam reads an unrecorded built-in mention as a stamp, + // or the template slot stops moving; why_new=the seams took no recorded set; seam=tentativeMentions, stampOnlyLine; + // no_claim=none beyond protects + test('pure: tentativeMentions and stampOnlyLine take a built-in mention above the new version only when the branch recorded it', () => { + const added = new Map([['README.md', new Set([...QM, 'harness v0.0.117', 'since: 0.0.115', "export const X_KEY_VERSION = '0.1.0';"])]]); + expect(tentativeMentions(added, '0.0.114', '0.0.116', ["_KEY_VERSION = '{version}'"], new Set(['0.0.114', '0.0.117'])).sort()).toEqual(['0.0.115', '0.0.117', '0.1.0']); + expect(stampOnlyLine(QM[0]!, 'QM v0.0.115 grades every run.', '0.0.114', '0.0.115')).toBe(false); + const side = { fork: '0.0.114', added: new Set([QM[0]!]), at: new Set([4]) }; + expect(stampOnlyLine(QM[0]!, 'QM v0.0.115 grades every run.', '0.0.114', '0.0.115', [], side, 4)).toBe(false); + expect(stampOnlyLine('harness v0.0.114', 'harness v0.0.115', '0.0.114', '0.0.115')).toBe(true); + expect(stampOnlyLine('harness v0.0.117', 'harness v0.0.116', '0.0.114', '0.0.116', [], { ...side, added: new Set(['harness v0.0.117']), recorded: new Set(['0.0.114', '0.0.117']) }, 4)).toBe(true); + expect(stampOnlyLine('harness v0.0.117', 'harness v0.0.116', '0.0.114', '0.0.116', [], { ...side, added: new Set(['harness v0.0.117']) }, 4)).toBe(false); + expect(stampOnlyLine('since: 0.0.115', 'since: 0.0.116', '0.0.114', '0.0.116', [], { ...side, added: new Set(['since: 0.0.115']) }, 4)).toBe(true); + const key = "export const T_KEY_VERSION = '0.1.15'; // QM v0.1.15"; + const tpl = ["_KEY_VERSION = '{version}'"]; + expect(stampOnlyLine(key, "export const T_KEY_VERSION = '0.0.115'; // QM v0.1.15", '0.0.114', '0.0.115', tpl, { ...side, added: new Set([key]) }, 4)).toBe(true); + expect(stampOnlyLine(key, "export const T_KEY_VERSION = '0.0.115'; // QM v0.0.115", '0.0.114', '0.0.115', tpl, { ...side, added: new Set([key]) }, 4)).toBe(false); + }); +}); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 18aab27ce..d96c669f0 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -540,6 +540,69 @@ describe('land: one pass, base, lock and history', () => { expect(remote(fx, 'pr-11')).toBe(head); }, 90_000); + // Value: protects=a PR that writes a third-party version into a stamp_files line lands it byte for byte, while its own + // fork-version token still moves (GST-1245, HARN-7: harness-evals#235's "QM v0.1.15" landed as "QM v0.0.115"); + // fails_when=land's restamp reads any vX newer than the fork point on a PR line as the PR's stamp; why_new=no land + // case put a version in a stamp file; seam=lib/restamp.ts rewriteStampPaths via buildStamp; no_claim=none beyond protects + test('a foreign version on a PR-added stamp_files line lands byte for byte; the PR\'s own fork-version token moves (GST-1245)', () => { + const fx = setup({ ...POLICY, stamp_files: ['VERSION', 'package.json', 'package-lock.json', 'CHANGELOG.md', 'README.md'] }); + const qm = 'QM v2.1.15.0 grades every run.\nPin QM v2.1.15.0 in CI.\nUpgrade notes for QM v2.1.15.0 are below.\n'; + openPr(fx, '235', { edit: work => fs.writeFileSync(path.join(work, 'README.md'), qm + 'Written at fixture v1.2.3.0 by this PR.\n') }); + const r = land(fx, ['--pr', '235']); + expect(r.err).toBe(''); + const stamp = remote(fx, 'pr-235'); + expect(show(fx, stamp + ':VERSION')).toBe('1.2.4.0'); + expect(show(fx, stamp + ':README.md')).toBe(qm + 'Written at fixture v1.2.4.0 by this PR.'); + }, 90_000); + + // Value: protects=a stamp reset back to the reviewed pin restamps instead of resuming a journal whose stamp commit the + // branch no longer holds (GST-1245, HARN-7: "resuming at stamped", then QUEUE_TRAIN_MOVED until the journal was removed + // by hand); fails_when=land keeps the journal because the pin is one of the heads it names; why_new=every resume case + // keeps the stamp commit on the branch; seam=lib/ship-queue-land.ts land; no_claim=the lock: own-lock resume already + // matches on PR and journal path, and this case asserts it needs no --break-lock + test.each([['pushed'], ['stamped']])('a stamp reset to the pin at phase %s discards the journal and restamps, with no --break-lock (GST-1245)', phase => { + const fx = setup(); + const base = remote(fx, 'main'); + const pin = openPr(fx, '14'); + expect(land(fx, ['--pr', '14']).status).toBe(EXIT.ok); + const first = remote(fx, 'pr-14'); + const file = path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', '14.json'); + const journal = JSON.parse(fs.readFileSync(file, 'utf8')); + expect([journal.phase, journal.stamp_commit, journal.last_pushed_head]).toEqual(['pushed', first, first]); + // phase stamped: a restamp built its commit and stopped before the push (the state HARN-7's rerun resumed at). + if (phase === 'stamped') fs.writeFileSync(file, JSON.stringify({ ...journal, phase: 'stamped' })); + fx.repo.git('push', '-q', '-f', 'origin', pin + ':refs/heads/pr-14'); + const again = land(fx, ['--pr', '14']); + expect(again.err).toBe(''); + expect(again.status).toBe(EXIT.ok); + expect(again.out).not.toContain('resuming at'); + expect(again.out).toContain(`JOURNAL: #14's head is the reviewed pin ${pin.slice(0, 12)}, not the stamp commit ${first.slice(0, 12)} (phase ${phase}); discarding the journal and restamping`); + expect(again.out).not.toContain('LOCK: broke'); + expect(again.out).toContain('PUSHED: '); + const restamped = remote(fx, 'pr-14'); + expect(fx.repo.git('rev-list', '--parents', '-n', '1', restamped).split(' ').slice(1)).toEqual([pin, base]); + expect(land(fx, ['--pr', '14']).out).toContain('MERGED: #14'); + }, 90_000); + + // Value: protects=a stamp land built but never pushed still resumes and pushes that commit: the branch never moved, + // so nothing is discarded (GST-1245 review); fails_when=the reset check fires on any journal whose head is the pin; + // why_new=the reset cases above all pushed a stamp first; seam=lib/ship-queue-land.ts land; no_claim=none beyond protects + test('a stamp built but never pushed resumes at stamped and pushes that commit, discarding nothing (GST-1245)', () => { + const fx = setup(); + const pin = openPr(fx, '15'); + expect(land(fx, ['--pr', '15']).status).toBe(EXIT.ok); + const first = remote(fx, 'pr-15'); + const file = path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', '15.json'); + fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), phase: 'stamped', last_pushed_head: null })); + fx.repo.git('push', '-q', '-f', 'origin', pin + ':refs/heads/pr-15'); + const again = land(fx, ['--pr', '15']); + expect(again.err).toBe(''); + expect(again.out).toContain('(resuming at stamped)'); + expect(again.out).not.toContain('JOURNAL:'); + expect(again.out).toContain(`PUSHED: ${first}`); + expect(remote(fx, 'pr-15')).toBe(first); + }, 90_000); + // Value: protects=a lost local journal resumes from the stamp commit's pin; fails_when=the stamp commit is treated as the pin and the receipt refuses; why_new=resume cases keep the journal; seam=none; no_claim=none beyond protects test('with the local journal gone, a head that is land\'s stamp commit resumes from its pin', () => { const fx = setup(); diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts index 4dd9f7f27..8472246dd 100644 --- a/test/ship-queue-train-stamp.test.ts +++ b/test/ship-queue-train-stamp.test.ts @@ -151,4 +151,24 @@ describe('stamped train prefixes', () => { expect(out.stamped.map(s => s.version)).toEqual(['1.2.4.0']); expect(show(r, out.stamped[0]!.commit + ':docs/f.md')).toBe('// since: 1.2.4.0'); }, 60_000); + + // Value: protects=a slot whose own claim is above the version the train gives it still moves its built-in-form row, + // because the claim is read from the pin's own commits through the train merge (GST-1245 red team); fails_when=the + // recorded versions walk only the merge's first parent (the predecessor), so the row stays at the stale claim and + // the train's own receipt passes it; seam=lib/restamp.ts recordedVersions via stampChain and stampMismatch + test('a pin claiming 1.2.6.0 above its slot\'s 1.2.5.0 moves its own since: 1.2.6.0 row (GST-1245)', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + pin(r, 'g', 'origin/main', '- g change\n'); + r.git('checkout', '-q', '--detach', 'origin/main'); + fs.writeFileSync(path.join(r.work, 'VERSION'), '1.2.4.0\n'); + r.git('commit', '-qam', 'main releases 1.2.4.0'); r.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); r.git('fetch', '-q', 'origin'); + r.git('checkout', '-q', 'g'); + fs.writeFileSync(path.join(r.work, 'VERSION'), '1.2.6.0\n'); + fs.writeFileSync(path.join(r.work, 'docs', 'g.md'), '// since: 1.2.6.0\nQM v9.9.9.0 stays\n'); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'g: claims 1.2.6.0, row at its claim, a foreign version beside it'); + const out = stampChain(chain(r, [['1', r.git('rev-parse', 'HEAD')]])); + if ('code' in out) throw new Error(`${out.code}: ${out.message}`); + expect(out.stamped.map(s => s.version)).toEqual(['1.2.5.0']); + expect(show(r, out.stamped[0]!.commit + ':docs/g.md')).toBe('// since: 1.2.5.0\nQM v9.9.9.0 stays'); + }, 60_000); });