Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 147 additions & 6 deletions scripts/pm/git-history.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,33 @@
* what git is handed and what the receipt prints, and a complete instant is
* parsed exactly and never consults the clock.
*
* ## The fifth trap, no git at all: a piped answer is cut at one buffer-full
*
* The answer leaves in ONE `process.stdout.write`. Node completes that write
* at once when stdout is a FILE, but when stdout is a PIPE (on POSIX) it puts
* in only what the pipe takes in one go and queues the rest for the event
* loop. The entry used to end with `process.exit(main(...))`, which tears the
* process down before that queue drains — so `log … | wc -l`, the natural
* spelling of a count taken through `log`, read one buffer-full at exit 0
* (#21659). Measured at 7d0781482d on this repository's shallow clone, the
* same `log --since=2026-08-14T00:00:00Z --ref=3711e0b763` two ways:
*
* | stdout | lines | exit |
* |--------------------------------|-------|------|
* | redirected to a file | 5455 | 0 |
* | piped to `wc -l`, three runs | 346 | 0 |
*
* 346 lines is about one 64 KiB pipe buffer. `count` and `touch` print one
* line, which always fits, so they never showed it. The entry therefore sets
* `process.exitCode` and returns: the process ends when its last queued write
* has drained, on every stdout kind. `main()` and `touchMain()` RETURN their
* exit codes; the one `process.exit` left is `usage()`'s, and every call to it
* comes before anything is written to stdout. A reader that closes early
* (`| head -1`) turns the queued rest into EPIPE; that is the reader's choice,
* not a lost answer, so it is absorbed and the answer's own exit code stands.
* The self-test pins a piped `log`, many buffers long, byte for byte against
* the same `log` sent to a file.
*
* ## Cost, measured — because a tool nobody runs fixes nothing
*
* | case | wall |
Expand All @@ -162,7 +189,8 @@
*/

import { execFileSync, spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { isEntrypoint } from '../invoked-as.mjs';
Expand All @@ -185,15 +213,16 @@ import { isEntrypoint } from '../invoked-as.mjs';
// remedy is to find what stopped registering.
const SELF_TEST_BATTERIES = Object.freeze({
'pure decisions': 10,
'real repos': 15,
'real repos': 17,
'historyHorizon: the read-only reading the #9902 adopters call': 12,
'bare dates: the instant the proof reads is the instant git counts': 16,
'touch: the provenance reading a shallow clone fabricates': 26,
'piped stdout: the answer a pipe reads is the answer a file holds': 4,
});

// DELETING an entry silences that battery's floor exactly as effectively as
// zeroing it, so the roster's own size is pinned too.
const SELF_TEST_BATTERY_FLOOR = 5;
const SELF_TEST_BATTERY_FLOOR = 6;

// The key an assertion is filed under when no battery is open. It is not a
// declared battery, so it reds by the same set difference rather than silently
Expand Down Expand Up @@ -303,9 +332,14 @@ export function ensureWindowCovered({ cwd, ref, sinceMs, allowFetch = true, allo
const remotes = (git(['remote'], { cwd, allowFail: true }) || '').split('\n').filter(Boolean);
const target = splitRemoteRef(ref, remotes);
if (!target) {
// The boundaries were READ above; a ref that names no remote (a bare sha, a
// local branch) cannot be deepened, but its floor is still known, so the
// refusal prints it rather than `shallow floor: unknown`.
return {
covered: false,
steps,
shallow: shallow0,
boundaries: boundaries0,
reason:
`history is truncated inside the window and '${ref}' names no remote to deepen from ` +
`(remotes here: ${remotes.length ? remotes.join(', ') : 'none'})`,
Expand Down Expand Up @@ -751,7 +785,7 @@ function main(argv) {
+ 'so no success line was printed. Exiting 0 here would report a self-test\n'
+ 'that never finished as a self-test that passed.\n',
);
process.exit(1);
return 1;
}
return selfTestCode;
}
Expand Down Expand Up @@ -785,7 +819,7 @@ function main(argv) {
` commits are invisible to git log, which reports no error (#9878).\n` +
` Remedy: git -C ${cwd} fetch --unshallow ${splitRemoteRef(opts.ref, ['origin']) ? 'origin' : '<remote>'}\n`,
);
process.exit(2);
return 2;
}

const receipt =
Expand Down Expand Up @@ -1055,6 +1089,21 @@ function selfTest() {
t('the clone is still shallow after that answer — proving the predicate is the floor, '
+ 'not the shallow flag', isShallow(shallowDeep) === true);

// A ref that names no remote — a bare sha, the way a tree is usually quoted —
// cannot be deepened, so a window crossing its floor is refused. The floor
// printed beside that refusal is the one already READ, never `unknown`.
// Nothing is fetched on this path, so `shallowDeep` keeps its depth.
const shallowDeepTip = g(['rev-parse', 'origin/main'], shallowDeep).trim();
const bareShaRefusal = runCliAllowFail(['count', `--since=${WINDOW_SINCE}`, `--ref=${shallowDeepTip}`], shallowDeep);
t('a bare-sha --ref whose window crosses the shallow floor is REFUSED, exit 2 with EMPTY stdout, '
+ 'because it names no remote to deepen from',
bareShaRefusal.code === 2 && bareShaRefusal.stdout.trim() === ''
&& /names no remote to deepen from/.test(bareShaRefusal.stderr), JSON.stringify(bareShaRefusal));
t('and the refusal prints the floor it READ, 2026-07-06, not `shallow floor: unknown` — the no-remote '
+ 'branch used to return without the boundaries it had already read',
/shallow floor: 2026-07-06 /.test(bareShaRefusal.stderr) && !/shallow floor: unknown/.test(bareShaRefusal.stderr),
bareShaRefusal.stderr);

// ── historyHorizon: the read-only reading the #9902 adopters call ───────
battery('historyHorizon: the read-only reading the #9902 adopters call');
const hFull = historyHorizon({ cwd: full, ref: 'origin/main', sinceMs: Date.parse(WINDOW_SINCE) });
Expand Down Expand Up @@ -1373,6 +1422,90 @@ function selfTest() {
never.code === 1 && never.stdout.trim() === '' && /no commit on/.test(never.stderr), JSON.stringify(never));
const noPath = runCliAllowFail(['touch'], full);
t('touch without --path is usage', noPath.code === 1 && noPath.stdout.trim() === '', JSON.stringify(noPath));

// ── piped stdout: the answer a pipe reads is the answer a file holds ────
// The fifth trap in the header. A file takes the whole answer in one write;
// a pipe takes what fits and the rest waits on the event loop, which the
// entry's old `process.exit()` ended. Every answer above fits in one
// buffer-full, so none of them could see it. This one is padded to many:
// `%<(16384)` pads a field to 16384 columns (git ignores wider), four to a
// line, 21 lines. A child's pipe here is a socketpair, so a buffer-full is
// the socket's send buffer (212992 bytes by default on Linux) rather than a
// FIFO's 64 KiB; the answer is sized well past both. `full` is read-only
// here, so this battery's place after the deepening ones changes nothing.
battery('piped stdout: the answer a pipe reads is the answer a file holds');
const wideArgs = [
'log', `--since=${WINDOW_SINCE}`, `--until=${WINDOW_UNTIL}`, `--format=${'%<(16384)%H'.repeat(4)}`, `--cwd=${full}`,
];
const newlines = (buf) => buf.reduce((n, b) => n + (b === 10 ? 1 : 0), 0);

const wideFile = join(root, 'wide-answer.txt');
const wideFd = openSync(wideFile, 'w');
let toFile;
try {
toFile = spawnSync(process.execPath, [self, ...wideArgs], { cwd: full, encoding: 'utf8', stdio: ['ignore', wideFd, 'pipe'] });
} finally {
closeSync(wideFd);
}
const fileBytes = readFileSync(wideFile);
const fileLines = newlines(fileBytes);
t('FIXTURE — the wide `log` sent to a FILE holds the 21 answer lines, exit 0, and is over 1 MiB: many times '
+ 'what a pipe takes in one go. Read first, because an answer that fits one buffer-full passes the piped '
+ 'pins below with or without the fix',
toFile.status === 0 && fileLines === 21 && fileBytes.length > 1024 * 1024,
`exit ${toFile.status} lines ${fileLines} bytes ${fileBytes.length} stderr ${JSON.stringify(toFile.stderr)}`);

// The reader is its own process, so the tool's stdout is a real pipe whose
// far end nothing else drains. It attaches its listener at once (Node
// discards a child's unread output when the child exits, which would look
// like a truncation of the reader's own making), holds the stream paused
// for 200 ms so the tool fills the pipe and has to wait, then reads to EOF —
// or, in `close-early` mode, takes one chunk and closes, the shape of `| head -1`.
const PIPE_READER = [
"const { spawn } = require('node:child_process');",
"const { createHash } = require('node:crypto');",
'const [mode, ...cmd] = process.argv.slice(1);',
"const child = spawn(process.execPath, cmd, { stdio: ['ignore', 'pipe', 'pipe'] });",
"const hash = createHash('sha256');",
"let bytes = 0; let lines = 0; let stderr = '';",
"child.stderr.on('data', (d) => { stderr += d; });",
'const take = (d) => { bytes += d.length; lines += d.reduce((n, b) => n + (b === 10 ? 1 : 0), 0); hash.update(d); };',
"if (mode === 'close-early') child.stdout.once('data', (d) => { take(d); child.stdout.destroy(); });",
"else child.stdout.on('data', take);",
'child.stdout.pause();',
'setTimeout(() => child.stdout.resume(), 200);',
"child.on('close', (code, signal) => process.stdout.write(JSON.stringify({ code, signal, bytes, lines, sha256: hash.digest('hex'), stderr })));",
].join('\n');
const readThroughPipe = (mode) => {
const r = spawnSync(process.execPath, ['-e', PIPE_READER, mode, self, ...wideArgs], {
cwd: full,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 60_000,
});
try {
return JSON.parse(r.stdout);
} catch {
return { code: null, readerExit: r.status, readerSignal: r.signal, readerError: String(r.error ?? ''),
readerStdout: String(r.stdout).slice(0, 300), stderr: String(r.stderr).slice(0, 300) };
}
};

const piped = readThroughPipe('slow');
t('the same `log` through a pipe whose reader holds off reads the SAME 21 lines, exit 0 — where the entry\'s '
+ 'old process.exit() delivered one buffer-full and still exited 0',
piped.code === 0 && piped.lines === fileLines, JSON.stringify(piped));
t('and the piped bytes ARE the file\'s bytes: the same length and the same sha256, not just the same line count',
piped.bytes === fileBytes.length && piped.sha256 === createHash('sha256').update(fileBytes).digest('hex'),
`piped ${piped.bytes} bytes ${piped.sha256} · file ${fileBytes.length} bytes`);

const early = readThroughPipe('close-early');
t('a reader that closes after its first chunk (the `| head -1` shape, proved by reading less than the file '
+ 'holds) costs the tool no `write EPIPE` trace: it exits with the answer\'s code, 0, and its stderr is the '
+ 'one-line method receipt',
early.code === 0 && early.bytes > 0 && early.bytes < fileBytes.length
&& /^method: git log [^\n]*\n$/.test(early.stderr ?? '') && !/EPIPE/.test(early.stderr ?? ''),
JSON.stringify(early));
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down Expand Up @@ -1429,5 +1562,13 @@ function selfTest() {

const invokedDirectly = isEntrypoint(import.meta.url);
if (invokedDirectly) {
process.exit(main(process.argv.slice(2)) || 0);
// The fifth trap in the header: `process.exit()` here ended the process with
// most of a piped `log` answer still queued. Setting the exit code and
// returning lets the queued writes drain first, on every stdout kind.
process.stdout.on('error', (err) => {
// A reader that stopped reading (`| head -1`) took what it wanted; the rest
// of the answer has nowhere to go. Anything else is a real failure.
if (err?.code !== 'EPIPE') throw err;
});
process.exitCode = main(process.argv.slice(2)) || 0;
}
Loading