okf-kit 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,24 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { parseFrontmatter } from "../bundle.js";
3
4
  import { runGit as defaultRunGit } from "../git.js";
4
- import { getTimestampEpoch, getValidSources } from "../util.js";
5
+ import { getRawTimestampString, getTimestampEpoch, getTimestampIdentity, getValidSources, hasUtcDesignator, } from "../util.js";
5
6
  const RULE_ID = "sources-fresh";
7
+ const FUTURE_RULE_ID = "sources-fresh-future";
8
+ /**
9
+ * Default clock-skew allowance (seconds) for `sources-fresh-future`: a doc
10
+ * timestamp up to this far after the doc's own last commit is still treated
11
+ * as fresh, absorbing the ordinary gap between "author wrote the timestamp"
12
+ * and "the commit that carries it landed". Override via
13
+ * `ctx.freshnessFutureSkewSeconds` (CLI: `--future-skew-minutes`).
14
+ */
15
+ export const DEFAULT_FUTURE_SKEW_SECONDS = 600;
6
16
  export const sourcesFreshRule = {
7
17
  id: RULE_ID,
8
- description: "Frontmatter `sources` paths must not have a last-commit time newer than the doc's `timestamp` and the doc file's own last commit.",
18
+ description: "Frontmatter `sources` paths must not have a last-commit time newer than the doc's `timestamp`, unless the doc's own last commit lands at/after the source's and that same commit actually re-stamped the doc: the doc's parsed frontmatter `timestamp` VALUE at that commit differs from its value in the commit's first parent (rename-aware; creating the doc counts as re-stamping it). Creation is trusted only in a genuinely unshallow repository: in a shallow clone, a commit with no parents can simply be where history was cut off, not a real root commit, so it gets the same `not assessable` notice instead of being assumed created. When git cannot answer the question at all, the doc gets a `not assessable` notice instead of either a STALE warning or a silent pass.",
9
19
  run(ctx) {
10
20
  const findings = [];
11
- const docsWithSources = ctx.docs
12
- .map((doc) => ({ doc, sources: getValidSources(doc.frontmatter.parsed) }))
13
- .filter((entry) => entry.sources !== undefined);
21
+ const docsWithSources = getDocsWithSources(ctx);
14
22
  if (docsWithSources.length === 0)
15
23
  return findings;
16
24
  if (!ctx.repoRoot) {
@@ -18,6 +26,9 @@ export const sourcesFreshRule = {
18
26
  // either (sources-shape), so staleness truly was not assessed, not
19
27
  // "everything looked fine". One notice for the whole bundle, not one
20
28
  // per doc: this is a bundle-level condition, not a per-doc finding.
29
+ // sources-fresh-future shares this same population and posture; it
30
+ // relies on this single notice too instead of emitting its own (see
31
+ // that rule below).
21
32
  findings.push({
22
33
  ruleId: RULE_ID,
23
34
  severity: "notice",
@@ -28,17 +39,39 @@ export const sourcesFreshRule = {
28
39
  }
29
40
  const repoRoot = ctx.repoRoot;
30
41
  const git = ctx.runGit ?? defaultRunGit;
42
+ // `--dirty-as-now` asked for, but git could not tell us what is dirty
43
+ // (a `status` past RunGit's output cap, a `rev-parse` failure, git
44
+ // missing): every path below then falls through to committed history,
45
+ // which is the RIGHT fallback but the WRONG silence -- "the flag found
46
+ // nothing dirty" and "the flag never ran" produce the identical empty
47
+ // finding list. Say so once, bundle-level, in the same style as the
48
+ // "not inside a git work tree" notice above (and, like that one,
49
+ // emitted only here: `sources-fresh-future` shares the same degraded
50
+ // state and relies on this single notice instead of duplicating it).
51
+ if (ctx.dirtyAsNow && getDirtyStateShared(ctx, git, repoRoot) === null) {
52
+ findings.push({
53
+ ruleId: RULE_ID,
54
+ severity: "notice",
55
+ file: "",
56
+ message: "`--dirty-as-now` not applied: git status could not be read, judging by committed history only",
57
+ });
58
+ }
31
59
  // One git call per unique source path across all docs, even though a
32
60
  // STALE/untracked finding is reported per (doc, path) below.
33
61
  const commitEpochCache = new Map();
34
- const commitEpochFor = (source) => {
62
+ // `--dirty-as-now`: every dirty path's "commit epoch" is the SAME
63
+ // virtual-commit instant, shared (by `ctx` identity) with
64
+ // `sources-fresh-future`'s doc-epoch lookup below -- see
65
+ // `commitEpochWithDirtyAsNow`'s doc comment for why this lives in one
66
+ // place instead of being reimplemented per rule.
67
+ const commitEpochFor = (source) => commitEpochWithDirtyAsNow(ctx, git, repoRoot, source, () => {
35
68
  const cached = commitEpochCache.get(source);
36
69
  if (cached !== undefined)
37
70
  return cached;
38
71
  const epoch = getLastCommitEpoch(git, repoRoot, source);
39
72
  commitEpochCache.set(source, epoch);
40
73
  return epoch;
41
- };
74
+ });
42
75
  for (const { doc, sources } of docsWithSources) {
43
76
  const timestampEpoch = getTimestampEpoch(doc.frontmatter.parsed);
44
77
  if (timestampEpoch === undefined) {
@@ -51,15 +84,68 @@ export const sourcesFreshRule = {
51
84
  continue;
52
85
  }
53
86
  // A doc whose own last commit is at or after the source's last commit
54
- // (typically: both landed in one squash-merge) is not stale, even if
55
- // its frontmatter timestamp is old. Lazy + memoized: the lookup costs a
56
- // git process per doc but is only ever consulted on the stale path.
57
- const repoRelDocPath = path
58
- .relative(repoRoot, path.join(ctx.bundleDir, doc.relPath))
59
- .split(path.sep)
60
- .join("/");
61
- let docCommitEpochMemo;
62
- const docCommitEpochFor = () => (docCommitEpochMemo ??= getLastCommitEpoch(git, repoRoot, repoRelDocPath));
87
+ // (typically: both landed in one squash-merge), AND that same commit
88
+ // actually re-stamped the doc, is not stale, even if its frontmatter
89
+ // timestamp predates the merge -- see restampedByOwnLastCommit below
90
+ // for what "re-stamped" means and why the plain commit-ordering check
91
+ // alone is not enough. Both lookups are lazy + memoized: they are only
92
+ // ever consulted on the stale path, and the epoch lookup is shared
93
+ // with sources-fresh-future via getDocCommitEpochShared so the two
94
+ // rules don't each spawn their own `git log` for the same doc in one
95
+ // `check` run.
96
+ //
97
+ // GIT PROCESS BUDGET, per doc, per `check` run, with `--dirty-as-now`
98
+ // OFF (this figure is measured with the flag off; see below for its
99
+ // extra cost with the flag on): 1 (the shared `git log -1
100
+ // --format=%ct` epoch lookup, always) + at most 4 more on the
101
+ // re-stamp path (`git log -1 --format=%H%n%P`, then `git diff-tree`,
102
+ // then two `git show`s), i.e. AT MOST 5 -- regardless of how many
103
+ // sources the doc declares, since both lookups are memoized per doc.
104
+ // A doc created by its last commit (or by the repo's root commit)
105
+ // stops after 2 (or 1). Plus one `git log` per UNIQUE source path
106
+ // across the whole bundle (commitEpochCache above). Pinned by
107
+ // "spends at most five git processes per doc" in
108
+ // test/sources-fresh.test.ts. PLUS: at most 1 `git rev-parse
109
+ // --is-shallow-repository` for the ENTIRE run, not per doc (see
110
+ // isShallowRepoShared below) -- only spent at all when some doc's
111
+ // re-stamp lookup actually reaches a root commit.
112
+ //
113
+ // With `--dirty-as-now` ON, add 2 for the ENTIRE run (not per doc,
114
+ // not per source -- see readDirtyState): 1 `git status --porcelain`
115
+ // and 1 `git rev-parse --show-prefix`. Plus, PER DOC, at most 1 `git
116
+ // ls-tree HEAD -- <doc>` (and, only when the doc already existed at
117
+ // HEAD, one more `git show HEAD:<doc>`), spent only when that doc is
118
+ // itself dirty AND some source of its reads stale (see restampFor
119
+ // below).
120
+ const repoRelDocPath = toRepoRelDocPath(repoRoot, ctx.bundleDir, doc);
121
+ const docCommitEpochFor = () => getDocCommitEpochShared(ctx, git, repoRoot, repoRelDocPath);
122
+ // The single decision point for "did the doc's last commit -- real or
123
+ // virtual -- actually re-stamp it": a dirty doc (under
124
+ // `--dirty-as-now`) is judged by its WORKING-TREE value against the
125
+ // value committed at HEAD (`dirtyDocRestampVerdict`, the virtual
126
+ // commit's implicit "first parent" is simply HEAD); a doc that is not
127
+ // itself dirty is judged the ordinary way, against its real last
128
+ // commit's first parent (`restampedByOwnLastCommit`). This replaces
129
+ // the round-2 approach of a SEPARATE rescue branch checked before the
130
+ // co-commit path: once `docCommitEpochFor` above already reports the
131
+ // dirty doc's epoch as "now" (see `commitEpochWithDirtyAsNow`), the
132
+ // ordinary `docCommitEpoch >= commitEpoch` gate below fires for a
133
+ // dirty doc exactly the way it fires for a real co-commit, so only
134
+ // ONE verdict function needs to be picked here, not two independent
135
+ // mechanisms answering overlapping questions.
136
+ let restampMemo;
137
+ const restampFor = () => {
138
+ if (restampMemo !== undefined)
139
+ return restampMemo;
140
+ restampMemo =
141
+ ctx.dirtyAsNow && isDirtyForShared(ctx, git, repoRoot, repoRelDocPath)
142
+ ? dirtyDocRestampVerdict(git, repoRoot, repoRelDocPath, getTimestampIdentity(doc.frontmatter.parsed))
143
+ : restampedByOwnLastCommit(git, repoRoot, repoRelDocPath, () => isShallowRepoShared(ctx, git, repoRoot));
144
+ return restampMemo;
145
+ };
146
+ // At most one "not assessable" notice per doc, however many of its
147
+ // sources hit the unanswerable re-stamp question.
148
+ let notAssessableReported = false;
63
149
  for (const source of sources) {
64
150
  // A missing path on disk is sources-shape's job to report; avoid a
65
151
  // duplicate/confusing finding here.
@@ -76,13 +162,37 @@ export const sourcesFreshRule = {
76
162
  continue;
77
163
  }
78
164
  let isStale = commitEpoch > timestampEpoch;
79
- // Doc committed at/after the source: not stale (see comment above).
80
- // A doc without git history (null epoch, e.g. uncommitted) keeps the
81
- // frontmatter-only comparison.
82
165
  if (isStale) {
166
+ // Doc committed (or, under `--dirty-as-now`, virtually committed
167
+ // right now) at/after the source, AND that commit actually
168
+ // re-stamped it: not stale (see comment above). A doc without git
169
+ // history (null epoch, e.g. uncommitted and clean, no dirty
170
+ // status at all) keeps the frontmatter-only comparison. When git
171
+ // cannot answer the re-stamp question at all, the doc is reported
172
+ // as not assessable rather than being guessed either way: calling
173
+ // it STALE would turn a git hiccup into a red build, and calling
174
+ // it fresh would be a silent pass.
83
175
  const docCommitEpoch = docCommitEpochFor();
84
176
  if (docCommitEpoch !== null && docCommitEpoch >= commitEpoch) {
85
- isStale = false;
177
+ const verdict = restampFor();
178
+ if (verdict === "restamped") {
179
+ isStale = false;
180
+ }
181
+ else if (verdict === "unknown" ||
182
+ verdict === "unknown-shallow-root") {
183
+ if (!notAssessableReported) {
184
+ notAssessableReported = true;
185
+ findings.push({
186
+ ruleId: RULE_ID,
187
+ severity: "notice",
188
+ file: doc.relPath,
189
+ message: verdict === "unknown-shallow-root"
190
+ ? "staleness not assessable: this is a shallow clone (`git clone --depth`), so git cannot tell whether the doc's earliest available commit really created it or is just where history was cut off -- use `fetch-depth: 0` (or an unshallow checkout) to assess it"
191
+ : "staleness not assessable: git could not read the doc's own last commit to decide whether it re-stamped the doc",
192
+ });
193
+ }
194
+ continue;
195
+ }
86
196
  }
87
197
  }
88
198
  if (isStale) {
@@ -98,6 +208,109 @@ export const sourcesFreshRule = {
98
208
  return findings;
99
209
  },
100
210
  };
211
+ /**
212
+ * Complements `sources-fresh`'s "too old" check with the opposite direction:
213
+ * a doc `timestamp` that is later than the doc file's OWN last commit (past
214
+ * a small clock-skew allowance) is almost always a mistake, not a real
215
+ * future date -- typically a local wall-clock time hand-written with a
216
+ * trailing `Z`/UTC suffix it does not actually have. Unlike `sources-fresh`,
217
+ * this check never looks at `sources` commit times at all: it only compares
218
+ * the doc's own `timestamp` against the doc file's own git history, so it
219
+ * has nothing to say about whether any source is stale.
220
+ *
221
+ * Deliberately assessed for the SAME population as `sources-fresh` (docs
222
+ * with a validly-shaped `sources` list and a repo root available): a
223
+ * `timestamp` only has "last verified against sources" semantics for a doc
224
+ * that declares `sources` (see the package README's authoring guidance), so
225
+ * a sourceless doc is out of scope for both freshness rules, not just this
226
+ * one. It shares `sources-fresh`'s "staleness unknown" posture for the two
227
+ * cases that make a real answer impossible: no repo root (silently defers
228
+ * to the single bundle-level notice `sources-fresh` already emits above,
229
+ * rather than duplicating it) and no valid `timestamp` (`sources-fresh`
230
+ * already reports that per-doc notice, so this rule silently skips such a
231
+ * doc rather than reporting it twice). An uncommitted doc (no own commit
232
+ * yet) is likewise "unknown, not flagged" by DEFAULT: there is no real
233
+ * commit time to compare the timestamp against, and flagging every
234
+ * hand-authored, not-yet-committed doc as "future-dated" would be a false
235
+ * positive on every fresh draft. Under `--dirty-as-now` (`ctx.dirtyAsNow`),
236
+ * this changes: `getDocCommitEpochShared` below substitutes the shared
237
+ * virtual-commit "now" instant for ANY dirty doc's epoch (untracked
238
+ * included -- see `commitEpochWithDirtyAsNow`), so a dirty doc DOES get a
239
+ * real comparison here, exactly the working-tree parity `--dirty-as-now`
240
+ * exists for: a `timestamp` re-stamped to "now" reads fresh (within the
241
+ * skew allowance), while one still carrying an implausible future value
242
+ * gets caught before the commit that would otherwise make it so in CI.
243
+ */
244
+ export const sourcesFreshFutureRule = {
245
+ id: FUTURE_RULE_ID,
246
+ description: "A doc's frontmatter `timestamp` must not be later than the doc file's own last commit time by more than a clock-skew allowance (default 10 minutes, `--future-skew-minutes`); catches a local time mistakenly written with a `Z`/UTC suffix. Skipped (notice) for a timestamp with no explicit UTC designator (`Z`) or numeric offset, since that parses in the local timezone and cannot be compared reliably against a minutes-wide allowance. Assessed for the same docs as `sources-fresh` (a `sources` list and a repo root); see the README's \"Staleness (sources-fresh)\" section for how the two rules relate.",
247
+ run(ctx) {
248
+ const findings = [];
249
+ const docsWithSources = getDocsWithSources(ctx);
250
+ if (docsWithSources.length === 0)
251
+ return findings;
252
+ if (!ctx.repoRoot)
253
+ return findings;
254
+ const repoRoot = ctx.repoRoot;
255
+ const git = ctx.runGit ?? defaultRunGit;
256
+ const skewSeconds = ctx.freshnessFutureSkewSeconds ?? DEFAULT_FUTURE_SKEW_SECONDS;
257
+ for (const { doc } of docsWithSources) {
258
+ const timestampEpoch = getTimestampEpoch(doc.frontmatter.parsed);
259
+ if (timestampEpoch === undefined)
260
+ continue;
261
+ // A string timestamp with no `Z`/numeric-offset suffix parses in the
262
+ // machine's local timezone (`Date.parse`), which would make this
263
+ // check's verdict swing by hours between machines against a
264
+ // minutes-wide allowance -- not usable. A native `Date` frontmatter
265
+ // value (see getTimestampEpoch's doc comment) carries no such
266
+ // ambiguity and always passes this gate. sources-fresh's own
267
+ // thresholds are in days, wide enough that this ambiguity doesn't
268
+ // practically matter there, so this gate is deliberately NOT applied
269
+ // to that rule.
270
+ const rawTimestamp = getRawTimestampString(doc.frontmatter.parsed);
271
+ if (rawTimestamp !== undefined && !hasUtcDesignator(rawTimestamp)) {
272
+ findings.push({
273
+ ruleId: FUTURE_RULE_ID,
274
+ severity: "notice",
275
+ file: doc.relPath,
276
+ message: "future-dated check skipped: timestamp has no UTC designator (`Z`) or numeric offset, can't be compared reliably across timezones",
277
+ });
278
+ continue;
279
+ }
280
+ const repoRelDocPath = toRepoRelDocPath(repoRoot, ctx.bundleDir, doc);
281
+ const docCommitEpoch = getDocCommitEpochShared(ctx, git, repoRoot, repoRelDocPath);
282
+ if (docCommitEpoch === null)
283
+ continue;
284
+ if (timestampEpoch > docCommitEpoch + skewSeconds) {
285
+ findings.push({
286
+ ruleId: FUTURE_RULE_ID,
287
+ severity: "warning",
288
+ file: doc.relPath,
289
+ message: `FUTURE-DATED: doc timestamp ${epochToIso(timestampEpoch)} is after the doc's own last commit ${epochToIso(docCommitEpoch)} (skew allowance ${skewSeconds}s)`,
290
+ });
291
+ }
292
+ }
293
+ return findings;
294
+ },
295
+ };
296
+ /**
297
+ * Docs carrying a validly-shaped frontmatter `sources` list (see
298
+ * `getValidSources`), each paired with that list. Shared by both rules in
299
+ * this file: `sources-fresh` and `sources-fresh-future` assess the same
300
+ * doc population, just in opposite time directions.
301
+ */
302
+ function getDocsWithSources(ctx) {
303
+ return ctx.docs
304
+ .map((doc) => ({ doc, sources: getValidSources(doc.frontmatter.parsed) }))
305
+ .filter((entry) => entry.sources !== undefined);
306
+ }
307
+ /** `doc`'s own path, relative to `repoRoot`, forward-slash separated -- the pathspec `git log` needs. */
308
+ function toRepoRelDocPath(repoRoot, bundleDir, doc) {
309
+ return path
310
+ .relative(repoRoot, path.join(bundleDir, doc.relPath))
311
+ .split(path.sep)
312
+ .join("/");
313
+ }
101
314
  /**
102
315
  * Last-commit epoch (seconds) for `source` relative to `repoRoot`, or null
103
316
  * when the path has no git history (untracked) or the git call itself
@@ -105,6 +318,10 @@ export const sourcesFreshRule = {
105
318
  * empty stdout, which is exactly the "untracked" case, distinct from a real
106
319
  * git failure (which RunGit also reports as null): both collapse to null
107
320
  * here because sources-fresh treats them the same way, "staleness unknown".
321
+ * Uses committer time (`%ct`), not author time (`%at`): a rebase or
322
+ * cherry-pick can carry a stale author date forward while the committer
323
+ * date reflects when the content actually landed on this branch, which is
324
+ * what both freshness rules care about.
108
325
  */
109
326
  function getLastCommitEpoch(git, repoRoot, source) {
110
327
  const out = git(["log", "-1", "--format=%ct", "--", source], repoRoot);
@@ -113,7 +330,567 @@ function getLastCommitEpoch(git, repoRoot, source) {
113
330
  const epoch = Number.parseInt(out, 10);
114
331
  return Number.isNaN(epoch) ? null : epoch;
115
332
  }
333
+ /**
334
+ * The full SET of repo-root-relative paths `git status --porcelain`
335
+ * reports as dirty (modified, staged, or untracked) across the WHOLE work
336
+ * tree, read via a SINGLE `status` process for the entire `check` run --
337
+ * not one process per unique `sources` path (the original, pre-review
338
+ * implementation), and not one per doc for the local-restamp rescue
339
+ * below. Used only behind `--dirty-as-now` (`ctx.dirtyAsNow`): a dirty
340
+ * path has no "last commit" yet that reflects its current content, so
341
+ * `sources-fresh` substitutes the current time for it instead of falling
342
+ * through to `getLastCommitEpoch`'s answer (the LAST commit's time, which
343
+ * for a locally-edited-but-uncommitted file is necessarily stale).
344
+ *
345
+ * `--no-optional-locks` skips git's opportunistic index refresh (which can
346
+ * otherwise rewrite the on-disk index file as a side effect of a plain
347
+ * `status`) -- unwanted here since this runs as a read-only check, often
348
+ * from a hook or CI step that should not perturb the index. Ignored files
349
+ * are deliberately NOT included (no `--ignored`): an ignored source keeps
350
+ * today's "untracked by git, staleness unknown" notice instead of being
351
+ * newly reported dirty by this option.
352
+ *
353
+ * `--untracked-files=all` (`-uall`) is NOT optional decoration. Under
354
+ * git's default untracked mode (`normal`), a brand-new directory is
355
+ * collapsed into ONE record for the directory itself (`? newdir/`) and the
356
+ * files inside it are never listed. The matcher below matches a queried
357
+ * path that IS a reported entry or is an ANCESTOR of one, so under that
358
+ * default a `sources` entry (or a doc) INSIDE a new untracked directory
359
+ * matched nothing at all and silently kept its committed-history verdict:
360
+ * `check --dirty-as-now` reported a brand-new source as merely `untracked
361
+ * by git, staleness unknown` (exit 0) while a plain `check` after
362
+ * committing it reported STALE (exit 1) -- the precise local/CI divergence
363
+ * this flag exists to remove. `-uall` makes git enumerate every untracked
364
+ * FILE as its own record, so the ordinary exact/descendant matching sees
365
+ * them. The cost is output size: `-uall` prints one record per untracked
366
+ * file rather than per untracked directory, which in a work tree carrying
367
+ * a large untracked build or dependency tree (anything not covered by
368
+ * `.gitignore` -- ignored paths are still excluded, see above) can run to
369
+ * many thousands of records. That output travels through RunGit's 16 MiB
370
+ * cap (`MAX_GIT_OUTPUT_BYTES` in src/git.ts), and a status that exceeds it
371
+ * resolves to null like any other git failure -- reported as the
372
+ * bundle-level "not applied" notice the rule emits, never as a silent
373
+ * "nothing is dirty".
374
+ *
375
+ * `git rev-parse --show-prefix` is the SECOND (and last) git process this
376
+ * costs per run. `git status` always reports paths relative to the
377
+ * repository's TOP LEVEL, while a `sources` entry is relative to
378
+ * `ctx.repoRoot` -- the same directory only when `--repo-root` names the
379
+ * top level. Pointed at a SUBDIRECTORY (`--repo-root packages/foo`), every
380
+ * status path carries a `packages/foo/` prefix the queried path does not,
381
+ * so nothing ever matched and the flag silently did nothing. Reading the
382
+ * prefix once and prepending it at match time makes that case work instead
383
+ * of quietly no-opping.
384
+ *
385
+ * Uses `--porcelain=v2 -z`, not the more familiar `--porcelain` (v1) line
386
+ * format, for one reason that is NOT about renames or quoting (`-z` alone
387
+ * would fix both): RunGit (src/git.ts) `.trim()`s every git invocation's
388
+ * output, and a v1 line for an unstaged-only change starts with a LITERAL
389
+ * SPACE (` M path`, index clean, worktree modified) -- an entirely common
390
+ * case. When that happens to be the FIRST line of the whole status output,
391
+ * `.trim()` strips that leading space as ordinary boundary whitespace,
392
+ * silently shifting every fixed-offset field in that one line and
393
+ * corrupting its path (verified against real git output: a lone `M
394
+ * source.ts` line, meant to be ` M source.ts`, parses to the wrong path
395
+ * with a fixed `line.slice(3)` cut). Every v2 record instead starts with a
396
+ * digit or letter (`1`, `2`, `u`, `?`) that is never whitespace, so the
397
+ * SAME `.trim()` can never eat a leading field byte. NUL (`-z`) delimits
398
+ * every record and untracked/rename path field can safely contain a space,
399
+ * tab, or non-ASCII byte, mirroring previousPathIn's `-z` rationale
400
+ * elsewhere in this file. Record shapes actually seen here (verified
401
+ * against real git output; XY, sub, mode and hash fields are read
402
+ * positionally, never re-split by content, since a path can itself
403
+ * legitimately contain a space): `? path` (untracked); `1 XY sub mH mI mW
404
+ * hH hI path` (ordinary; 8 fixed fields before path); `2 XY sub mH mI mW
405
+ * hH hI score path\0origPath` (rename/copy; 9 fixed fields before path,
406
+ * origPath is the FOLLOWING NUL-delimited token verbatim); `u XY sub m1 m2
407
+ * m3 mW h1 h2 h3 path` (unmerged; 10 fixed fields before path) -- both
408
+ * sides of a rename count as dirty. `!` (ignored) records are never
409
+ * produced since `--ignored` is not passed.
410
+ *
411
+ * A failed git call (either one: `null`) makes the WHOLE state null, which
412
+ * every path then falls through on -- it keeps its ordinary commit-epoch
413
+ * verdict rather than this function inventing a dirty one. Unlike every
414
+ * other git failure in this file that is silent, this one is surfaced: the
415
+ * rule turns a null state into a single bundle-level "`--dirty-as-now` not
416
+ * applied" notice, because "the flag silently did nothing" and "nothing
417
+ * was dirty" are indistinguishable to a reader of an empty finding list.
418
+ */
419
+ function readDirtyState(git, repoRoot) {
420
+ // `""` (this IS the top level) is a valid answer, so only an outright
421
+ // failure (`null`) disables the flag here.
422
+ const rawPrefix = git(["rev-parse", "--show-prefix"], repoRoot);
423
+ if (rawPrefix === null)
424
+ return null;
425
+ const prefix = rawPrefix === "" ? "" : `${normalizeRelPath(rawPrefix)}/`;
426
+ const out = git([
427
+ "--no-optional-locks",
428
+ "status",
429
+ "--porcelain=v2",
430
+ "-z",
431
+ "--untracked-files=all",
432
+ ], repoRoot);
433
+ if (out === null)
434
+ return null;
435
+ const paths = new Set();
436
+ // `-z` NUL-terminates every record, including the last -- RunGit's
437
+ // `.trim()` does not strip NUL, so splitting always leaves one empty
438
+ // trailing token.
439
+ const tokens = out.split("\0").filter((t) => t !== "");
440
+ let i = 0;
441
+ while (i < tokens.length) {
442
+ const token = tokens[i];
443
+ const kind = token[0];
444
+ if (kind === "?") {
445
+ paths.add(normalizeRelPath(token.slice(2)));
446
+ i += 1;
447
+ }
448
+ else if (kind === "1") {
449
+ paths.add(normalizeRelPath(token.split(" ").slice(8).join(" ")));
450
+ i += 1;
451
+ }
452
+ else if (kind === "2") {
453
+ paths.add(normalizeRelPath(token.split(" ").slice(9).join(" ")));
454
+ const origPath = tokens[i + 1];
455
+ if (origPath !== undefined)
456
+ paths.add(normalizeRelPath(origPath));
457
+ i += 2;
458
+ }
459
+ else if (kind === "u") {
460
+ paths.add(normalizeRelPath(token.split(" ").slice(10).join(" ")));
461
+ i += 1;
462
+ }
463
+ else {
464
+ // An unrecognized record kind (should not occur without --ignored,
465
+ // which is never passed): skip rather than mis-parse it as a path.
466
+ i += 1;
467
+ }
468
+ }
469
+ return { prefix, paths };
470
+ }
471
+ /**
472
+ * Canonicalizes a repo-relative path spelling before it is used as a dirty
473
+ * lookup key or matched against one: strips a leading `./`, strips a
474
+ * trailing `/`, converts backslashes to forward slashes, and collapses `.`
475
+ * / `..` segments and duplicate slashes via `path.posix.normalize`. `git
476
+ * status` itself always reports canonical forward-slash paths with neither
477
+ * a leading `./` nor a trailing `/`, but a frontmatter-authored `sources`
478
+ * entry (or a doc's own bundle-relative path, joined with `path.join`) is
479
+ * under no such obligation -- `./srcdir` and `srcdir/` name the exact same
480
+ * directory `srcdir` does, and MUST match the same dirty entries it does.
481
+ * Applied on BOTH sides of every dirty-path comparison in this file (here,
482
+ * at insertion into the dirty-paths Set, and in `isDirtyForShared` on the
483
+ * queried path already joined with the repo prefix), so any spelling of
484
+ * the same path always matches.
485
+ *
486
+ * `.`, `./` and the empty string all normalize to `.`, the root directory
487
+ * itself. That is a legal `sources` spelling but never a `git status`
488
+ * entry, so `isDirtyForShared` answers it by containment instead of by
489
+ * lookup -- see the `normalized === "."` branch there.
490
+ *
491
+ * A round-1-then-round-2 regression this closes: the per-run status
492
+ * refactor introduced exact-path (`paths.has`) and prefix (`startsWith`)
493
+ * matching against the RAW `source` string, so `srcdir/` built a
494
+ * self-defeating double-slash prefix (`srcdir//`) and `./srcdir` never
495
+ * matched a `git status` path at all (which never carries a `./` prefix) --
496
+ * both spellings silently lost dirty detection entirely.
497
+ */
498
+ function normalizeRelPath(relPath) {
499
+ let p = relPath.replace(/\\/g, "/");
500
+ while (p.startsWith("./"))
501
+ p = p.slice(2);
502
+ p = path.posix.normalize(p);
503
+ while (p.length > 1 && p.endsWith("/"))
504
+ p = p.slice(0, -1);
505
+ return p;
506
+ }
507
+ /**
508
+ * Per-run cache (keyed by `BundleContext`, same pattern as
509
+ * `docCommitEpochCache` below) of `readDirtyState`'s answer, so the WHOLE
510
+ * `check` run -- both rules, every doc, every source -- spends at most one
511
+ * `git status` (plus one `git rev-parse --show-prefix`) process, not one
512
+ * per rule. A cached `null` (the git read failed) is a real cached answer,
513
+ * hence the `.has` check rather than a truthiness test: a failed read is
514
+ * not retried per source.
515
+ */
516
+ const dirtyStateCache = new WeakMap();
517
+ function getDirtyStateShared(ctx, git, repoRoot) {
518
+ if (dirtyStateCache.has(ctx))
519
+ return dirtyStateCache.get(ctx) ?? null;
520
+ const state = readDirtyState(git, repoRoot);
521
+ dirtyStateCache.set(ctx, state);
522
+ return state;
523
+ }
524
+ /**
525
+ * Whether `relPath` (a `sources` entry OR a doc's own path relative to
526
+ * `ctx.repoRoot`, in any spelling `normalizeRelPath` accepts) is dirty per
527
+ * `git status`. The queried path is first rebased onto the repository top
528
+ * level with `state.prefix` (a no-op in the ordinary case where
529
+ * `--repo-root` IS the top level), since that is the frame every reported
530
+ * dirty path is expressed in. A dirty FILE then matches by exact
531
+ * (normalized) path; a dirty DIRECTORY source matches if any reported
532
+ * dirty path lives under it (a `/`-terminated prefix, so a sibling
533
+ * directory whose name merely starts with the same characters -- `srcdir`
534
+ * vs `srcdirX/x.ts` -- never falsely matches).
535
+ *
536
+ * `.` (or `./`, or any spelling that normalizes to `.`) names the root
537
+ * directory itself, which `git status` never reports as an entry and which
538
+ * no `"./"`-prefix test can match either -- so it is matched explicitly as
539
+ * "any dirty path at all lives under this root", the same containment
540
+ * question the directory case asks, just with an empty prefix. Without
541
+ * that branch a bundle declaring `.` as its source silently lost dirty
542
+ * detection entirely.
543
+ *
544
+ * A failed state read (`null`) is treated as "nothing is dirty": every
545
+ * path falls through to its ordinary (real) commit-epoch path rather than
546
+ * this function inventing a dirty verdict either way. The rule reports
547
+ * that case once, bundle-level, rather than letting the flag no-op in
548
+ * silence (see `readDirtyState`).
549
+ */
550
+ function isDirtyForShared(ctx, git, repoRoot, relPath) {
551
+ const state = getDirtyStateShared(ctx, git, repoRoot);
552
+ if (state === null)
553
+ return false;
554
+ const { paths } = state;
555
+ const normalized = normalizeRelPath(`${state.prefix}${relPath}`);
556
+ if (normalized === ".")
557
+ return paths.size > 0;
558
+ if (paths.has(normalized))
559
+ return true;
560
+ const prefix = `${normalized}/`;
561
+ for (const p of paths) {
562
+ if (p.startsWith(prefix))
563
+ return true;
564
+ }
565
+ return false;
566
+ }
567
+ /**
568
+ * Per-run cache (keyed by `BundleContext`) of the SINGLE "now" instant
569
+ * `--dirty-as-now` substitutes for every dirty path's commit epoch, shared
570
+ * across BOTH rules in this file and every doc/source they assess: every
571
+ * dirty path in one `check` invocation is treated as landing in the exact
572
+ * same virtual commit, so two dirty sources (or a dirty source and a dirty
573
+ * doc) never disagree with each other about what "now" was.
574
+ *
575
+ * THE CACHE IS THE GUARANTEE, not the order the callers happen to run in.
576
+ * The rescue gate above is an INEQUALITY on epochs (`docCommitEpoch >=
577
+ * commitEpoch`) whose two sides are BOTH this value when a dirty doc is
578
+ * paired with a dirty source. Reading the clock afresh on each side
579
+ * instead would leave the gate resting on call order: as the rule is
580
+ * written today the source's epoch is read before the doc's
581
+ * (`commitEpochFor`, then `docCommitEpochFor`), so a second-boundary
582
+ * crossing between them could only ever raise the DOC's side and the gate
583
+ * would survive by accident -- but read in the other order (a reordering
584
+ * nothing in the rule forbids) the doc's side would be the older one, and
585
+ * a run that straddled a whole second would flip to a silent,
586
+ * unreproducible STALE. The cache removes the ordering question entirely:
587
+ * every dirty path in one run reads the identical integer, so the gate
588
+ * holds with equality no matter who asks first. Any future caller wanting
589
+ * "now" here must therefore go through this accessor rather than reading
590
+ * the clock again.
591
+ */
592
+ const nowEpochCache = new WeakMap();
593
+ function getNowEpochShared(ctx) {
594
+ let epoch = nowEpochCache.get(ctx);
595
+ if (epoch === undefined) {
596
+ epoch = Math.floor(Date.now() / 1000);
597
+ nowEpochCache.set(ctx, epoch);
598
+ }
599
+ return epoch;
600
+ }
601
+ /**
602
+ * THE single choke point where `--dirty-as-now`'s virtual-commit model is
603
+ * applied: a dirty `relPath` (source or doc, see `isDirtyForShared`) is
604
+ * judged as committed at the shared "now" instant (`getNowEpochShared`)
605
+ * instead of consulting `actualEpoch` at all; anything else -- the flag
606
+ * off, or `relPath` not dirty -- defers to `actualEpoch` (typically a
607
+ * memoized real `git log` lookup), byte-identical to pre-flag behavior.
608
+ * Every commit-epoch read in this file that is meant to honor
609
+ * `--dirty-as-now` MUST go through this function (directly, or via
610
+ * `getDocCommitEpochShared` below, which is itself built on it) rather
611
+ * than re-implementing the dirty check inline, so a source's epoch and a
612
+ * doc's own epoch are always read the same way, by both rules.
613
+ */
614
+ function commitEpochWithDirtyAsNow(ctx, git, repoRoot, relPath, actualEpoch) {
615
+ if (ctx.dirtyAsNow && isDirtyForShared(ctx, git, repoRoot, relPath)) {
616
+ return getNowEpochShared(ctx);
617
+ }
618
+ return actualEpoch();
619
+ }
620
+ /**
621
+ * `--dirty-as-now` only: the verdict for "did the doc's OWN uncommitted
622
+ * working-tree state re-stamp it" -- the virtual-commit analogue of
623
+ * `restampedByOwnLastCommit`'s co-commit check, used in its place (see
624
+ * `restampFor` in the rule above) whenever the doc itself is dirty. The
625
+ * virtual commit's implicit "first parent" is simply HEAD, so this compares
626
+ * the doc's CURRENT (working-tree) parsed `timestamp` identity -- already
627
+ * read from disk by the caller, since `BundleDoc` always reflects the
628
+ * working tree, dirty or not -- against the value committed at HEAD for the
629
+ * same path. Any change of value, in EITHER direction (including a
630
+ * backwards re-stamp), counts as re-stamped, mirroring the committed
631
+ * rescue's "changed, not corrected" semantics documented on
632
+ * `restampedByOwnLastCommit` below.
633
+ *
634
+ * Distinguishes a genuinely untracked/new doc (no entry for this path at
635
+ * HEAD at all -- `git ls-tree HEAD -- <path>` succeeds with EMPTY output,
636
+ * never a git failure) from a real git failure reading an EXISTING HEAD
637
+ * blob (`git show` itself failing -- a corrupt object, an unreadable blob):
638
+ * the former counts as "restamped" (there is no prior committed value to
639
+ * compare against, so its whole content, stamp included, is new -- the
640
+ * working-tree equivalent of "the doc being created there"); the latter is
641
+ * `unknown`, falling through to the rule's existing not-assessable notice
642
+ * rather than being silently treated the same as "created". `git ls-tree`
643
+ * itself failing (an unborn HEAD, git unavailable) is likewise `unknown`.
644
+ */
645
+ function dirtyDocRestampVerdict(git, repoRoot, repoRelDocPath, onDiskTimestamp) {
646
+ const treeEntry = git(["ls-tree", "-z", "HEAD", "--", repoRelDocPath], repoRoot);
647
+ if (treeEntry === null)
648
+ return "unknown";
649
+ if (treeEntry === "")
650
+ return "restamped";
651
+ const committed = git(["show", `HEAD:${repoRelDocPath}`], repoRoot);
652
+ if (committed === null)
653
+ return "unknown";
654
+ const committedStamp = getTimestampIdentity(parseFrontmatter(committed).frontmatter.parsed);
655
+ return committedStamp !== onDiskTimestamp ? "restamped" : "not-restamped";
656
+ }
116
657
  function epochToIso(epochSeconds) {
117
658
  return new Date(epochSeconds * 1000).toISOString();
118
659
  }
660
+ /**
661
+ * Per-run cache of a doc's own REAL (committed) last-commit epoch, keyed by
662
+ * the `BundleContext` instance so `sources-fresh`'s doc-commit comparison
663
+ * and `sources-fresh-future`'s timestamp comparison -- both need the
664
+ * IDENTICAL (repoRoot, doc path) `getLastCommitEpoch` lookup for every doc
665
+ * in one `check` invocation -- share one `git log` process per doc instead
666
+ * of each rule spawning its own. Safe to key on the context object itself:
667
+ * a fresh `BundleContext` is built per `runCheck`/`loadBundle` call, so
668
+ * nothing reuses a stale cache entry across invocations, and the WeakMap
669
+ * lets the cache be garbage-collected with the context once a run is done.
670
+ *
671
+ * `getDocCommitEpochShared` below is the single place BOTH rules read a
672
+ * doc's commit epoch from, and it is itself built on
673
+ * `commitEpochWithDirtyAsNow`: under `--dirty-as-now`, a dirty doc's epoch
674
+ * is the shared virtual "now" instant instead of this real cache's answer,
675
+ * so `sources-fresh`'s co-commit rescue and `sources-fresh-future`'s
676
+ * timestamp comparison always see the identical (real-or-virtual) epoch
677
+ * for the same doc in the same `check` run.
678
+ */
679
+ const docCommitEpochCache = new WeakMap();
680
+ function getDocCommitEpochShared(ctx, git, repoRoot, repoRelDocPath) {
681
+ return commitEpochWithDirtyAsNow(ctx, git, repoRoot, repoRelDocPath, () => {
682
+ let cache = docCommitEpochCache.get(ctx);
683
+ if (!cache) {
684
+ cache = new Map();
685
+ docCommitEpochCache.set(ctx, cache);
686
+ }
687
+ const cached = cache.get(repoRelDocPath);
688
+ if (cached !== undefined)
689
+ return cached;
690
+ const epoch = getLastCommitEpoch(git, repoRoot, repoRelDocPath);
691
+ cache.set(repoRelDocPath, epoch);
692
+ return epoch;
693
+ });
694
+ }
695
+ /**
696
+ * Per-run cache (keyed by `BundleContext`, same pattern as
697
+ * `docCommitEpochCache` above) of whether `repoRoot` is a shallow clone
698
+ * (`git clone --depth <n>`), so `restampedByOwnLastCommit`'s root-commit
699
+ * shortcut spends at most ONE `git rev-parse --is-shallow-repository` for
700
+ * the whole `check` run, not one per doc. A failed git call (repoRoot
701
+ * somehow not a real git work tree after all) is treated as shallow: this
702
+ * function's only consumer only ever asks it to decide whether a commit
703
+ * with an empty parent list is trustworthy as a genuine root commit, and
704
+ * the file's standing rule is to answer "unknown" rather than invent
705
+ * either verdict when git itself cannot be asked -- so a failure here must
706
+ * NOT fall back to the previous "always trust it" behavior.
707
+ */
708
+ const shallowRepoCache = new WeakMap();
709
+ function isShallowRepoShared(ctx, git, repoRoot) {
710
+ const cached = shallowRepoCache.get(ctx);
711
+ if (cached !== undefined)
712
+ return cached;
713
+ const out = git(["rev-parse", "--is-shallow-repository"], repoRoot);
714
+ const isShallow = out === null ? true : out.trim() === "true";
715
+ shallowRepoCache.set(ctx, isShallow);
716
+ return isShallow;
717
+ }
718
+ /**
719
+ * Whether `doc`'s own last commit actually re-stamped it, decided by
720
+ * comparing the doc's PARSED FRONTMATTER `timestamp` VALUE at that commit
721
+ * against its value in the commit's FIRST PARENT. A doc created by that
722
+ * commit (or by a genuine root commit of an unshallow repository) counts as
723
+ * re-stamped: its stamp arrived with it. In a SHALLOW clone (`git clone
724
+ * --depth`), the doc's last commit can have an EMPTY parent list purely
725
+ * because that is where history was grafted off, not because it is really
726
+ * the repo's first commit -- `isShallowRepo` (checked lazily, only when a
727
+ * commit with no parents is actually seen) distinguishes the two, so a
728
+ * shallow checkout gets `unknown-shallow-root` there instead of an assumed
729
+ * `restamped`.
730
+ *
731
+ * This is what narrows `sources-fresh`'s co-commit staleness exception: a
732
+ * commit that merely happens to also touch the doc file (a typo fix, a
733
+ * repo-wide formatter run, a rename) without changing the stamp carries no
734
+ * verification claim and must NOT suppress staleness; only a commit that
735
+ * actually rewrote the stamp (or created the doc) does.
736
+ *
737
+ * WHY VALUES AND NOT DIFF TEXT. An earlier version of this check scanned
738
+ * `git log -1 -p -- <doc>` for a `^\+timestamp:` line. Scanning diff TEXT is
739
+ * wrong in three distinct, independently reachable ways, each of which this
740
+ * value comparison closes structurally rather than by another special case:
741
+ *
742
+ * 1. A fenced YAML EXAMPLE in the doc's BODY can contain an unindented
743
+ * `timestamp:` line. Added in a commit that also changed a source, that
744
+ * body line reads as a re-stamp and silently suppresses staleness --
745
+ * exactly the review class this rule exists to close. Parsing
746
+ * frontmatter cannot see a body line at all.
747
+ * 2. A RENAME (`git mv`) shows as `new file mode` under a single-path
748
+ * `git log -p`, firing the "created counts as stamped" branch even
749
+ * though the stamp never moved. The rename-aware lookup below reads the
750
+ * doc's real previous path instead.
751
+ * 3. A MERGE commit prints NO patch at all under `git log -p` (git's
752
+ * default combined-diff suppression), so a genuine re-stamp landing on
753
+ * a `refs/pull/N/merge` ref -- the ref CI actually checks out -- became
754
+ * an invisible false-positive STALE. Both trees are perfectly readable
755
+ * via `git show`, merge or not.
756
+ *
757
+ * Its limits, stated rather than hidden: this answers "did the value
758
+ * change", never "is the new value right". A hand-typed or backdated stamp
759
+ * still counts as a re-stamp (`sources-fresh-future` is the rule that
760
+ * catches an implausible value), and comparing against only the FIRST parent
761
+ * means a merge that takes its doc content wholesale from the second parent
762
+ * is judged against the first-parent baseline, which is the same baseline
763
+ * the PR under review is measured against.
764
+ *
765
+ * Spends at most 4 git processes and returns early before most of them: 1
766
+ * for the commit + parents, 1 for the rename-aware name-status lookup
767
+ * (skipped for a root commit), and 2 blob reads (skipped when the doc was
768
+ * created there). See the GIT PROCESS BUDGET comment in the rule above.
769
+ */
770
+ function restampedByOwnLastCommit(git, repoRoot, repoRelDocPath, isShallowRepo) {
771
+ // %H then %P on its own line: the doc's last commit and its parent list in
772
+ // ONE process. Default history simplification is exactly what this needs
773
+ // for a single path: a merge whose result for that path differs from every
774
+ // parent (a conflict resolution, or a clean auto-merge of two sides that
775
+ // both touched the doc) IS returned here, while a merge that is TREESAME
776
+ // to a parent resolves to the real content-changing commit on that side.
777
+ const head = git(["log", "-1", "--format=%H%n%P", "--", repoRelDocPath], repoRoot);
778
+ if (head === null)
779
+ return "unknown";
780
+ const [sha, parentLine] = head.split("\n");
781
+ if (!sha)
782
+ return "unknown";
783
+ const parents = (parentLine ?? "").split(" ").filter((p) => p !== "");
784
+ // An empty parent list means "the doc arrived with the repo's first
785
+ // commit, stamp and all" ONLY in a genuinely unshallow repository. In a
786
+ // shallow clone (`git clone --depth`), the boundary commit git grafted
787
+ // the history onto also reports an empty parent list for every path
788
+ // touched at or before it -- indistinguishable from a real root commit
789
+ // by this lookup alone -- so trusting it there would let the shallow-clone
790
+ // shortcut fire unconditionally and silently suppress every doc's
791
+ // staleness. isShallowRepo() is checked here, not unconditionally at the
792
+ // top of this function, so an unshallow repo never pays for it.
793
+ if (parents.length === 0) {
794
+ return isShallowRepo() ? "unknown-shallow-root" : "restamped";
795
+ }
796
+ const firstParent = parents[0];
797
+ const previous = previousPathIn(git, repoRoot, firstParent, sha, repoRelDocPath);
798
+ if (previous.kind === "unknown")
799
+ return "unknown";
800
+ if (previous.kind === "created")
801
+ return "restamped";
802
+ const current = git(["show", `${sha}:${repoRelDocPath}`], repoRoot);
803
+ if (current === null)
804
+ return "unknown";
805
+ const before = git(["show", `${firstParent}:${previous.path}`], repoRoot);
806
+ if (before === null)
807
+ return "unknown";
808
+ const currentStamp = getTimestampIdentity(parseFrontmatter(current).frontmatter.parsed);
809
+ const beforeStamp = getTimestampIdentity(parseFrontmatter(before).frontmatter.parsed);
810
+ // Both undefined (no parseable stamp on either side) compares equal, i.e.
811
+ // "not re-stamped" -- nothing was rewritten, so nothing is claimed.
812
+ return currentStamp !== beforeStamp ? "restamped" : "not-restamped";
813
+ }
814
+ /**
815
+ * Where the doc lived in `parentSha`, so its previous revision can be read
816
+ * by that path: the same path (`same`), a different one it was renamed from
817
+ * (`renamed`), or nowhere at all because that commit created it (`created`).
818
+ *
819
+ * Runs `git diff-tree` WITHOUT a pathspec on purpose. A pathspec is applied
820
+ * BEFORE rename detection, so `git diff-tree -M --name-status <parent> <sha>
821
+ * -- <doc>` reports a renamed doc as `A` (verified against a real `git mv`
822
+ * fixture) -- which is precisely the false "created" verdict that made a
823
+ * rename suppress staleness. The unfiltered output is still bounded and
824
+ * small: `--name-status -r` prints one line per changed path, not any file
825
+ * content, and the raised RunGit output cap (see src/git.ts) covers even a
826
+ * repo-wide formatting commit.
827
+ *
828
+ * A doc that does not appear in the diff at all resolves to `same`: the two
829
+ * revisions are then byte-identical by construction, and the value
830
+ * comparison above resolves that to "not re-stamped" without a second code
831
+ * path deciding it.
832
+ *
833
+ * Runs with `-z` (NUL-delimited output) rather than the default newline/tab
834
+ * form, and for one reason that is NOT about newlines: git's default
835
+ * `--name-status` output C-QUOTES any path containing a non-ASCII byte
836
+ * (`core.quotePath` defaults to true) as a double-quoted string with octal
837
+ * escapes (e.g. `"bundle/\303\266lt.md"` for `bundle/ölt.md`), so a plain
838
+ * `repoRelDocPath` never string-equals that quoted form and a non-ASCII doc
839
+ * falls through every branch below to the `same` fallback, which returns
840
+ * the doc's real (unquoted) CURRENT path unchanged. When that commit was
841
+ * actually a rename or creation, the doc was NOT at that path in the first
842
+ * parent -- it lived under a different name there, or did not exist yet --
843
+ * so the caller's `git show <firstParent>:<that path>` blob read fails,
844
+ * turning a normal rename or creation into a false `not assessable`
845
+ * notice. `-z` prints every path verbatim,
846
+ * unquoted, regardless of `core.quotePath`, closing that structurally
847
+ * rather than by passing `-c core.quotePath=false` (which is a config
848
+ * override this rule would otherwise have to remember on every git
849
+ * invocation touching a path, not just this one).
850
+ */
851
+ function previousPathIn(git, repoRoot, parentSha, sha, repoRelDocPath) {
852
+ const nameStatus = git([
853
+ "diff-tree",
854
+ "-r",
855
+ "-M",
856
+ "-z",
857
+ "--name-status",
858
+ "--no-commit-id",
859
+ parentSha,
860
+ sha,
861
+ ], repoRoot);
862
+ if (nameStatus === null)
863
+ return { kind: "unknown" };
864
+ // `-z` NUL-terminates every field (status, then path(s)) instead of the
865
+ // default "status TAB path NEWLINE" (or, for a rename/copy row, "status
866
+ // TAB old-path TAB new-path NEWLINE") -- including a trailing NUL after
867
+ // the very last field, which RunGit's `.trim()` does not strip (NUL is
868
+ // not whitespace), so the split below always drops one empty trailing
869
+ // token. A rename/copy row is 3 NUL-terminated tokens (status, old path,
870
+ // new path); every other row is 2 (status, path) -- read positionally,
871
+ // not by re-joining on a separator, since a path can itself legitimately
872
+ // contain a tab or newline once quoting is off.
873
+ const tokens = nameStatus.split("\0").filter((t) => t !== "");
874
+ let i = 0;
875
+ while (i < tokens.length) {
876
+ const status = tokens[i];
877
+ if (status.startsWith("R") || status.startsWith("C")) {
878
+ const oldPath = tokens[i + 1];
879
+ const newPath = tokens[i + 2];
880
+ i += 3;
881
+ if (newPath === repoRelDocPath) {
882
+ return { kind: "renamed", path: oldPath };
883
+ }
884
+ continue;
885
+ }
886
+ const p = tokens[i + 1];
887
+ i += 2;
888
+ if (p !== repoRelDocPath)
889
+ continue;
890
+ if (status.startsWith("A"))
891
+ return { kind: "created" };
892
+ return { kind: "same", path: repoRelDocPath };
893
+ }
894
+ return { kind: "same", path: repoRelDocPath };
895
+ }
119
896
  //# sourceMappingURL=sources-fresh.js.map