mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(bin): gstack-decision-trail and gstack-worktree-audit, ported from pstack (GSTA-123)
Two MIT ports from Lauren Tan's pstack (licenses/MIT-pstack.txt, NOTICE.md):
a TSV decision trail with the header-once append, the formula guard, a start
row per run and a --run admission check; and a read-only worktree audit with
size, merge state, dirty, remote, PR, last chat and a suggested bucket.
Docs: docs/unattended.md (the decision trail and its end-of-run audit),
autoplan/DRIVER.md, docs/CONTRIBUTOR_REFERENCE.md, docs/standalone-binaries.md,
docs/troubleshooting.md, one line in landing-report.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(egress): reasoned scanner exemption for gstack-worktree-audit's base fetch
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review of the pstack ports
bin/gstack-worktree-audit: a worktree path with a space is read whole (sed
on the porcelain 'worktree ' line, not awk's second field); a registered
worktree whose directory is gone prints a 'prunable' row instead of being
skipped; 'safe' now requires a clean tree and either merged into the base
or a closed/merged PR with every commit pushed, so unpushed commits or
untracked files on a merged branch read 'review' (the port's rule called
them safe); fork PRs are dropped and an open PR wins over an older closed
one on a reused branch name; GIT_TERMINAL_PROMPT=0 on the base fetch.
bin/gstack-decision-trail: only a leading -h/--help is help, so a '-h'
cell is appended as data; a trail without a final newline, with CRLF rows
or with a repeated header line is appended to correctly; a run id is
limited to [A-Za-z0-9._:@/-] (exit 2) so a tab or quote cannot break the
start-row idempotence check.
Tests cover each case; the 'without gh' test resolves git from
'git --exec-path' instead of a PATH rebuilt from a tool list. Docs,
CHANGELOG and --help text describe the stricter buckets.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes, pass 2
bin/gstack-worktree-audit: a worktree whose own .git link is gone (git
marks it prunable, or its toplevel is another repository) prints a 'broken'
row in 'review' instead of describing the containing repository as clean
and safe; a locked entry whose directory is gone reads 'review', not
'prunable', since prune would not touch it; a clean tree names its
gitignored entries as 'clean,ignored:<n>' without changing the bucket; the
branch name is read from refs/heads/ by hand so a tag of the same name
cannot hide the PR; porcelain blocks are read with IFS=tab so paths with
leading or trailing spaces survive; a failed git status reads 'unknown',
never 'clean'; the base fetch also sets GIT_ASKPASS=true and ssh BatchMode.
bin/gstack-decision-trail: a file whose first line is not the header (a
blank file, rows written by hand) gets the header appended, and a UTF-8
BOM before the header no longer turns it into a data row.
Tests: the fake gh records its argv and runs the audit's own --jq over
canned JSON with a fork PR when jq is installed; row counts are asserted
before the every() checks; each test starts from the same gh and closed
worktree; the snapshot includes the worktree registry; new fixtures for
locked, broken, tag-shadowed and ignored-only worktrees, a blank trail and
a BOM. Docs and CHANGELOG describe the new DIRTY values and buckets.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes, pass 3
bin/gstack-decision-trail: hasHeader looks for a header line anywhere in
the file, so a blank or hand-written trail gets exactly one header rather
than one per append. bin/gstack-worktree-audit: the broken-worktree check
uses rev-parse --show-cdup and the presence of /.git instead of
comparing two spellings of the same path, which differ on Git for Windows.
Test: the blank-trail case appends twice and asserts a single header.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: adversarial review, pass 5 (rule-23 grant, one finding)
bin/gstack-worktree-audit: git status runs with --untracked-files=normal
--ignore-submodules=none, so a user's status.showUntrackedFiles=no config
cannot hide untracked files and let a worktree holding them read clean and
safe. Test: the fresh worktree under that config via GIT_CONFIG_* still
reads scratch:1 / review (red before the fix).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: documentation audit corrections for the pstack ports
docs/standalone-binaries.md, docs/CONTRIBUTOR_REFERENCE.md,
docs/troubleshooting.md: the audit's safe condition is stated in full
(clean and merged, or PR closed/merged with every commit pushed), the
DIRTY list names missing, broken and unknown, start is a reserved phase
name, and 'read-only' names the base fetch as the one write --no-fetch
skips. CHANGELOG.md says the same (the audit's one decision item).
Audit id gsta123-docs-20261026T221657Z, status updated, no blockers.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Stamp 1.91.268.0 for #3494 (train slot 1, base d9b0d600)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
161 lines
8.2 KiB
TypeScript
161 lines
8.2 KiB
TypeScript
/**
|
|
* Subprocess tests for bin/gstack-decision-trail: the TSV decision trail
|
|
* (header once, append-only, one-line cells, spreadsheet formula guard), the
|
|
* start-row rule and its idempotent rerun, and the --run admission check.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import { spawnSync } from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
|
|
const ROOT = path.resolve(import.meta.dir, "..");
|
|
const BIN = path.join(ROOT, "bin", "gstack-decision-trail");
|
|
const TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
|
|
let tmp: string;
|
|
let file: string;
|
|
beforeEach(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-decision-trail-"));
|
|
file = path.join(tmp, "run", "decisions.tsv");
|
|
});
|
|
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
function run(...args: string[]) {
|
|
const r = spawnSync(process.execPath, [BIN, ...args], { cwd: ROOT, encoding: "utf8", timeout: 20_000 });
|
|
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
}
|
|
const rows = () => fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map(l => l.split("\t"));
|
|
|
|
describe("gstack-decision-trail append", () => {
|
|
test("first append creates the parent directory, the header and one stamped row", () => {
|
|
const r = run(file, "frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out");
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
|
|
const [header, row] = rows();
|
|
expect(header).toEqual(["ts", "phase", "decision", "why", "evidence", "result"]);
|
|
expect(row!.slice(1)).toEqual(["frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out"]);
|
|
expect(row![0]).toMatch(TS_RE);
|
|
});
|
|
|
|
test("later appends add rows and never a second header; an empty existing file gets the header", () => {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, "");
|
|
run(file, "a", "b", "c", "d", "e");
|
|
run(file, "f", "g", "h", "i", "j");
|
|
const all = rows();
|
|
expect(all).toHaveLength(3);
|
|
expect(all.filter(r => r[0] === "ts")).toHaveLength(1);
|
|
expect(all[2]!.slice(1)).toEqual(["f", "g", "h", "i", "j"]);
|
|
});
|
|
|
|
test("cells lose tabs and line breaks, and a leading =, +, - or @ gets a quote so a spreadsheet cannot run it", () => {
|
|
run(file, "probe", "=HYPERLINK(\"http://evil\")", "two\nlines\there\r", "-", "@SUM(A1)");
|
|
const row = rows()[1]!;
|
|
expect(row).toHaveLength(6);
|
|
expect(row.slice(1)).toEqual(["probe", "'=HYPERLINK(\"http://evil\")", "two lines here ", "'-", "'@SUM(A1)"]);
|
|
run(file, "+1", "plain", "plain", "plain", "plain");
|
|
expect(rows()[2]![1]).toBe("'+1");
|
|
});
|
|
|
|
test("a cell equal to -h or --help is data, a trail without a final newline or with CRLF rows is repaired on append, and a repeated header is never a row", () => {
|
|
const r = run(file, "phase", "--help", "why", "evidence", "-h");
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
|
|
expect(rows()[1]!.slice(1)).toEqual(["phase", "'--help", "why", "evidence", "'-h"]);
|
|
fs.appendFileSync(file, "x\ty\tz");
|
|
run(file, "p2", "d2", "w", "e", "r");
|
|
const all = rows();
|
|
expect(all[2]).toEqual(["x", "y", "z"]);
|
|
expect(all[3]!.slice(1)).toEqual(["p2", "d2", "w", "e", "r"]);
|
|
const crlf = path.join(tmp, "crlf.tsv");
|
|
fs.writeFileSync(crlf, "ts\tphase\tdecision\twhy\tevidence\tresult\r\n2026-01-01T00:00:00Z\ta\tb\tc\td\te\r\nts\tphase\tdecision\twhy\tevidence\tresult\r\n");
|
|
const start = run("start", crlf, "run-1");
|
|
expect(start.stdout).toBe(`appended start row 2 for run run-1 to ${crlf}\n`);
|
|
expect(fs.readFileSync(crlf, "utf8").split("\n").filter(Boolean).pop()!.split("\t")[3]).toBe("1 earlier row (2026-01-01T00:00:00Z..2026-01-01T00:00:00Z) written by other runs");
|
|
});
|
|
|
|
test("a blank file gets a header, and a BOM before the header does not make it a row", () => {
|
|
const blank = path.join(tmp, "blank.tsv");
|
|
fs.writeFileSync(blank, "\n");
|
|
expect(run(blank, "p", "d", "w", "e", "r").status).toBe(0);
|
|
expect(run(blank, "p2", "d", "w", "e", "r").status).toBe(0);
|
|
const lines = fs.readFileSync(blank, "utf8").split("\n");
|
|
expect(lines[1]).toBe("ts\tphase\tdecision\twhy\tevidence\tresult");
|
|
expect(lines[2]!.split("\t").slice(1)).toEqual(["p", "d", "w", "e", "r"]);
|
|
expect(lines.filter(l => l === "ts\tphase\tdecision\twhy\tevidence\tresult")).toHaveLength(1);
|
|
const bom = path.join(tmp, "bom.tsv");
|
|
fs.writeFileSync(bom, "\uFEFFts\tphase\tdecision\twhy\tevidence\tresult\n");
|
|
expect(run("--run", "r1", bom, "p", "d", "w", "e", "r").status).toBe(0);
|
|
expect(fs.readFileSync(bom, "utf8").split("\n").filter(Boolean)).toHaveLength(2);
|
|
});
|
|
|
|
test("a run id with whitespace or quotes is a usage error on start and on --run", () => {
|
|
expect(run("start", file, "a\tb").status).toBe(2);
|
|
expect(run("start", file, "a b").status).toBe(2);
|
|
expect(run("--run", "x'y", file, "a", "b", "c", "d", "e").status).toBe(2);
|
|
expect(run("start", file, "jam_01ABC.turn-2:x/y@z").status).toBe(0);
|
|
expect(fs.readFileSync(file, "utf8").split("\n")).toHaveLength(3);
|
|
});
|
|
|
|
test("wrong arity, an unknown option and a bare call are usage errors; --help exits 0", () => {
|
|
expect(run(file, "only", "four", "cells", "here").status).toBe(2);
|
|
expect(run("--bogus", file, "a", "b", "c", "d", "e").status).toBe(2);
|
|
expect(run().status).toBe(2);
|
|
expect(run(file, "start", "a", "b", "c", "d").status).toBe(2);
|
|
const help = run("--help");
|
|
expect(help.status).toBe(0);
|
|
expect(help.stdout).toContain("gstack-decision-trail start <file> <run-id>");
|
|
expect(fs.existsSync(file)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("gstack-decision-trail start", () => {
|
|
test("a start row names the earlier rows and the run; rerunning it for the same run is a no-op", () => {
|
|
run(file, "a", "b", "c", "d", "e");
|
|
run(file, "f", "g", "h", "i", "j");
|
|
const first = run("start", file, "jam_A");
|
|
expect(first.status).toBe(0);
|
|
expect(first.stdout).toBe(`appended start row 3 for run jam_A to ${file}\n`);
|
|
const start = rows()[3]!;
|
|
expect(start[1]).toBe("start");
|
|
expect(start[2]).toBe("run jam_A joins this trail");
|
|
expect(start[3]).toMatch(/^2 earlier rows \(\d{4}-.*\.\.\d{4}-.*\) written by other runs$/);
|
|
expect(start[4]).toBe("run:jam_A");
|
|
expect(start[5]).toBe("open");
|
|
|
|
const again = run("start", file, "jam_A");
|
|
expect(again.status).toBe(0);
|
|
expect(again.stdout).toBe(`start row for run jam_A already present at ${file}:4\n`);
|
|
expect(rows()).toHaveLength(4);
|
|
});
|
|
|
|
test("a start row on an empty trail opens it, and another run's start row follows a prior run's", () => {
|
|
expect(run("start", file, "run-1").stdout).toBe(`appended start row 1 for run run-1 to ${file}\n`);
|
|
expect(rows()[1]!.slice(1)).toEqual(["start", "run run-1 opens this trail", "no earlier rows", "run:run-1", "open"]);
|
|
run("--run", "run-1", file, "work", "did a thing", "because", "commit 1", "ok");
|
|
expect(run("start", file, "run-2").status).toBe(0);
|
|
expect(rows().map(r => r[1])).toEqual(["phase", "start", "work", "start"]);
|
|
// run-1's row is older than run-2's start, so run-1 is a new run again and needs a new start row.
|
|
expect(run("start", file, "run-1").stdout).toContain("appended start row 4");
|
|
});
|
|
|
|
test("--run refuses an append until the run's start row is the newest one, and admits a fresh trail", () => {
|
|
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
|
|
run("start", file, "other");
|
|
const refused = run("--run", "r9", file, "a", "b", "c", "d", "e");
|
|
expect(refused.status).toBe(3);
|
|
expect(refused.stderr).toContain("DECISION_TRAIL_START_REQUIRED");
|
|
expect(refused.stderr).toContain(`gstack-decision-trail start ${file} r9`);
|
|
expect(rows()).toHaveLength(3);
|
|
run("start", file, "r9");
|
|
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
|
|
expect(rows()).toHaveLength(5);
|
|
});
|
|
|
|
test("start with the wrong arity is a usage error", () => {
|
|
expect(run("start", file).status).toBe(2);
|
|
expect(run("start", file, "r", "extra").status).toBe(2);
|
|
});
|
|
});
|