mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 03:00:26 +01:00
Merge remote-tracking branch 'origin/main' into gsta-114-autoplan-gates
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md
This commit is contained in:
commit
624f0eedec
20 files changed
+973
-6
No files matched your search
@@ -3,7 +3,8 @@
|
||||
* parent agent reads to drive /autoplan through `gstack-autoplan`. It is
|
||||
* generated from autoplan/DRIVER.md.tmpl by resolvers that read the code
|
||||
* (phase order, scope flags, run files, RESULT FORMAT, result codes, the
|
||||
* coordinator contract), stays under 160 lines with no harness vocabulary,
|
||||
* coordinator contract), stays at or under 162 lines (160 until the decision
|
||||
* trail paragraph joined the coordinator contract section) with no harness vocabulary,
|
||||
* is in the generator's normal, out-dir and dry-run inventories, and is
|
||||
* found from an installed root through its recorded `.source-path`.
|
||||
*/
|
||||
@@ -47,8 +48,8 @@ describe('autoplan/DRIVER.md content comes from the code', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('at most 160 lines and no harness vocabulary', () => {
|
||||
expect(doc.split('\n').length).toBeLessThanOrEqual(160);
|
||||
test('at most 162 lines and no harness vocabulary', () => {
|
||||
expect(doc.split('\n').length).toBeLessThanOrEqual(162);
|
||||
for (const term of ['PreToolUse', 'PostToolUse', 'SessionStart', 'AskUserQuestion', 'Skill tool']) expect(doc).not.toContain(term);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,8 @@ const SCANNER_EXEMPT: Record<string, string> = {
|
||||
'fetches the user\'s own repo\'s base branch for version-claim freshness — a user-repo dev-workflow op, not gstack-state egress',
|
||||
'bin/gstack-version-bump':
|
||||
'git fetch appears only in an error-message string',
|
||||
'bin/gstack-worktree-audit':
|
||||
'read-only audit: best-effort fetch of the user\'s own repo\'s base branch so the MERGED column is current (skipped with --no-fetch) — a user-repo dev-workflow op like gstack-next-version\'s, not gstack-state egress',
|
||||
'bin/gstack-state-matrix':
|
||||
'git fetch appears only in an error-message string (the fix the user runs when origin/<base> has no merge base)',
|
||||
'bin/gstack-redact-prepush':
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Fixture test for bin/gstack-worktree-audit: a bare origin, a main clone and
|
||||
* worktrees in every bucket the script knows, with a fake gh on PATH and a
|
||||
* Claude Code transcript directory under CLAUDE_CONFIG_DIR. The audit reads
|
||||
* only: the fixture is byte-compared before and after.
|
||||
*/
|
||||
import { afterAll, beforeAll, 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-worktree-audit");
|
||||
|
||||
let tmp: string;
|
||||
let repo: string;
|
||||
let origin: string;
|
||||
let fakeBin: string;
|
||||
let claudeDir: string;
|
||||
const wts: Record<string, string> = {};
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
function commit(cwd: string, file: string, text: string, msg: string): void {
|
||||
fs.writeFileSync(path.join(cwd, file), text);
|
||||
git(cwd, "add", file);
|
||||
git(cwd, "commit", "-q", "-m", msg);
|
||||
}
|
||||
function audit(...args: string[]) {
|
||||
const r = spawnSync("bash", [BIN, ...args], {
|
||||
cwd: repo, encoding: "utf8", timeout: 60_000,
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp },
|
||||
});
|
||||
const lines = r.stdout.split("\n").filter(Boolean);
|
||||
const header = lines[1]?.split("\t") ?? [];
|
||||
const rows = lines.slice(2).map(l => Object.fromEntries(l.split("\t").map((v, i) => [header[i]!, v])) as Record<string, string>);
|
||||
return { status: r.status, stderr: r.stderr, base: lines[0], header, rows, byPath: Object.fromEntries(rows.map(r => [r.WORKTREE, r])) };
|
||||
}
|
||||
function snapshot(dir: string): string {
|
||||
const r = spawnSync("bash", ["-c", "find . -path ./.git -prune -o -type f -print0 | sort -z | xargs -0 sha256sum; git -C . status --porcelain; git -C . for-each-ref; git -C . worktree list --porcelain"], { cwd: dir, encoding: "utf8", timeout: 30_000 });
|
||||
return r.stdout;
|
||||
}
|
||||
// The fake gh records its argv and, when jq is installed, runs the audit's own --jq expression over
|
||||
// canned JSON (so the fork-PR filter is exercised); without jq it prints the TSV that expression yields.
|
||||
function fakeGh(prs: Array<{ number: number; state: string; headRefName: string; isCrossRepository: boolean }>): void {
|
||||
fs.writeFileSync(path.join(fakeBin, "prs.json"), JSON.stringify(prs));
|
||||
fs.writeFileSync(path.join(fakeBin, "prs.tsv"), prs.filter(p => !p.isCrossRepository).map(p => `${p.headRefName}\t${p.number}\t${p.state.toLowerCase()}`).join("\n") + "\n");
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), `#!/usr/bin/env bash
|
||||
printf '%s\\n' "$@" > "${fakeBin}/gh-args"
|
||||
expr=""; while [ $# -gt 0 ]; do [ "$1" = --jq ] && expr="$2"; shift; done
|
||||
if command -v jq >/dev/null 2>&1 && [ -n "$expr" ]; then jq -r "$expr" "${fakeBin}/prs.json"; else cat "${fakeBin}/prs.tsv"; fi
|
||||
`, { mode: 0o755 });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-wt-audit-"));
|
||||
origin = path.join(tmp, "origin.git");
|
||||
repo = path.join(tmp, "repo");
|
||||
fs.mkdirSync(origin);
|
||||
git(origin, "init", "-q", "--bare", "--initial-branch=main");
|
||||
git(tmp, "clone", "-q", origin, repo);
|
||||
git(repo, "checkout", "-q", "-b", "main");
|
||||
commit(repo, "README.md", "# fixture\n", "initial");
|
||||
git(repo, "push", "-q", "-u", "origin", "main");
|
||||
|
||||
// merged: a branch whose commit was fast-forwarded into main and pushed; clean tree, branch never pushed.
|
||||
wts.merged = path.join(tmp, "wt-merged");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/merged", wts.merged);
|
||||
commit(wts.merged, "merged.txt", "done\n", "merged work");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/merged");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
|
||||
// wip: pushed branch with a tracked edit on top; its PR is open.
|
||||
wts.wip = path.join(tmp, "wt-wip");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/wip", wts.wip);
|
||||
commit(wts.wip, "wip.txt", "v1\n", "wip work");
|
||||
git(wts.wip, "push", "-q", "-u", "origin", "feat/wip");
|
||||
fs.writeFileSync(path.join(wts.wip, "wip.txt"), "v2 edited\n");
|
||||
|
||||
// scratch: an unpushed branch, two untracked files, a chat from today → verify-recent-chat.
|
||||
wts.scratch = path.join(tmp, "wt-scratch");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/scratch", wts.scratch);
|
||||
commit(wts.scratch, "scratch.txt", "s\n", "scratch work");
|
||||
fs.writeFileSync(path.join(wts.scratch, "notes.md"), "untracked\n");
|
||||
fs.writeFileSync(path.join(wts.scratch, "tmp.log"), "untracked\n");
|
||||
claudeDir = path.join(tmp, "claude");
|
||||
const chat = path.join(claudeDir, "projects", wts.scratch.replace(/[^A-Za-z0-9]/g, "-"));
|
||||
fs.mkdirSync(chat, { recursive: true });
|
||||
fs.writeFileSync(path.join(chat, "session.jsonl"), "{}\n");
|
||||
|
||||
// stale: an unpushed branch nobody touched, no chat, no PR → review.
|
||||
wts.stale = path.join(tmp, "wt-stale");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/stale", wts.stale);
|
||||
commit(wts.stale, "stale.txt", "s\n", "stale work");
|
||||
|
||||
// closed: pushed branch whose PR was closed without merging → safe.
|
||||
wts.closed = path.join(tmp, "wt-closed");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/closed", wts.closed);
|
||||
commit(wts.closed, "closed.txt", "c\n", "closed work");
|
||||
git(wts.closed, "push", "-q", "-u", "origin", "feat/closed");
|
||||
|
||||
fakeBin = path.join(tmp, "fakebin");
|
||||
fs.mkdirSync(fakeBin);
|
||||
|
||||
// spaced: a worktree whose path has a space, merged into main, clean → safe.
|
||||
wts.spaced = path.join(tmp, "wt with space");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/spaced", wts.spaced);
|
||||
commit(wts.spaced, "spaced.txt", "s\n", "spaced work");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/spaced");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
|
||||
// fresh: head is in main but an untracked file exists only here → review, not safe.
|
||||
wts.fresh = path.join(tmp, "wt-fresh");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/fresh", wts.fresh);
|
||||
fs.writeFileSync(path.join(wts.fresh, "newfeature.ts"), "export const x = 1;\n");
|
||||
|
||||
// gone: registered, directory deleted → prunable.
|
||||
wts.gone = path.join(tmp, "wt-gone");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/gone", wts.gone);
|
||||
fs.rmSync(wts.gone, { recursive: true, force: true });
|
||||
|
||||
// locked: registered and locked, directory gone → review (prune would not touch it).
|
||||
wts.locked = path.join(tmp, "wt-locked");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/locked", wts.locked);
|
||||
git(repo, "worktree", "lock", wts.locked);
|
||||
fs.rmSync(wts.locked, { recursive: true, force: true });
|
||||
|
||||
// broken: a gitignored worktree inside the clone whose .git file is gone, with a
|
||||
// precious file only there; git -C would describe the clone itself → review.
|
||||
fs.appendFileSync(path.join(repo, ".git", "info", "exclude"), ".wt\n");
|
||||
wts.broken = path.join(repo, ".wt", "broken");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/broken", wts.broken);
|
||||
fs.writeFileSync(path.join(wts.broken, "precious.txt"), "only here\n");
|
||||
fs.rmSync(path.join(wts.broken, ".git"));
|
||||
|
||||
// tagged: merged and clean, with a tag of the same name as its branch and an open PR → hold-open-pr.
|
||||
wts.tagged = path.join(tmp, "wt-tagged");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/tagged", wts.tagged);
|
||||
git(repo, "tag", "feat/tagged");
|
||||
|
||||
// ignored: merged and clean, with a gitignored .env → safe, DIRTY names the ignored entry.
|
||||
wts.ignored = path.join(tmp, "wt-ignored");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/ignored", wts.ignored);
|
||||
fs.writeFileSync(path.join(wts.ignored, ".gitignore"), ".env\n");
|
||||
git(wts.ignored, "add", ".gitignore");
|
||||
git(wts.ignored, "commit", "-q", "-m", "ignore .env");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/ignored");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
fs.writeFileSync(path.join(wts.ignored, ".env"), "SECRET=1\n");
|
||||
});
|
||||
// Each test starts from the same gh (one open, one closed, one fork PR on feat/stale that must be
|
||||
// ignored) and the same closed worktree, whatever an earlier test changed.
|
||||
beforeEach(() => {
|
||||
fakeGh([
|
||||
{ number: 41, state: "OPEN", headRefName: "feat/wip", isCrossRepository: false },
|
||||
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
|
||||
{ number: 99, state: "OPEN", headRefName: "feat/stale", isCrossRepository: true },
|
||||
{ number: 43, state: "OPEN", headRefName: "feat/tagged", isCrossRepository: false },
|
||||
]);
|
||||
git(wts.closed, "reset", "-q", "--hard", "origin/feat/closed");
|
||||
});
|
||||
afterAll(() => {
|
||||
if (repo) spawnSync("git", ["worktree", "prune"], { cwd: repo, timeout: 30_000 });
|
||||
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("gstack-worktree-audit", () => {
|
||||
test("classifies every worktree, reads the PR column through gh, the chat through CLAUDE_CONFIG_DIR, and changes nothing", () => {
|
||||
const live = Object.values(wts).filter(w => fs.existsSync(w));
|
||||
const before = live.map(snapshot).join("\n") + snapshot(repo);
|
||||
const r = audit();
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain("warn: no -b given; MERGED checks against origin/main");
|
||||
expect(r.base).toBe("# base: origin/main");
|
||||
expect(r.header).toEqual(["SIZE", "AGE", "MERGED", "DIRTY", "REMOTE", "PR", "LAST_CHAT", "BUCKET", "WORKTREE"]);
|
||||
expect(r.rows).toHaveLength(12);
|
||||
expect(r.byPath[repo]).toBeUndefined();
|
||||
expect(r.byPath[wts.locked]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "review" });
|
||||
expect(r.byPath[wts.broken]).toMatchObject({ DIRTY: "broken", BUCKET: "review" });
|
||||
expect(r.byPath[wts.tagged]).toMatchObject({ MERGED: "YES", DIRTY: "clean", PR: "#43/open", BUCKET: "hold-open-pr" });
|
||||
expect(r.byPath[wts.ignored]).toMatchObject({ MERGED: "YES", DIRTY: "clean,ignored:1", BUCKET: "safe" });
|
||||
const ghArgs = fs.readFileSync(path.join(fakeBin, "gh-args"), "utf8").split("\n");
|
||||
expect(ghArgs).toContain("number,state,headRefName,isCrossRepository");
|
||||
expect(ghArgs.find(a => a.startsWith(".[]"))).toContain("select(.isCrossRepository | not)");
|
||||
expect(r.byPath[wts.spaced]).toMatchObject({ MERGED: "YES", DIRTY: "clean", BUCKET: "safe" });
|
||||
expect(r.byPath[wts.fresh]).toMatchObject({ MERGED: "YES", DIRTY: "scratch:1", REMOTE: "no-remote", BUCKET: "review" });
|
||||
expect(r.byPath[wts.gone]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "prunable" });
|
||||
expect(r.rows.slice(-2).map(row => row.WORKTREE).sort()).toEqual([wts.gone, wts.locked].sort());
|
||||
|
||||
const merged = r.byPath[wts.merged]!;
|
||||
expect(merged).toMatchObject({ MERGED: "YES", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "safe", AGE: "0d" });
|
||||
expect(merged.SIZE).toMatch(/^\d+(\.\d+)?[KMG]?$/);
|
||||
|
||||
expect(r.byPath[wts.wip]).toMatchObject({ MERGED: "no", DIRTY: "wip:1", REMOTE: "pushed", PR: "#41/open", BUCKET: "hold-wip" });
|
||||
const scratch = r.byPath[wts.scratch]!;
|
||||
expect(scratch).toMatchObject({ MERGED: "no", DIRTY: "scratch:2", REMOTE: "no-remote", PR: "-", BUCKET: "verify-recent-chat" });
|
||||
expect(scratch.LAST_CHAT).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(r.byPath[wts.stale]).toMatchObject({ MERGED: "no", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "review" });
|
||||
expect(r.byPath[wts.closed]).toMatchObject({ MERGED: "no", DIRTY: "clean", REMOTE: "pushed", PR: "#40/closed", BUCKET: "safe" });
|
||||
|
||||
const after = live.map(snapshot).join("\n") + snapshot(repo);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
test("status.showUntrackedFiles=no in the user's config does not hide untracked files", () => {
|
||||
const r = spawnSync("bash", [BIN, "--no-fetch"], {
|
||||
cwd: repo, encoding: "utf8", timeout: 60_000,
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "status.showUntrackedFiles", GIT_CONFIG_VALUE_0: "no" },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const row = r.stdout.split("\n").find(l => l.endsWith("\t" + wts.fresh))!;
|
||||
expect(row.split("\t").slice(3, 4)).toEqual(["scratch:1"]);
|
||||
expect(row.split("\t")[7]).toBe("review");
|
||||
});
|
||||
|
||||
test("an unpushed commit on a branch with a closed PR is review, an open PR on the same name wins and holds it, and -b picks the base", () => {
|
||||
git(wts.closed, "commit", "-q", "--allow-empty", "-m", "one more");
|
||||
const r1 = audit("-b", "origin/main", "--no-fetch", repo);
|
||||
expect(r1.stderr).not.toContain("warn: no -b given");
|
||||
expect(r1.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#40/closed", BUCKET: "review" });
|
||||
fakeGh([
|
||||
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
|
||||
{ number: 42, state: "OPEN", headRefName: "feat/closed", isCrossRepository: false },
|
||||
]);
|
||||
const r2 = audit("-b", "origin/main", "--no-fetch", repo);
|
||||
expect(r2.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#42/open", BUCKET: "hold-open-pr" });
|
||||
});
|
||||
|
||||
test("without gh the PR column is - with a warning, and a missing base ref or repo is an error", () => {
|
||||
// A PATH with every tool the audit uses except gh. git goes in as the real binary from its
|
||||
// exec path, since a distribution's /usr/bin/git may be a wrapper script that looks git up on PATH.
|
||||
const noGh = path.join(tmp, "nogh");
|
||||
fs.mkdirSync(noGh);
|
||||
const onPath = (tool: string) => (process.env.PATH ?? "").split(path.delimiter).map(d => path.join(d, tool)).find(p => fs.existsSync(p));
|
||||
const execPath = spawnSync("git", ["--exec-path"], { encoding: "utf8", timeout: 30_000 }).stdout.trim();
|
||||
const realGit = [path.join(execPath, "git"), onPath("git")].find(p => p && fs.existsSync(p))!;
|
||||
fs.symlinkSync(fs.realpathSync(realGit), path.join(noGh, "git"));
|
||||
for (const tool of ["bash", "sh", "env", "du", "awk", "sed", "sort", "head", "find", "xargs", "stat", "date", "mktemp", "grep", "rm", "cat", "dirname", "basename", "uname", "tr", "cut", "wc"]) {
|
||||
const found = onPath(tool);
|
||||
if (found) fs.symlinkSync(fs.realpathSync(found), path.join(noGh, tool));
|
||||
}
|
||||
const r = spawnSync("bash", [BIN, "--no-fetch"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: { ...process.env, PATH: noGh, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain("PR column is -");
|
||||
const rows = r.stdout.split("\n").filter(Boolean).slice(2);
|
||||
expect(rows).toHaveLength(12);
|
||||
expect(rows.every(l => l.split("\t")[5] === "-")).toBe(true);
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), "#!/usr/bin/env bash\necho 'gh: not logged in' >&2; exit 4\n", { mode: 0o755 });
|
||||
const failing = audit("--no-fetch");
|
||||
expect(failing.status).toBe(0);
|
||||
expect(failing.stderr).toContain("gh could not list this repository's PRs; PR column is -");
|
||||
expect(failing.rows).toHaveLength(12);
|
||||
expect(failing.rows.every(row => row.PR === "-")).toBe(true);
|
||||
expect(audit("-b", "origin/nope", "--no-fetch").status).toBe(1);
|
||||
expect(audit("--no-fetch", path.join(tmp, "not-a-repo")).status).toBe(1);
|
||||
expect(audit("--bogus").status).toBe(2);
|
||||
expect(audit("--help").status).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user