mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-15 21:07:20 +01:00
fix(restamp): review pass 1 — closing quotes, positional provenance, retry after a partial apply
- since: 'X' and (was 'X') require the quote to close right after X in the scanner and the rewrite table, so a quoted prerelease or a mismatched pair is never a stamp (the tree receipt shares the table). - branchAddedLines records positions from the unified=0 hunk headers; the stamp_paths stage rewrites by position, so a base line with the same text stays and a CRLF checkout still finds the branch's row. - Journal recovery restores the equal-base provenance (no branch stamp) so a stamp interrupted after VERSION was written retries as a stamp, not noop. - Skipped detail names the explicit version it searched for; --was help line names the CHANGELOG re-heading; docs and CHANGELOG state the boundaries, the fork-point attribution and the rule's false-positive class. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e27c79902a
commit
6469472483
5 files changed
+111
-30
No files matched your search
+6
-4
@@ -2,14 +2,16 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**`gstack-restamp --next` moves a PR's quoted `since:` rows when its VERSION already equals the base's (gbrain #7640).** A gbrain PR stamped 0.60.468.0, lost its slot, merged master, and master was 0.60.468.0 by then. At its next slot restamp left the PR's `{ since: '0.60.468.0', … }` notice rows at .468 while the release shipped as .469, and gbrain's `compareReleases` then hid that notice from every user already on .468. Two gaps combined: with VERSION equal on both sides the branch had no stamp of its own, so nothing was rewritten everywhere, and the mention scanner that reads the lines the branch added could not pass the opening quote of a quoted row (`since:\s*(\d…)`), so it found nothing either. `--was 0.60.468.0` was no answer, because it rewrites master's own .468 rows too.
|
||||
**`gstack-restamp --next` moves a PR's quoted `since:` rows when its VERSION already equals the base's (gbrain #7640).** A gbrain PR stamped 0.60.468.0, lost its slot, merged master, and master was 0.60.468.0 by then. At its next slot restamp left the PR's `{ since: '0.60.468.0', … }` notice rows at .468 while the release shipped as .469, and gbrain's `compareReleases` then hid that notice from every user already on .468. Two gaps combined: with VERSION equal on both sides the branch had no stamp of its own, so nothing was rewritten everywhere, and the mention scanner that reads the lines the branch added could not pass the opening quote of a quoted row (`since:\s*(\d…)`), so it found nothing either. `--was 0.60.468.0` was no answer, because it rewrites master's own .468 rows too. The quote fix alone covers that merged-base state (the base's .468 is newer than the branch's .467 fork point, so the scanner names it); the equal-version rule below covers the state the fork-point floor cannot, a branch rebased onto the base after it reached X, or one never stamped.
|
||||
|
||||
### Fixed
|
||||
- **Quoted versions are stamp forms** in both the mention scanner (`tentativeMentions`) and the exact rewrite table (`rewriteStamps`) of `lib/restamp.ts`: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` match like the bare forms and keep their quote style; `vX` already accepted a quote before the `v`. The scanner also reads the policy's `stamp_patterns` templates, so a template-only form on a branch-added line is a candidate too. The tree receipt and the gate-reuse normalizers share `rewriteStamps`, so a quoted `since:` rewrite classifies as a stamp without a template.
|
||||
- **The equal-version rule.** When the branch has no stamp of its own (its VERSION equals the base's and no `--was` is given), a stamp-form mention of the base version on a line the branch added is its stale tentative stamp and moves to the allocated version; the same version on a line the base already carries is the base's release and stays. The `stamp_paths` stage detail, printed in the stage table and kept in the journal, 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 (<file>: N moved, M kept)`), so the stamp commit's tool output shows which lines moved and why. The explicit `--was X` path is unchanged and still rewrites X on every line; its help line now says so.
|
||||
- **Quoted versions are stamp forms** in both the mention scanner (`tentativeMentions`) and the exact rewrite table (`rewriteStamps`) of `lib/restamp.ts`: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` match like the bare forms and keep their quote style, and an opened quote must close right after X, so `since: '1.2.3-beta'` and a mismatched `since: '1.2.3"` are not the form (the tree receipt and the gate-reuse normalizers share `rewriteStamps`, so a quoted `since:` rewrite classifies as a stamp without a template, and a quoted prerelease change never does); `vX` already accepted a quote before the `v`. The scanner also reads the policy's `stamp_patterns` templates, so a template-only form on a branch-added line is a candidate too.
|
||||
- **The equal-version rule.** When the branch has no stamp of its own (its VERSION equals the base's and no `--was` is given), a stamp-form mention of the base version on a line the branch added is its stale tentative stamp and moves to the allocated version; the same version on a line the base already carries is the base's release and stays. The `stamp_paths` stage detail, printed in the stage table and kept in the journal, 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 (<file>: N moved, M kept)`), so the stamp commit's tool output shows which lines moved and why; when an explicit stamp matched nothing the skipped detail now names the version it searched for. The rule reads line ownership, not meaning: a branch-added `Requires vX or later` inside `stamp_paths` on a branch forked at X moves too, visible in `--dry-run` and the counts; `docs/ship-policy.md` says how to write such a reference. The explicit `--was X` path is unchanged and still rewrites X on every line; its help line now says so and names the CHANGELOG re-heading it also drives.
|
||||
- **Branch-added lines are known by position, not text.** `branchAddedLines` records each added line's position from the `--unified=0` hunk headers beside its text, and the stage rewrites by position, so a base line whose text equals a branch-added line stays while the added copy moves, and a CRLF checkout (`core.autocrlf`, `eol=crlf`), whose diff is LF-normalized while the file carries CR, still finds the branch's row instead of reporting `0 moved` and shipping it stale.
|
||||
- **A stamp interrupted after VERSION was written retries as a stamp.** The journal recovery restored the branch's own stamp but kept the already-written VERSION as a stamp when the original was the base version, so the retry rewrote nothing and returned `noop` with the row still stale; it now restores the equal-base provenance (no branch stamp) and the retry completes the stamp.
|
||||
|
||||
### Tests
|
||||
- `test/restamp-mentions.test.ts`: the pure forms (single quote, double quote, bare unchanged, quote style kept, a mismatched quote pair refused, a template overlapping the built-in form counted once, the fork-version floor on quoted mentions) and, on the fixture repo, the reporter's state rebuilt with the real `bin/gstack-restamp`: the other PR takes the slot through the tool, the PR merges main with VERSION equal on both sides, and `--next` moves the PR's quoted row, keeps the base's two rows byte for byte, and says why in the stage detail; the same with the built-in form alone (no `since:` template); a negative control where the PR adds no base-version mention and every base-side quoted row stays; a branch forked at the base version (fork equals base, nothing stamped) whose own base-version row and doc mention move while the base's stay; and `--was` rewriting the base's row as well. Seven of the file's nine cases are red at 1.91.253.0 (the template-overlap and `--was` cases pass there, as they should).
|
||||
- `test/restamp-mentions.test.ts`: the pure forms (single quote, double quote, bare unchanged, quote style kept, a mismatched quote pair refused, a template overlapping the built-in form counted once, the fork-version floor on quoted mentions) and, on the fixture repo, the reporter's state rebuilt with the real `bin/gstack-restamp`: the other PR takes the slot through the tool, the PR merges main with VERSION equal on both sides, and `--next` moves the PR's quoted row, keeps the base's two rows byte for byte, and says why in the stage detail; the same with the built-in form alone (no `since:` template); a negative control where the PR adds no base-version mention and every base-side quoted row stays; a branch forked at the base version (fork equals base, nothing stamped) whose own base-version row and doc mention move while the base's stay; and `--was` rewriting the base's row as well; then the quoted-prerelease and mismatched-quote boundaries in both the scanner and the table, a branch-added line whose text equals a base line (the base copy stays), a CRLF checkout, and an apply interrupted by `EACCES` after VERSION was written whose retry completes the stamp. Seven of the file's first nine cases are red at 1.91.253.0 (the template-overlap and `--was` cases pass there, as they should).
|
||||
- `test/stamp-templates.test.ts`: the quoted `since:` row now counts once with or without the template (it pinned zero without one).
|
||||
|
||||
### Docs
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and
|
||||
--bump <level> micro|patch|minor|major (default: policy bump, else micro)
|
||||
--queue-mode <mode> stamp-at-merge | claim-at-open (default: policy queue_mode, else claim-at-open)
|
||||
--pr <n> this PR, for --expect-order and the gate-ahead record
|
||||
--was <X> rewrite X on every line of every stamp file, the base's own lines included (default: the branch's own stamp everywhere, else only the stale mentions on lines the branch added)
|
||||
--was <X> rewrite X on every line of every stamp file, the base's own lines included, and re-head the CHANGELOG section [X] (default: the branch's own stamp everywhere, else only the stale mentions on lines the branch added)
|
||||
--expect-base <sha> refuse with QUEUE_STALE when origin/<base> moved
|
||||
--expect-order <pr,...> refuse with QUEUE_STALE when a predecessor is unmerged or a successor merged
|
||||
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands)
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
|
||||
| `pr_count` | `{ default_pr_count, split_requires_owner }` | `{1, true}` | The PR-count row in plan reviews. |
|
||||
| `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. |
|
||||
| `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. |
|
||||
| `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, `<!-- x-stamp: {version} -->`); matched literally at a version boundary, never as a regex. The restamp writer and the tree receipt use them beside the built-in forms. |
|
||||
| `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, `<!-- x-stamp: {version} -->`); matched literally at a version boundary, never as a regex. The restamp writer, its mention scanner and the tree receipt use them beside the built-in forms. |
|
||||
| `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). |
|
||||
| `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. |
|
||||
| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. |
|
||||
@@ -612,7 +612,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
|
||||
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
|
||||
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 `<X>`, 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/<base>` 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 `<X>` (`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', … }`), 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, else `--was <X>`) is rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (so a queue reorder that moves the base under a branch still finds its `since:` fields), and when the branch has no stamp of its own because its VERSION equals the base's (it stamped X, lost its slot, merged the base and the base was X by then; or it 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 (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
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, else `--was <X>`) is 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 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 (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. 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.
|
||||
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).
|
||||
|
||||
|
||||
+44
-23
@@ -240,24 +240,40 @@ export function parseUnifiedDiff(text: string): FileDiff[] {
|
||||
return files;
|
||||
}
|
||||
|
||||
/** The lines a branch itself added, per stamp file: `git diff --unified=0 <since> -- <files>` in `cwd` against its working tree. */
|
||||
export function branchAddedLines(cwd: string, since: string, files: string[]): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
/** The lines a branch itself added to one file: their text, and their 1-based positions in the working-tree file. */
|
||||
export interface AddedLines { text: Set<string>; at: Set<number> }
|
||||
|
||||
/**
|
||||
* The lines a branch itself added, per stamp file: `git diff --unified=0 <since> -- <files>` in `cwd`
|
||||
* against its working tree. Positions come from each hunk's new-side start (no context under
|
||||
* `--unified=0`, so a hunk's added lines are consecutive from there), so a base line whose text
|
||||
* equals an added line, or a CRLF checkout whose diff is LF-normalized, is told apart by position.
|
||||
*/
|
||||
export function branchAddedLines(cwd: string, since: string, files: string[]): Map<string, AddedLines> {
|
||||
const out = new Map<string, AddedLines>();
|
||||
if (files.length === 0) return out;
|
||||
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', since, '--', ...files]);
|
||||
for (const f of parseUnifiedDiff(diff.out)) {
|
||||
const set = out.get(f.path) ?? new Set<string>();
|
||||
for (const h of f.hunks) for (const a of h.added) set.add(a);
|
||||
out.set(f.path, set);
|
||||
const own = out.get(f.path) ?? { text: new Set<string>(), at: new Set<number>() };
|
||||
for (const h of f.hunks) h.added.forEach((a, i) => { own.text.add(a); own.at.add(h.at + i); });
|
||||
out.set(f.path, own);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const VERSION_RE = '(\\d+(?:\\.\\d+){1,3})(?![0-9.])';
|
||||
/** The built-in stamp forms, each with the version as group 1: `(was X)`, `since: X` (a quote may open X: `since: 'X'`, `(was "X")`) and `vX`. */
|
||||
const BUILT_IN_MENTION = new RegExp(`(?:\\(was ['"]?|since:\\s*['"]?|(?<![A-Za-z0-9.])v)${VERSION_RE}`, 'g');
|
||||
/** A policy `stamp_patterns` template (one `{version}` slot) as a scanner, the version as group 1 at a version boundary. */
|
||||
const VERSION_RE = '(?<v>\\d+(?:\\.\\d+){1,3})(?![0-9.])';
|
||||
/**
|
||||
* The built-in stamp forms as scanners, the version in the `v` group: `(was X)`, `since: X` and `vX`.
|
||||
* A quote may wrap X in the first two (`since: 'X'`, `(was "X")`); an opened quote must close right
|
||||
* after X, so `since: '1.2.3-beta'` and `since: '1.2.3"` are not the form.
|
||||
*/
|
||||
const BUILT_IN_MENTIONS: readonly RegExp[] = [
|
||||
new RegExp(`\\(was (['"]?)${VERSION_RE}(?=\\1\\))`, 'g'),
|
||||
new RegExp(`since:\\s*(['"]?)${VERSION_RE}(?=\\1)`, 'g'),
|
||||
new RegExp(`(?<![A-Za-z0-9.])v${VERSION_RE}`, 'g'),
|
||||
];
|
||||
/** A policy `stamp_patterns` template (one `{version}` slot) as a scanner, the version in the `v` group at a version boundary. */
|
||||
function templateMention(template: string): RegExp {
|
||||
const [pre, post] = template.split(VERSION_SLOT) as [string, string];
|
||||
return new RegExp(`${escapeRegExp(pre)}(?<![0-9.])${VERSION_RE}${escapeRegExp(post)}`, 'g');
|
||||
@@ -275,13 +291,13 @@ export function tentativeMentions(added: Map<string, Set<string>>, forkVersion:
|
||||
const fork = parseVersion(forkVersion);
|
||||
if (!fork) return [];
|
||||
const width = versionWidth(forkVersion);
|
||||
const scanners = [BUILT_IN_MENTION, ...templates.map(templateMention)];
|
||||
const scanners = [...BUILT_IN_MENTIONS, ...templates.map(templateMention)];
|
||||
const found = new Set<string>();
|
||||
for (const lines of added.values()) {
|
||||
for (const text of lines) {
|
||||
for (const re of scanners) {
|
||||
for (const m of text.matchAll(re)) {
|
||||
const raw = m[1]!;
|
||||
const raw = m.groups!.v!;
|
||||
const v = parseVersion(raw);
|
||||
if (!v || versionWidth(raw) !== width || raw === newVersion || cmpVersion(v, fork) <= 0) continue;
|
||||
found.add(raw);
|
||||
@@ -305,14 +321,15 @@ export function forkVersion(repoRoot: string, baseRef: string, fallback: string)
|
||||
* `stamp_patterns` templates (one `{version}` slot, matched at a version
|
||||
* boundary); anything else in the file stays. A quote around X in the
|
||||
* `(was X)` and `since: X` forms (`since: 'X'`, `(was "X")`, the shape of a
|
||||
* quoted source row) is matched and kept as written; `vX` already accepts a
|
||||
* quote before the `v`.
|
||||
* quoted source row) is matched and kept as written when it closes right after
|
||||
* 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 } {
|
||||
const v = escapeRegExp(oldVersion);
|
||||
const patterns: Array<[RegExp, (...m: string[]) => string]> = [
|
||||
[new RegExp(`\\(was (['"]?)${v}\\1\\)`, 'g'), (_, q) => `(was ${q}${newVersion}${q})`],
|
||||
[new RegExp(`(since:\\s*['"]?)${v}(?![0-9.])`, 'g'), (_, pre) => `${pre}${newVersion}`],
|
||||
[new RegExp(`(since:\\s*)(['"]?)${v}(?![0-9.])(?=\\2)`, 'g'), (_, pre, q) => `${pre}${q}${newVersion}`],
|
||||
[new RegExp(`(?<![A-Za-z0-9.])v${v}(?![0-9.])`, 'g'), () => `v${newVersion}`],
|
||||
...templates.map(t => { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; return [new RegExp(`${escapeRegExp(pre)}(?<![0-9.])${v}(?![0-9.])${escapeRegExp(post)}`, 'g'), () => `${pre}${newVersion}${post}`] as [RegExp, () => string]; }),
|
||||
];
|
||||
@@ -441,7 +458,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
|
||||
}
|
||||
const added = branchAddedLines(staging, since, files);
|
||||
const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion));
|
||||
const tentative = new Set(tentativeMentions(added, 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).filter(v => !explicit.has(v)));
|
||||
const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null;
|
||||
if (equalBase) tentative.add(equalBase);
|
||||
const moved = new Map<string, number>();
|
||||
@@ -462,14 +479,15 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
|
||||
if (r.count) moved.set(old, (moved.get(old) ?? 0) + r.count);
|
||||
return r.text;
|
||||
};
|
||||
const out = fs.readFileSync(abs, 'utf8').split('\n').map(line => {
|
||||
let text = line;
|
||||
for (const old of explicit) text = rewrite(text, old);
|
||||
if (own?.has(line)) for (const old of tentative) text = rewrite(text, old);
|
||||
const out = fs.readFileSync(abs, 'utf8').split('\n').map((line, i) => {
|
||||
const mine = own?.at.has(i + 1) ?? false;
|
||||
if (equalBase) {
|
||||
const n = rewriteStamps(line, equalBase, plan.newVersion, templates).count;
|
||||
if (own?.has(line)) ownBase += n; else keptBase += n;
|
||||
if (mine) ownBase += n; else keptBase += n;
|
||||
}
|
||||
let text = line;
|
||||
for (const old of explicit) text = rewrite(text, old);
|
||||
if (mine) for (const old of tentative) text = rewrite(text, old);
|
||||
return text;
|
||||
}).join('\n');
|
||||
if (ownBase || keptBase) baseSide.push(`${rel}: ${ownBase} moved, ${keptBase} kept`);
|
||||
@@ -480,9 +498,10 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
|
||||
const equalNote = equalBase && baseSide.length
|
||||
? `; VERSION equals base ${equalBase}, so ${equalBase} moved only on lines this branch added and stayed on the base's own lines (${baseSide.join('; ')})`
|
||||
: '';
|
||||
const searched = explicit.size ? `no mention of ${[...explicit].join(', ')} in the stamp files and ` : '';
|
||||
sp.detail = olds.length
|
||||
? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}`
|
||||
: `no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`;
|
||||
: `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`;
|
||||
}
|
||||
|
||||
function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] {
|
||||
@@ -570,7 +589,9 @@ export function restamp(o: RestampOptions): RestampResult {
|
||||
if (!o.after && fs.existsSync(journalPath)) {
|
||||
try {
|
||||
const prior: Journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
|
||||
if (prior.inputs?.new_version === plan.newVersion && prior.status !== 'complete' && prior.inputs.old_version !== plan.newVersion) { plan.oldVersion = prior.inputs.old_version; plan.branchStamp = prior.inputs.old_version !== plan.baseVersion ? prior.inputs.old_version : plan.branchStamp; }
|
||||
// A retry after a partial apply sees the already-written VERSION as a branch stamp; the journal holds the
|
||||
// original provenance (the branch's own stamp, or the base version when the branch had none), so restore it.
|
||||
if (prior.inputs?.new_version === plan.newVersion && prior.status !== 'complete' && prior.inputs.old_version !== plan.newVersion) { plan.oldVersion = prior.inputs.old_version; plan.branchStamp = prior.inputs.old_version !== plan.baseVersion ? prior.inputs.old_version : null; }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ describe('quoted stamp forms (pure)', () => {
|
||||
// A quote is an exact boundary too: a longer version behind it is not X.
|
||||
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps("since: '1.2.3.0.1'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
// An opened quote must close right after X: a quoted prerelease or a mismatched pair is not the form
|
||||
// (the tree receipt and gate reuse share this table, so a looser match would call a real change a stamp).
|
||||
expect(rewriteStamps("since: '1.2.3.0-beta'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps('since: "1.2.3.0-beta"', '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps("since: '1.2.3.0\"", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps('since: 1.2.3.0-beta', '1.2.3.0', '1.2.4.0').count).toBe(1);
|
||||
});
|
||||
|
||||
test('rewriteStamps: (was X) accepts a matching quote pair and keeps it; a mismatched pair is not the form; a quoted vX already matched', () => {
|
||||
@@ -59,6 +65,9 @@ describe('quoted stamp forms (pure)', () => {
|
||||
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0', ['<!-- x-tree-stamp: {version} -->']).sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0', '1.2.7.0']);
|
||||
// The fork-version floor and the allocated version still apply to quoted mentions.
|
||||
expect(tentativeMentions(added, '1.2.5.0', '1.2.6.0').sort()).toEqual([]);
|
||||
// A quoted prerelease, a mismatched pair and an unclosed (was are not candidates; the bare prerelease still is.
|
||||
const odd = new Map([['x', new Set(["since: '1.2.4.0-rc'", 'since: "1.2.5.0\'', "(was '1.2.6.0'x", '(was 1.2.7.0-rc)', 'since: 1.2.8.0-rc'])]]);
|
||||
expect(tentativeMentions(odd, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.8.0']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +168,55 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => {
|
||||
expect(readFile(repo, 'docs/notes.md')).toBe('Previous release v1.2.3.0.\nleft-pad pinned at 1.2.3 (was 1.2.2)\n');
|
||||
});
|
||||
|
||||
test('provenance is by position: a branch-added line whose text equals a base line moves alone, and the base copy stays', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-dup-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'dup')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.3.0', 'dup')]));
|
||||
repo.git('commit', '-qam', 'my row, same text as the base row');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe(`1 mention(s) in 1 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.4.0', 'dup')]));
|
||||
});
|
||||
|
||||
test('a CRLF checkout (core.autocrlf, eol=crlf) whose diff is LF-normalized still finds the branch-added row', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-crlf-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, '.gitattributes', `${NOTICE} text eol=crlf\n`);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('config', 'core.autocrlf', 'true');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]).replace(/\n/g, '\r\n'));
|
||||
repo.git('commit', '-qam', 'my row');
|
||||
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toContain("+ { since: '1.2.3.0', id: 'mine'");
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]).replace(/\n/g, '\r\n'));
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('a stamp interrupted after VERSION was written retries as a stamp, not a noop: the journal restores the equal-base provenance', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-retry-', policy: policy(true) }); repos.push(repo);
|
||||
lostSlot(repo);
|
||||
const dir = path.join(repo.work, path.dirname(NOTICE));
|
||||
fs.chmodSync(dir, 0o555);
|
||||
try {
|
||||
const first = restamp(repo, ['--next']);
|
||||
expect(first.status).not.toBe(EXIT.ok);
|
||||
expect(first.err).toMatch(/EACCES|permission denied/i);
|
||||
} finally { fs.chmodSync(dir, 0o755); }
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
|
||||
expect(readFile(repo, NOTICE)).toContain(row('1.2.4.0', 'mine'));
|
||||
const again = restamp(repo, ['--next']);
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(again.out).toContain('RESTAMP: 1.2.5.0 -> 1.2.5.0 (stamped;');
|
||||
expect(stageDetail(again.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
|
||||
expect(JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json')).status).toBe('complete');
|
||||
});
|
||||
|
||||
test('--was still rewrites every line, the base\'s rows included: it is not the fix for this state', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-was-', policy: policy(true) }); repos.push(repo);
|
||||
lostSlot(repo);
|
||||
|
||||
Reference in new issue
Block a user