From 6b833d03415292273bfc4d628cf53ba41518af15 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 00:22:59 +0000 Subject: [PATCH] feat: gstack-clean reclaims per-repo .gstack scratch and logs, dry run by default (#2627) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- BROWSER.md | 12 + TODOS.md | 5 + bin/gstack-clean | 12 + changelog.d/capy-gsta226-e2-gstack-clean.md | 10 + docs/troubleshooting.md | 24 ++ lib/repo-state-clean.ts | 365 ++++++++++++++++ lib/result-codes-clean.ts | 30 ++ lib/result-codes.ts | 2 + test/fixtures/module-size-ratchet.json | 4 +- test/gstack-clean-producers.test.ts | 104 +++++ test/gstack-clean.test.ts | 456 ++++++++++++++++++++ 11 files changed, 1023 insertions(+), 1 deletion(-) create mode 100755 bin/gstack-clean create mode 100644 changelog.d/capy-gsta226-e2-gstack-clean.md create mode 100644 lib/repo-state-clean.ts create mode 100644 lib/result-codes-clean.ts create mode 100644 test/gstack-clean-producers.test.ts create mode 100644 test/gstack-clean.test.ts diff --git a/BROWSER.md b/BROWSER.md index 13aa70909..704ce6391 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -1515,6 +1515,18 @@ capacity each), flushed to disk asynchronously via `Bun.write()`: - Network: `.gstack/browse-network.log` - Dialog: `.gstack/browse-dialog.log` +The browse server appends to these logs (and to `browse-daemon.log`, +`browse-startup-error.log` and the `browse-audit.jsonl` command audit) for +the life of the project, and codex scratch files collect in `.gstack/tmp/`. +Run `gstack-clean` in the repository to see what it would reclaim, then +`gstack-clean --apply` to do it: it cuts these logs to their last 256 KB +once they pass 1 MB, removes `tmp/codex-*` files older than 7 days, and +trims the audit log only when you pass `--audit-days N`. It never touches +`browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/` +or `browse-states/`, and it skips the browse files while the daemon runs. +Deleting the whole `.gstack/` folder instead also deletes your cookies and +browser skills. + The `console`, `network`, and `dialog` commands read from the in-memory buffers (not disk) so capture is real-time even when disk is slow. diff --git a/TODOS.md b/TODOS.md index 89b7d0396..42136bc71 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,11 @@ ## NEXT PRIORITY +### P3: gstack-clean follow-ups (filed 2026-10-31, GSTA-226 E2, #2627) + +- **Codex templates delete their own scratch.** The `/codex` review, challenge and consult blocks leave `codex-err-*`, `codex-resp-*`, `codex-out-*` and `codex-prompt-*` mktemp files under `TMP_ROOT` (which falls back to `.gstack/tmp`) and the `codex-prompt.*` and `codex-focus.*` free-text files in `.gstack/tmp`; `gstack-clean` removes them after 7 days. Delete each file at the end of its block instead. Skill prose for every user, so it needs its own review. **Effort:** S. **Priority:** P3. +- **Browse hints `gstack-clean` when `.gstack/tmp` is large.** Print one line at daemon start when `.gstack/tmp` or the browse logs pass a size threshold, naming `gstack-clean`. **Effort:** S. **Priority:** P3. + ### P3: GST-421 and GST-412 follow-ups (filed 2026-10-31, from the PR B /ship review) - **Check the Approvals table's own cells.** `--check-prose` skips the whole rendered Approvals block, including the Asked, Covers and Still pending cells the lane types. Narrow the skip to the Exact words column. **Priority:** P3. diff --git a/bin/gstack-clean b/bin/gstack-clean new file mode 100755 index 000000000..d5c363986 --- /dev/null +++ b/bin/gstack-clean @@ -0,0 +1,12 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-clean — reclaim throwaway files in this repository's .gstack folder + * (#2627): old codex scratch in tmp/, oversized browse logs cut in place, and + * with --audit-days N old browse audit entries. Dry run unless --apply; + * `gstack-clean --help` lists the fixed thresholds. The rules live in + * lib/repo-state-clean.ts; error codes GSTACK_CLEAN_* are documented in + * docs/troubleshooting.md. + */ +import { main } from '../lib/repo-state-clean'; + +process.exit(main(process.argv.slice(2))); diff --git a/changelog.d/capy-gsta226-e2-gstack-clean.md b/changelog.d/capy-gsta226-e2-gstack-clean.md new file mode 100644 index 000000000..91f4b993b --- /dev/null +++ b/changelog.d/capy-gsta226-e2-gstack-clean.md @@ -0,0 +1,10 @@ +**`gstack-clean` reclaims the throwaway files that pile up in a repository's `.gstack/` folder, and changes nothing unless you pass `--apply`** (#2627). Until now nothing removed old codex scratch files or trimmed the browse logs, so the folder only grew (the reporter measured 8.3 MB in six weeks), and the only way out was deleting the whole folder, which also deletes your cookies, Chromium profile and browser skills. + +### Added +- **`bin/gstack-clean`** (rules in `lib/repo-state-clean.ts`): cleans `/.gstack` only. It removes top-level `tmp/codex-*` files older than 7 days, cuts `browse-console.log`, `browse-network.log`, `browse-dialog.log`, `browse-daemon.log` and `browse-startup-error.log` to their last 256 KB once they pass 1 MB (in place, starting after a line break, so a running writer keeps appending), and with `--audit-days N` drops `browse-audit.jsonl` entries older than N days while keeping every line without a readable timestamp. Everything else, including `browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/`, `browse-states/` and the other `tmp/` entries, is never touched. +- **Safety:** it refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. It skips symlinks and files changed in the last hour, skips browse files while the browse daemon runs and `tmp/` while codex runs, and skips a group when it cannot tell. `--json` prints the same result as one versioned object. +- Result codes `GSTACK_CLEAN_USAGE`, `GSTACK_CLEAN_TARGET_REFUSED`, `GSTACK_CLEAN_PARTIAL` and `GSTACK_CLEAN_INTERNAL`, each with a docs/troubleshooting.md entry; BROWSER.md names the command where it lists the log files. + +### Tests +- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder and a symlinked `tmp/` are left alone, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. +- `test/gstack-clean-producers.test.ts`: every tracked producer of a `.gstack/tmp/` entry is classified as scratch or kept, and any new `codex-*` name must be added to the scratch list on purpose. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9cf9db55c..42de15501 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5818,3 +5818,27 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul ### `RENDER_DEST_NOT_OWNED: exists and holds no loadout.lock.json` **Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render. + +## Per-repo cleanup (gstack-clean) + +These codes come from `bin/gstack-clean`, which reclaims old codex scratch in `/.gstack/tmp/`, browse logs over 1 MB and, with `--audit-days N`, old browse audit entries. It changes nothing unless you pass `--apply`, and `gstack-clean --help` lists what it touches. + + +### `gstack-clean: an unknown flag or a bad --audit-days value … (GSTACK_CLEAN_USAGE)` + +**Meaning.** A flag is not one of `--apply`, `--audit-days N`, `--json` or `--help`, or `--audit-days` got something other than a whole number of days from 1 to 99999. The line quotes the bad value and the last line is the usage line. Exit 2; nothing was read or changed. **Fix.** `gstack-clean --audit-days 30`, for example. + + +### `gstack-clean: the folder to clean is not a safe per-repo .gstack folder … (GSTACK_CLEAN_TARGET_REFUSED)` + +**Meaning.** gstack-clean only cleans `/.gstack` for the directory you run it in. It refuses outside a git checkout, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` points at a different folder's `browse.json`, and when the folder is, holds or sits inside the gstack state root (`~/.gstack` by default), where your learnings and settings live. Exit 3; nothing was touched. **Fix.** Run it from inside the repository, unset `BROWSE_STATE_FILE` (or point it at this repository's `.gstack/browse.json`), and replace a symlinked `.gstack` with a real folder. + + +### `FAILED ` … `(GSTACK_CLEAN_PARTIAL)` + +**Meaning.** One or more removals or cuts failed, usually `EACCES` or `EPERM` (a read-only file or folder) or `EBUSY` (another program holds the file). Every other action finished. Exit 1. **Fix.** Rerunning is safe: fix the permissions on each FAILED path, or wait for the program holding it to finish, then run `gstack-clean --apply` again. + + +### `gstack-clean: unexpected gstack-clean failure … (GSTACK_CLEAN_INTERNAL)` + +**Meaning.** gstack-clean hit an error it does not expect; the line names it. Exit 1. Changes made before the error stay made, and each was checked right before it was made. **Fix.** Rerun once; if it repeats, report the printed error to the gstack maintainers. diff --git a/lib/repo-state-clean.ts b/lib/repo-state-clean.ts new file mode 100644 index 000000000..200906a31 --- /dev/null +++ b/lib/repo-state-clean.ts @@ -0,0 +1,365 @@ +/** + * repo-state-clean — gstack-clean's plan and apply (#2627; plan GSTA-226 D8, + * D9). Reclaims a fixed allowlist of byproducts in one repository's + * `/.gstack`, and nothing else: + * + * tmp/codex-* top-level regular files older than 7 days + * browse-*.log over 1 MB, cut in place to the last 256 KB from a + * line start (same inode, so O_APPEND writers go on) + * browse-audit.jsonl only with --audit-days N: entries older than N days + * + * browse.json, session-state.json, chromium-profile/, browser-skills/, + * browse-states/ and every other tmp/ entry (restamp journal, gate-ahead, + * pregate.json, ship receipt, autoplan/, ship-queue/, eval-bisect/) are never + * touched; test/gstack-clean-producers.test.ts classifies every tmp producer. + * Dry run unless --apply. Each change re-checks the file and its group's + * liveness first (live browse daemon, running codex, a write in the last + * hour); an unknown answer skips the group with its reason. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolveStateRoot } from './state-root'; +import { resultLine } from './result-codes'; + +export const CLEAN_SCHEMA_VERSION = 1; +const DAY_MS = 24 * 60 * 60 * 1000; +export const SCRATCH_PREFIX = 'codex-'; +export const SCRATCH_MIN_AGE_MS = 7 * DAY_MS; +export const RECENT_MS = 60 * 60 * 1000; +export const LOG_CUT_OVER_BYTES = 1024 * 1024; +export const LOG_KEEP_BYTES = 256 * 1024; +export const BROWSE_LOGS = ['browse-console.log', 'browse-network.log', 'browse-dialog.log', 'browse-daemon.log', 'browse-startup-error.log'] as const; +export const AUDIT_LOG = 'browse-audit.jsonl'; +export const USAGE = 'usage: gstack-clean [--apply] [--audit-days N] [--json]'; + +export const HELP = `gstack-clean — reclaim throwaway files in this repository's .gstack folder + +${USAGE} + +Reports what it would do and changes nothing unless you pass --apply. +Target: /.gstack of the current directory. Refuses (exit 3) +outside git, when .gstack is a symlink, when BROWSE_STATE_FILE names another +folder, and when the folder is, holds or sits inside the gstack state root. + +What it cleans (fixed thresholds): + tmp/codex-* codex scratch files older than 7 days: removed + browse-console.log, browse-network.log, browse-dialog.log, + browse-daemon.log, browse-startup-error.log + over 1 MB: cut in place to the last 256 KB, + starting after a line break + browse-audit.jsonl only with --audit-days N: entries older than N days + removed; lines without a readable ts are kept + +Everything else (browse.json, session-state.json, chromium-profile/, +browser-skills/, browse-states/, and every other tmp/ entry) is never touched. +Files changed in the last hour are skipped, browse files are skipped while the +browse daemon runs, tmp/ is skipped while codex runs, and a group is skipped +when that cannot be told. + +Options: + --apply make the changes + --audit-days N also trim browse-audit.jsonl to the last N days (N >= 1) + --json print one versioned JSON object instead of text + +Exit codes: 0 done or nothing to do, 1 an action failed (rerunning is safe), +2 usage, 3 target refused. +`; + +type Group = 'tmp' | 'browse'; +type Kind = 'remove' | 'cut' | 'rotate'; +export interface CleanOptions { apply: boolean; json: boolean; auditDays: number | null } +interface Candidate { file: string; group: Group; kind: Kind } +interface Action extends Candidate { bytes: number; reason: string; keep?: Buffer } +interface Skip { file: string; group: Group; reason: string } +interface Done { action: Action; errno?: string } +export interface CleanTarget { target: string; chosenBy: string } +interface Io { out: (line: string) => void; err: (line: string) => void } + +class Refused extends Error {} +class UsageError extends Error {} + +export function parseCleanArgs(argv: string[]): CleanOptions | 'help' { + const opts: CleanOptions = { apply: false, json: false, auditDays: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === '--help' || arg === '-h') return 'help'; + if (arg === '--apply') { opts.apply = true; continue; } + if (arg === '--json') { opts.json = true; continue; } + if (arg === '--audit-days' || arg.startsWith('--audit-days=')) { + const value = arg === '--audit-days' ? argv[++i] : arg.slice('--audit-days='.length); + if (value === undefined || !/^[1-9]\d{0,4}$/.test(value)) throw new UsageError(`--audit-days needs a whole number of days from 1 to 99999; got ${JSON.stringify(value ?? '')}`); + opts.auditDays = Number(value); + continue; + } + throw new UsageError(`unknown flag ${JSON.stringify(arg)}`); + } + return opts; +} + +/** realpath when the path exists, else its nearest existing parent's realpath plus the rest. */ +function canonical(p: string): string { + const abs = path.resolve(p); + try { return fs.realpathSync(abs); } catch { /* missing: resolve the parent */ } + const parent = path.dirname(abs); + return parent === abs ? abs : path.join(canonical(parent), path.basename(abs)); +} + +const within = (child: string, parent: string) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep); + +/** The verified target, or the missing path when .gstack does not exist. Throws Refused. */ +export function resolveCleanTarget(env: NodeJS.ProcessEnv, cwd: string): CleanTarget | { missing: string } { + const git = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000, windowsHide: true }); + const top = git.status === 0 ? git.stdout.trim() : ''; + if (!top) throw new Refused(`${cwd} is not inside a git work tree`); + const toplevel = fs.realpathSync(top); + const candidate = path.join(toplevel, '.gstack'); + if (env.BROWSE_STATE_FILE && canonical(path.resolve(cwd, env.BROWSE_STATE_FILE)) !== canonical(path.join(candidate, 'browse.json'))) { + throw new Refused(`BROWSE_STATE_FILE is ${env.BROWSE_STATE_FILE}, not ${path.join(candidate, 'browse.json')}`); + } + let st: fs.Stats; + try { st = fs.lstatSync(candidate); } catch (e: any) { + if (e?.code === 'ENOENT') return { missing: candidate }; + throw e; + } + if (st.isSymbolicLink()) throw new Refused(`${candidate} is a symlink`); + if (!st.isDirectory()) throw new Refused(`${candidate} is not a directory`); + const target = fs.realpathSync(candidate); + const stateRoot = canonical(path.resolve(cwd, resolveStateRoot(env))); + if (within(target, stateRoot) || within(stateRoot, target)) { + throw new Refused(`${target} is, holds or sits inside the gstack state root ${stateRoot}`); + } + const from = canonical(cwd) === toplevel ? '' : `, found from ${cwd}`; + return { target, chosenBy: `the .gstack folder at the git toplevel ${toplevel}${from}` }; +} + +function pidAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch (e: any) { return e?.code !== 'ESRCH'; } +} + +/** Why browse files must wait, or null when no browse daemon can be using them. */ +function browseBusy(target: string): string | null { + if (process.platform === 'win32') return 'cannot tell whether the browse daemon is running on Windows'; + for (const name of ['browse.json', 'browse.json.lock']) { + let text: string; + try { text = fs.readFileSync(path.join(target, name), 'utf8'); } catch (e: any) { + if (e?.code === 'ENOENT') continue; + return `cannot read ${name} (${e?.code ?? 'error'}) to tell whether the browse daemon is running`; + } + let pid: unknown; + try { pid = name === 'browse.json' ? JSON.parse(text)?.pid : Number(text.trim()); } catch { pid = undefined; } + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return `${name} names no pid, so the browse daemon may be running`; + if (pidAlive(pid)) return `the browse daemon (pid ${pid}, ${name}) is running`; + } + return null; +} + +/** Why tmp/ must wait, or null when no codex process runs. */ +function codexBusy(): string | null { + if (process.platform === 'win32') return 'cannot tell whether codex is running on Windows'; + const ps = spawnSync('ps', ['-A', '-o', 'args='], { encoding: 'utf8', timeout: 10_000, windowsHide: true }); + if (ps.error || ps.status !== 0) return 'cannot list processes to tell whether codex is running'; + for (const line of ps.stdout.split('\n')) { + const words = line.trim().split(/\s+/).slice(0, 2); + if (words.some(w => /^codex(?:\.js|-[^.]*)?$/.test(path.basename(w)))) return `codex is running (${line.trim().slice(0, 80)})`; + } + return null; +} + +/** Audit lines to keep (newer than the cutoff, or without a readable ts), or why the file is malformed. */ +function auditKeep(buf: Buffer, cutoffMs: number): Buffer | string { + const kept: string[] = []; + for (const [i, line] of buf.toString('utf8').split(/(?<=\n)/).entries()) { + const body = line.replace(/\r?\n$/, ''); + if (body.trim() === '') { kept.push(line); continue; } + let entry: unknown; + try { entry = JSON.parse(body); } catch { return `line ${i + 1} is not JSON, so the file was left as it is`; } + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return `line ${i + 1} is not a JSON object, so the file was left as it is`; + const ts = Date.parse(String((entry as { ts?: unknown }).ts ?? '')); + if (Number.isNaN(ts) || ts >= cutoffMs) kept.push(line); + } + return Buffer.from(kept.join(''), 'utf8'); +} + +function readTail(file: string, size: number): Buffer { + const fd = fs.openSync(file, 'r'); + try { + const length = Math.min(size, LOG_KEEP_BYTES); + const buf = Buffer.alloc(length); + return buf.subarray(0, fs.readSync(fd, buf, 0, length, size - length)); + } finally { fs.closeSync(fd); } +} + +/** + * One file's action, a skip, or null when the file is not a target. `busy` is + * the group's liveness answer; applyClean asks it again right before a change. + */ +function evaluate(c: Candidate, dir: string, busy: string | null, opts: CleanOptions, now: number): Action | Skip | null { + let st: fs.Stats; + try { st = fs.lstatSync(c.file); } catch (e: any) { + return e?.code === 'ENOENT' ? null : { ...c, reason: `cannot stat (${e?.code ?? 'error'})` }; + } + if (st.isSymbolicLink()) return { ...c, reason: 'is a symlink; left alone' }; + if (!st.isFile()) return c.kind === 'remove' ? { ...c, reason: 'is not a regular file; left alone' } : null; + if (path.dirname(fs.realpathSync(c.file)) !== dir) return { ...c, reason: 'resolves outside the folder being cleaned; left alone' }; + if (c.kind === 'remove' && now - st.mtimeMs < SCRATCH_MIN_AGE_MS) return null; + if (c.kind === 'cut' && st.size <= LOG_CUT_OVER_BYTES) return null; + if (now - st.mtimeMs < RECENT_MS) return { ...c, reason: 'modified within the last hour' }; + if (busy) return { ...c, reason: busy }; + if (c.kind === 'remove') return { ...c, bytes: st.size, reason: 'codex scratch older than 7 days' }; + if (c.kind === 'cut') { + const tail = readTail(c.file, st.size); + const at = tail.indexOf(0x0a); + if (at < 0) return { ...c, reason: 'no line break in its last 256 KB; left alone' }; + const keep = tail.subarray(at + 1); + return { ...c, keep, bytes: st.size - keep.length, reason: 'over 1 MB; keeps the last 256 KB from a line start' }; + } + const keep = auditKeep(fs.readFileSync(c.file), now - opts.auditDays! * DAY_MS); + if (typeof keep === 'string') return { ...c, reason: keep }; + if (keep.length === st.size) return null; + return { ...c, keep, bytes: st.size - keep.length, reason: `audit entries older than ${opts.auditDays} days` }; +} + +function candidates(target: string, opts: CleanOptions): { list: Candidate[]; skips: Skip[] } { + const list: Candidate[] = []; + const skips: Skip[] = []; + const tmp = path.join(target, 'tmp'); + let tmpStat: fs.Stats | null = null; + try { tmpStat = fs.lstatSync(tmp); } catch (e: any) { + if (e?.code !== 'ENOENT') skips.push({ file: tmp, group: 'tmp', reason: `cannot stat (${e?.code ?? 'error'}); the tmp group is skipped` }); + } + if (tmpStat?.isSymbolicLink()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp/ is a symlink; the tmp group is refused' }); + else if (tmpStat && !tmpStat.isDirectory()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp is not a directory; the tmp group is skipped' }); + else if (tmpStat) { + for (const name of fs.readdirSync(tmp).sort()) if (name.startsWith(SCRATCH_PREFIX)) list.push({ file: path.join(tmp, name), group: 'tmp', kind: 'remove' }); + } + for (const name of BROWSE_LOGS) list.push({ file: path.join(target, name), group: 'browse', kind: 'cut' }); + if (opts.auditDays !== null) list.push({ file: path.join(target, AUDIT_LOG), group: 'browse', kind: 'rotate' }); + return { list, skips }; +} + +const dirOf = (c: Candidate, target: string) => (c.group === 'tmp' ? path.join(target, 'tmp') : target); +const busyFor = (group: Group, target: string) => (group === 'tmp' ? codexBusy() : browseBusy(target)); + +export function planClean(target: string, opts: CleanOptions, now = Date.now()): { actions: Action[]; skips: Skip[] } { + const { list, skips } = candidates(target, opts); + const busy: Record = { + tmp: list.some(c => c.group === 'tmp') ? busyFor('tmp', target) : null, + browse: busyFor('browse', target), + }; + const actions: Action[] = []; + for (const c of list) { + const result = evaluate(c, dirOf(c, target), busy[c.group], opts, now); + if (result && 'bytes' in result) actions.push(result); + else if (result) skips.push(result); + } + return { actions, skips }; +} + +function rewriteInPlace(file: string, keep: Buffer): void { + const fd = fs.openSync(file, 'r+'); + try { + fs.writeSync(fd, keep, 0, keep.length, 0); + fs.ftruncateSync(fd, keep.length); + } finally { fs.closeSync(fd); } +} + +/** Re-checks each planned action against its file and its group's liveness right before the change. */ +export function applyClean(target: string, planned: Action[], opts: CleanOptions): { done: Done[]; skips: Skip[] } { + const done: Done[] = []; + const skips: Skip[] = []; + for (const p of planned) { + const fresh = evaluate({ file: p.file, group: p.group, kind: p.kind }, dirOf(p, target), busyFor(p.group, target), opts, Date.now()); + if (!fresh) continue; + if (!('bytes' in fresh)) { skips.push(fresh); continue; } + try { + if (fresh.kind === 'remove') fs.unlinkSync(fresh.file); + else rewriteInPlace(fresh.file, fresh.keep!); + done.push({ action: fresh }); + } catch (e: any) { + done.push({ action: fresh, errno: e?.code ?? 'UNKNOWN' }); + } + } + return { done, skips }; +} + +const VERB: Record = { remove: ['would remove', 'removed'], cut: ['would cut', 'cut'], rotate: ['would trim', 'trimmed'] }; + +function report(io: Io, opts: CleanOptions, t: CleanTarget, rows: Done[], skips: Skip[]): number { + const rel = (file: string) => path.relative(t.target, file) || '.'; + const failed = rows.filter(r => r.errno); + const ok = rows.filter(r => !r.errno); + const bytes = ok.reduce((n, r) => n + r.action.bytes, 0); + const exit = failed.length ? 1 : 0; + const partial = resultLine('gstack-clean', 'GSTACK_CLEAN_PARTIAL', `${failed.length} of ${rows.length} actions failed`); + if (opts.json) { + io.out(JSON.stringify({ + schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run', + status: failed.length ? 'partial' : rows.length === 0 ? 'nothing_to_do' : opts.apply ? 'done' : 'planned', + target: t.target, chosen_by: t.chosenBy, audit_days: opts.auditDays, + actions: rows.map(({ action: a, errno }) => ({ + path: a.file, rel: rel(a.file), group: a.group, action: a.kind, bytes: a.bytes, reason: a.reason, + status: errno ? 'failed' : opts.apply ? 'done' : 'planned', ...(errno ? { errno } : {}), + })), + skips: skips.map(s => ({ path: s.file, rel: rel(s.file), group: s.group, reason: s.reason })), + total: { actions: ok.length, bytes, failed: failed.length, skipped: skips.length }, + error: failed.length ? { code: 'GSTACK_CLEAN_PARTIAL', message: partial } : null, + exit_code: exit, + }, null, 2)); + if (failed.length) io.err(partial); + return exit; + } + io.out(`gstack-clean: target ${t.target} (${t.chosenBy})`); + for (const { action: a, errno } of rows) { + if (errno) { io.out(`FAILED ${a.file} ${errno}`); continue; } + const size = a.kind === 'remove' ? `${a.bytes} bytes` : `${opts.apply ? 'freed' : 'freeing'} ${a.bytes} bytes`; + io.out(`${VERB[a.kind][opts.apply ? 1 : 0].padEnd(12)} ${rel(a.file)} (${size}): ${a.reason}`); + } + for (const s of skips) io.out(`${'skip'.padEnd(12)} ${rel(s.file)}: ${s.reason}`); + const failNote = failed.length ? `; ${failed.length} failed` : ''; + io.out(`total: ${ok.length} action${ok.length === 1 ? '' : 's'} ${opts.apply ? 'freed' : 'would free'} ${bytes} bytes${failNote}; ${skips.length} skipped`); + if (failed.length) io.err(partial); + else if (!opts.apply) io.out(rows.length ? `nothing changed; to do this: gstack-clean --apply${opts.auditDays === null ? '' : ` --audit-days ${opts.auditDays}`}` : 'nothing changed; nothing to clean'); + return exit; +} + +function run(argv: string[], env: NodeJS.ProcessEnv, cwd: string, io: Io): number { + let parsed: CleanOptions | 'help'; + try { parsed = parseCleanArgs(argv); } catch (e) { + if (!(e instanceof UsageError)) throw e; + io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_USAGE', e.message)); + io.err(USAGE); + return 2; + } + if (parsed === 'help') { io.out(HELP.trimEnd()); return 0; } + const opts = parsed; + let resolved: CleanTarget | { missing: string }; + try { resolved = resolveCleanTarget(env, cwd); } catch (e) { + if (!(e instanceof Refused)) throw e; + const line = resultLine('gstack-clean', 'GSTACK_CLEAN_TARGET_REFUSED', e.message); + io.err(line); + if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', status: 'refused', error: { code: 'GSTACK_CLEAN_TARGET_REFUSED', message: line }, exit_code: 3 }, null, 2)); + return 3; + } + if ('missing' in resolved) { + if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run', status: 'nothing_to_do', target: resolved.missing, actions: [], skips: [], total: { actions: 0, bytes: 0, failed: 0, skipped: 0 }, error: null, exit_code: 0 }, null, 2)); + else io.out(`nothing to clean: ${resolved.missing} does not exist`); + return 0; + } + const plan = planClean(resolved.target, opts); + if (!opts.apply) return report(io, opts, resolved, plan.actions.map(action => ({ action })), plan.skips); + const applied = applyClean(resolved.target, plan.actions, opts); + return report(io, opts, resolved, applied.done, [...plan.skips, ...applied.skips]); +} + +/** The bin's entry. Never throws: an unexpected error prints one GSTACK_CLEAN_INTERNAL line and exits 1. */ +export function main(argv: string[], env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): number { + const io: Io = { out: line => process.stdout.write(`${line}\n`), err: line => process.stderr.write(`${line}\n`) }; + try { + return run(argv, env, cwd, io); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_INTERNAL', `${err.name}: ${err.message}`.slice(0, 300))); + return 1; + } +} diff --git a/lib/result-codes-clean.ts b/lib/result-codes-clean.ts new file mode 100644 index 000000000..5a4dc94c0 --- /dev/null +++ b/lib/result-codes-clean.ts @@ -0,0 +1,30 @@ +/** + * Result codes for gstack-clean (lib/repo-state-clean.ts, #2627). Kept beside + * lib/result-codes.ts (which spreads this table into RESULT_CODES) so the owner + * module stays within the module-size ratchet; the same rules apply: one stable + * docs/troubleshooting.md anchor, a summary and a fix clause each. + */ +import type { ResultCode } from './result-codes'; + +export const CLEAN_RESULT_CODES = { + GSTACK_CLEAN_USAGE: { + anchor: 'gstack-clean-usage', + summary: 'an unknown flag or a bad --audit-days value; nothing was read or changed', + fix: 'gstack-clean [--apply] [--audit-days N] [--json], where N is a whole number of days, 1 or more', + }, + GSTACK_CLEAN_TARGET_REFUSED: { + anchor: 'gstack-clean-target-refused', + summary: 'the folder to clean is not a safe per-repo .gstack folder, so nothing was touched', + fix: 'run gstack-clean inside a git checkout whose .gstack is a real folder (not a symlink) outside the gstack state root, with BROWSE_STATE_FILE unset or naming that folder\'s browse.json', + }, + GSTACK_CLEAN_PARTIAL: { + anchor: 'gstack-clean-partial', + summary: 'one or more removals or cuts failed; the others finished', + fix: 'rerunning is safe: fix the permissions on each FAILED path or wait for the program holding it, then rerun gstack-clean --apply', + }, + GSTACK_CLEAN_INTERNAL: { + anchor: 'gstack-clean-internal', + summary: 'unexpected gstack-clean failure; the line names the error', + fix: 'rerun once; if it repeats, report the printed error to the gstack maintainers', + }, +} as const satisfies Record; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index a7ac73c3d..f4bdd00ba 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -14,6 +14,7 @@ import { RELEASE_RESULT_CODES } from './result-codes-release'; import { CAPY_RESULT_CODES } from './result-codes-capy'; import { BUDGET_RESULT_CODES } from './result-codes-budget'; import { EVAL_RESULT_CODES } from './result-codes-eval'; +import { CLEAN_RESULT_CODES } from './result-codes-clean'; export interface ResultCode { /** Stable `` in docs/troubleshooting.md. */ @@ -763,6 +764,7 @@ export const RESULT_CODES = { ...CAPY_RESULT_CODES, ...BUDGET_RESULT_CODES, ...EVAL_RESULT_CODES, + ...CLEAN_RESULT_CODES, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 8139bdb26..8a6052c6e 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -176,7 +176,9 @@ { "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" }, { "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" }, { "file": "lib/pr-wait.ts", "movedFrom": "(new: gstack-pr-wait event logic, GSTA-115 item 1; design ported from tstack pr-wait)" }, - { "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" } + { "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" }, + { "file": "lib/repo-state-clean.ts", "movedFrom": "(new: per-repo .gstack cleanup behind bin/gstack-clean, #2627)" }, + { "file": "lib/result-codes-clean.ts", "movedFrom": "lib/result-codes.ts (the gstack-clean rows, #2627)" } ], "residualFiles": [ { "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" }, diff --git a/test/gstack-clean-producers.test.ts b/test/gstack-clean-producers.test.ts new file mode 100644 index 000000000..ba3cef654 --- /dev/null +++ b/test/gstack-clean-producers.test.ts @@ -0,0 +1,104 @@ +/** + * gstack-clean removes only top-level tmp/codex-* files in a repository's + * .gstack (lib/repo-state-clean.ts, #2627 D8). This test greps the tracked + * templates, lib, bin and scripts for every producer of a .gstack/tmp/ entry + * and fails when one is not classified below, as scratch gstack-clean may + * remove or as kept, or when a codex-* name is not on the scratch list (a kept + * file named codex-* would be deleted after 7 days). Classify a new producer + * here before shipping it. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { SCRATCH_PREFIX } from '../lib/repo-state-clean'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +/** Throwaway names gstack-clean removes after 7 days; every one starts with SCRATCH_PREFIX. Exact names, so a new codex-* producer must be looked at. */ +const SCRATCH: Record = { + 'codex-err-XXXXXX': 'codex stderr capture under TMP_ROOT, which falls back to .gstack/tmp (codex/sections/*.md.tmpl)', + 'codex-resp-XXXXXX': 'codex response capture under TMP_ROOT (codex/sections/*.md.tmpl)', + 'codex-out-XXXXXX': 'codex review output under TMP_ROOT (codex/sections/review-mode.md.tmpl)', + 'codex-prompt-XXXXXX': 'codex review prompt under TMP_ROOT (codex/sections/review-mode.md.tmpl)', + 'codex-prompt.XXXXXX': 'codex challenge and consult prompt, a free-text file in .gstack/tmp (codex/sections/*.md.tmpl)', + 'codex-focus.XXXXXX': 'codex review focus, a free-text file in .gstack/tmp (codex/sections/review-mode.md.tmpl)', +}; + +/** Names or name patterns (prefix* or *suffix) gstack-clean never touches. */ +const KEPT: Record = { + 'restamp-journal.json': 'gstack-restamp rollback journal (lib/restamp.ts)', + 'gate-ahead*': 'gate-ahead record and worktree (lib/restamp.ts)', + 'pregate.json': 'pregate result (lib/pregate/index.ts)', + 'ship-queue': 'ship-queue state (lib/ship-queue-gate.ts)', + 'ship-receipt.md': '/ship handoff receipt', + 'ship-gate.json': '/ship handoff gate record', + 'issues.json': '/ship issue links', + 'docs-audit.json': '/ship documentation audit record', + 'tree-receipt.json': 'gstack-tree-receipt output', + 'base-red.json': '/ship base-red record', + 'review-*': '/ship review loop records', + 'fix-check-*': '/ship fix-check inputs', + 'autoplan': 'autoplan phase artifacts (autoplan/SKILL.md.tmpl)', + 'eval-bisect': '/investigate eval bisect cache', + 'price-gate': 'gstack-eval-plan price receipts', + 'qt.txt': 'question-tuning summary file', + 'tune-words.txt': '/plan-tune free text', + 'before.txt': '/ship PR screenshot input', + 'after.txt': '/ship PR screenshot input', + '*.XXXXXX': 'free-text and page mktemp files (scripts/resolvers/free-text-file.ts, scripts/resolvers/gbrain.ts)', + '<*': 'a free-text file named by the basename its mktemp printed', + 'name*': 'bin/gstack-paths comment example for TMP_ROOT', +}; + +const matches = (name: string, pattern: string) => pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : pattern.startsWith('*') ? name.endsWith(pattern.slice(1)) : name === pattern; + +function trackedSources(): string[] { + const r = spawnSync('git', ['ls-files', '-z', '--', '*.tmpl', 'lib', 'bin', 'scripts', 'autoplan/bin', 'browse/src'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }); + if (r.status !== 0) throw new Error('git ls-files failed: ' + r.stderr); + return r.stdout.split('\0').filter(f => f && !/(^|\/)test\//.test(f) && !f.endsWith('.test.ts') && fs.existsSync(path.join(ROOT, f))); +} + +/** Every .gstack/tmp entry name a source can create, with where it was seen. */ +function producers(): Map { + const found = new Map(); + const add = (name: string, where: string) => { if (name && !found.has(name)) found.set(name, where); }; + for (const file of trackedSources()) { + const text = fs.readFileSync(path.join(ROOT, file), 'utf8'); + const at = (index: number) => file + ':' + (text.slice(0, index).split('\n').length); + for (const m of text.matchAll(/\.gstack\/tmp\/([^\s"'`)\]}$;,|&*\\]*)/g)) add(m[1]!.split('/')[0]!.replace(/\.+$/, ''), at(m.index!)); + for (const m of text.matchAll(/['"]\.gstack['"],\s*['"]tmp['"](?:,\s*['"]([^'"]+)['"])?/g)) add(m[1] ?? '', at(m.index!)); + for (const m of text.matchAll(/\$\{?TMP_ROOT\}?\/([A-Za-z0-9._<>-]+)/g)) add(m[1]!, at(m.index!)); + for (const m of text.matchAll(/\{\{FREE_TEXT_FILE(?::([^}]*))?\}\}/g)) { + for (const spec of (m[1] ?? 'TEXT_FILE=text').split(':')) add((spec.split('=')[1] || 'text') + '.XXXXXX', at(m.index!)); + } + } + return found; +} + +describe('gstack-clean producer classification', () => { + const found = producers(); + + test('the scan sees the known producers', () => { + for (const name of ['restamp-journal.json', 'pregate.json', 'ship-queue', 'autoplan', 'codex-err-XXXXXX']) expect(found.has(name)).toBe(true); + }); + + test('every .gstack/tmp producer is classified', () => { + const problems: string[] = []; + for (const [name, where] of found) { + if (name.startsWith(SCRATCH_PREFIX)) { + if (!(name in SCRATCH)) problems.push(where + ': ' + name + ' starts with ' + SCRATCH_PREFIX + ', so gstack-clean removes it after 7 days; add it to SCRATCH if it is throwaway, or rename it'); + } else if (!Object.keys(KEPT).some(pattern => matches(name, pattern))) { + problems.push(where + ': ' + name + ' is not classified; add it to KEPT (or name it ' + SCRATCH_PREFIX + '* and add it to SCRATCH)'); + } + } + expect(problems).toEqual([]); + }); + + test('every classified entry still has a producer, and scratch is what gstack-clean removes', () => { + expect(Object.keys(SCRATCH).filter(name => !name.startsWith(SCRATCH_PREFIX))).toEqual([]); + const names = [...found.keys()]; + expect(Object.keys(SCRATCH).filter(name => !found.has(name))).toEqual([]); + expect(Object.keys(KEPT).filter(pattern => !names.some(name => !name.startsWith(SCRATCH_PREFIX) && matches(name, pattern)))).toEqual([]); + }); +}); diff --git a/test/gstack-clean.test.ts b/test/gstack-clean.test.ts new file mode 100644 index 000000000..b49fac8f7 --- /dev/null +++ b/test/gstack-clean.test.ts @@ -0,0 +1,456 @@ +/** + * gstack-clean (#2627, GSTA-226 D8/D9): dry run changes nothing, apply removes + * exactly the allowlist, every refusal touches nothing, links never lead it + * outside the folder, liveness and unknown liveness skip a group, the in-place + * cut keeps the inode and a line boundary, audit rotation keeps newer and + * unreadable lines byte for byte, exit codes 0/1/2/3, and the --json shape. + * Fixtures live under os.tmpdir(), outside any checkout, with GSTACK_HOME at + * a temp state root; every fixture path is resolved and checked to stay there. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawn, spawnSync } from 'node:child_process'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-clean'); +const DAY = 24 * 60 * 60 * 1000; +const roots: string[] = []; + +afterAll(() => { + for (const root of roots) { + spawnSync('chmod', ['-R', 'u+rwx', root], { timeout: 10_000 }); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +interface Fixture { root: string; home: string; state: string; repo: string; gs: string } + +/** Bun's install cache and the machine's git shim write under HOME; neither is gstack-clean's doing. */ +const HOME_NOISE = new Set(['home/.bun', 'home/.capy']); + +function git(cwd: string, ...args: string[]): string { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }); + if (r.status !== 0) throw new Error('git ' + args.join(' ') + ': ' + r.stderr); + return r.stdout.trim(); +} + +function inRoot(root: string, p: string): string { + const real = fs.realpathSync(p); + if (real !== root && !real.startsWith(root + path.sep)) throw new Error(p + ' resolves outside the fixture root ' + root); + return real; +} + +function fixture(): Fixture { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-clean-'))); + roots.push(root); + if (root.startsWith(ROOT + path.sep)) throw new Error('fixture root is inside the checkout'); + const home = path.join(root, 'home'); + const state = path.join(root, 'state'); + const repo = path.join(root, 'repo'); + fs.mkdirSync(home); + fs.mkdirSync(state); + fs.mkdirSync(repo); + git(repo, 'init', '-q'); + expect(git(repo, 'rev-parse', '--show-toplevel')).toBe(repo); + const gs = path.join(repo, '.gstack'); + fs.mkdirSync(path.join(gs, 'tmp'), { recursive: true }); + inRoot(root, gs); + return { root, home, state, repo, gs }; +} + +function put(file: string, content: string | Buffer, ageMs = 30 * DAY): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + const when = new Date(Date.now() - ageMs); + fs.utimesSync(file, when, when); + return file; +} + +/** Log text of whole numbered lines, about the given size. */ +function logText(bytes: number): string { + const lines: string[] = []; + let size = 0; + for (let i = 0; size < bytes; i++) { + const line = 'line ' + String(i).padStart(7, '0') + ' ' + 'x'.repeat(i % 90) + '\n'; + lines.push(line); + size += line.length; + } + return lines.join(''); +} + +function deadPid(): number { + const r = spawnSync('true', [], { timeout: 10_000 }); + return r.pid!; +} + +function run(f: Fixture, args: string[], opts: { cwd?: string; env?: Record } = {}) { + const env: Record = { ...process.env, HOME: f.home, GSTACK_HOME: f.state, ...opts.env }; + for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR', 'CLAUDE_PLUGIN_DATA', 'BROWSE_STATE_FILE']) if (!(opts.env && name in opts.env)) delete env[name]; + for (const [k, v] of Object.entries(env)) if (v === undefined) delete env[k]; + const r = spawnSync(process.execPath, [...bunIsolationArgs(), BIN, ...args], { cwd: opts.cwd ?? f.repo, env: env as NodeJS.ProcessEnv, encoding: 'utf8', timeout: 30_000 }); + return { code: r.status, out: r.stdout, err: r.stderr }; +} + +/** Every entry under the fixture root (links not followed): type, inode, mtime and content hash. */ +function snapshot(root: string): Record { + const out: Record = {}; + const walk = (dir: string) => { + for (const name of fs.readdirSync(dir).sort()) { + const p = path.join(dir, name); + const st = fs.lstatSync(p); + const rel = path.relative(root, p); + if (HOME_NOISE.has(rel)) continue; + if (st.isSymbolicLink()) out[rel] = 'link ' + fs.readlinkSync(p); + else if (st.isDirectory()) { out[rel] = 'dir'; walk(p); } + else out[rel] = 'file ' + st.ino + ' ' + st.mtimeMs + ' ' + crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); + } + }; + walk(root); + return out; +} + +function changedKeys(before: Record, after: Record): string[] { + return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => before[k] !== after[k]).sort(); +} + +/** A .gstack with one of everything: targets, near misses and protected files. */ +function populate(f: Fixture): void { + const t = path.join(f.gs, 'tmp'); + put(path.join(t, 'codex-err-old1'), 'old stderr', 8 * DAY); + put(path.join(t, 'codex-resp-old2'), 'old response', 400 * DAY); + put(path.join(t, 'codex-resp-new'), 'two days old', 2 * DAY); + for (const name of ['restamp-journal.json', 'gate-ahead.json', 'gate-ahead-2.json', 'pregate.json', 'ship-receipt.md', 'brief.AbC123', 'page.Xy12']) + put(path.join(t, name), '{"keep":true}', 400 * DAY); + for (const dir of ['autoplan', 'ship-queue', 'eval-bisect', 'price-gate']) put(path.join(t, dir, 'codex-nested-old'), 'nested', 400 * DAY); + put(path.join(t, 'codex-dir', 'inside'), 'a directory named like scratch', 400 * DAY); + put(path.join(f.gs, 'browse-console.log'), logText(1.5 * 1024 * 1024), 2 * 60 * 60 * 1000); + put(path.join(f.gs, 'browse-network.log'), logText(500 * 1024), 400 * DAY); + put(path.join(f.gs, 'browse-daemon.log.1'), logText(2 * 1024 * 1024), 400 * DAY); + put(path.join(f.gs, 'browse-audit.jsonl'), '{"ts":"2020-01-01T00:00:00.000Z","cmd":"goto"}\n', 400 * DAY); + put(path.join(f.gs, 'browse.json'), JSON.stringify({ pid: deadPid(), port: 1 }), 400 * DAY); + put(path.join(f.gs, 'session-state.json'), '{"cookies":[]}', 400 * DAY); + put(path.join(f.gs, 'chromium-profile', 'Default', 'Cookies'), 'cookies', 400 * DAY); + put(path.join(f.gs, 'browser-skills', 'mine', 'script.ts'), 'export {}', 400 * DAY); + put(path.join(f.gs, 'browse-states', 'saved.json'), '{}', 400 * DAY); +} + +describe('gstack-clean', () => { + test('dry run changes no byte or mtime and names the target first', () => { + const f = fixture(); + populate(f); + const before = snapshot(f.root); + const r = run(f, []); + expect(r.code).toBe(0); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + const lines = r.out.trimEnd().split('\n'); + expect(lines[0]).toBe('gstack-clean: target ' + f.gs + ' (the .gstack folder at the git toplevel ' + f.repo + ')'); + expect(r.out).toMatch(/would remove +tmp\/codex-err-old1 \(10 bytes\)/); + expect(r.out).toMatch(/would cut +browse-console\.log \(freeing \d+ bytes\)/); + expect(r.out).toMatch(/skip +tmp\/codex-dir: is not a regular file/); + expect(r.out).toMatch(/^total: 3 actions would free \d+ bytes; 1 skipped$/m); + expect(lines.at(-1)).toBe('nothing changed; to do this: gstack-clean --apply'); + }); + + test('apply removes exactly the allowlist; protected files survive at any age', () => { + const f = fixture(); + populate(f); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.err).toBe(''); + const after = snapshot(f.root); + expect(changedKeys(before, after)).toEqual([ + 'repo/.gstack/browse-console.log', + 'repo/.gstack/tmp/codex-err-old1', + 'repo/.gstack/tmp/codex-resp-old2', + ]); + expect(after['repo/.gstack/tmp/codex-err-old1']).toBeUndefined(); + expect(after['repo/.gstack/browse-console.log']).toBeDefined(); + expect(r.out).toMatch(/^removed +tmp\/codex-resp-old2 \(12 bytes\)/m); + expect(r.out).toMatch(/^total: 3 actions freed \d+ bytes; 1 skipped$/m); + expect(r.out).not.toContain('nothing changed'); + }); + + test('a missing .gstack is nothing to clean', () => { + const f = fixture(); + fs.rmSync(f.gs, { recursive: true }); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out.trim()).toBe('nothing to clean: ' + f.gs + ' does not exist'); + }); + + describe('refusals touch nothing (exit 3)', () => { + const refused = (f: Fixture, r: ReturnType, before: Record, why: RegExp) => { + expect(r.code).toBe(3); + expect(r.out).toBe(''); + expect(r.err.trim()).toMatch(why); + expect(r.err.trim()).toEndWith('(GSTACK_CLEAN_TARGET_REFUSED)'); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }; + + test('outside git', () => { + const f = fixture(); + const plain = path.join(f.root, 'plain'); + put(path.join(plain, '.gstack', 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const before = snapshot(f.root); + refused(f, run(f, ['--apply'], { cwd: plain }), before, /is not inside a git work tree/); + }); + + test('a symlinked .gstack', () => { + const f = fixture(); + fs.rmSync(f.gs, { recursive: true }); + const elsewhere = path.join(f.root, 'elsewhere'); + put(path.join(elsewhere, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + fs.symlinkSync(elsewhere, f.gs); + const before = snapshot(f.root); + refused(f, run(f, ['--apply']), before, /\.gstack is a symlink/); + }); + + test('BROWSE_STATE_FILE naming another folder; its own browse.json is accepted', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const before = snapshot(f.root); + const foreign = path.join(f.root, 'other', '.gstack', 'browse.json'); + refused(f, run(f, ['--apply'], { env: { BROWSE_STATE_FILE: foreign } }), before, /BROWSE_STATE_FILE is .*other\/\.gstack\/browse\.json/); + const own = run(f, [], { env: { BROWSE_STATE_FILE: path.join(f.gs, 'browse.json') } }); + expect(own.code).toBe(0); + expect(own.out).toContain('would remove'); + }); + + test('the target is the state root (HOME/.gstack with GSTACK_HOME=HOME/.gstack)', () => { + const f = fixture(); + git(f.home, 'init', '-q'); + const home = path.join(f.home, '.gstack'); + put(path.join(home, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + put(path.join(home, 'projects', 'p', 'learnings.jsonl'), '{}\n', 30 * DAY); + const before = snapshot(f.root); + refused(f, run(f, ['--apply'], { cwd: f.home, env: { HOME: f.home, GSTACK_HOME: home } }), before, /sits inside the gstack state root/); + }); + + test('the target holds the state root', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const inner = path.join(f.gs, 'state'); + fs.mkdirSync(inner); + const before = snapshot(f.root); + refused(f, run(f, ['--apply'], { env: { GSTACK_HOME: inner } }), before, /holds or sits inside the gstack state root/); + }); + }); + + test('a link in tmp/ pointing outside leaves the outside file intact', () => { + const f = fixture(); + const outside = put(path.join(f.root, 'outside', 'codex-precious'), 'precious', 30 * DAY); + fs.symlinkSync(outside, path.join(f.gs, 'tmp', 'codex-link')); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +tmp\/codex-link: is a symlink/); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + expect(fs.readFileSync(outside, 'utf8')).toBe('precious'); + }); + + test('a symlinked tmp/ refuses the tmp group', () => { + const f = fixture(); + fs.rmSync(path.join(f.gs, 'tmp'), { recursive: true }); + const outsideTmp = path.join(f.root, 'outside-tmp'); + put(path.join(outsideTmp, 'codex-err-old'), 'x', 30 * DAY); + fs.symlinkSync(outsideTmp, path.join(f.gs, 'tmp')); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +tmp: tmp\/ is a symlink; the tmp group is refused/); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + + describe('liveness', () => { + const bigLog = (f: Fixture, ageMs = 2 * 60 * 60 * 1000) => put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), ageMs); + + test('a live browse daemon (browse.json names a running pid) skips browse files', () => { + const f = fixture(); + bigLog(f); + put(path.join(f.gs, 'browse.json'), JSON.stringify({ pid: process.pid, port: 1 })); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toContain('skip browse-console.log: the browse daemon (pid ' + process.pid + ', browse.json) is running'); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + + test('a starting daemon (browse.json.lock with a live pid) skips browse files', () => { + const f = fixture(); + bigLog(f); + put(path.join(f.gs, 'browse.json.lock'), process.pid + '\n'); + const r = run(f, ['--apply']); + expect(r.out).toContain('browse.json.lock) is running'); + expect(fs.statSync(path.join(f.gs, 'browse-console.log')).size).toBeGreaterThan(1024 * 1024); + }); + + test('an unreadable browse.json is unknown liveness and skips browse files', () => { + const f = fixture(); + bigLog(f); + put(path.join(f.gs, 'browse.json'), 'not json'); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toContain('browse.json names no pid, so the browse daemon may be running'); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + + test('no process listing is unknown liveness and skips tmp/', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const onlyGit = path.join(f.root, 'only-git'); + fs.mkdirSync(onlyGit); + const gitBin = spawnSync('bash', ['-c', 'command -v git'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + fs.symlinkSync(gitBin, path.join(onlyGit, 'git')); + const before = snapshot(path.join(f.repo)); + const r = run(f, ['--apply'], { env: { PATH: onlyGit } }); + expect(r.code).toBe(0); + expect(r.out).toContain('skip tmp/codex-err-old: cannot list processes to tell whether codex is running'); + expect(changedKeys(before, snapshot(f.repo))).toEqual([]); + }); + + test('a running codex skips tmp/', async () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const fake = path.join(f.root, 'fakebin', 'codex'); + put(fake, '#!/bin/sh\nsleep 30\n', 0); + fs.chmodSync(fake, 0o755); + const child = spawn(fake, [], { stdio: 'ignore' }); + try { + await Bun.sleep(300); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +tmp\/codex-err-old: codex is running/); + expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(true); + } finally { + child.kill('SIGKILL'); + } + }); + + test('a file modified within the last hour is skipped', () => { + const f = fixture(); + bigLog(f, 0); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.out).toContain('skip browse-console.log: modified within the last hour'); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + }); + + test('the in-place cut keeps the inode and starts on a line boundary', () => { + const f = fixture(); + const text = logText(1.5 * 1024 * 1024); + const log = put(path.join(f.gs, 'browse-daemon.log'), text, 2 * 60 * 60 * 1000); + const ino = fs.statSync(log).ino; + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + const kept = fs.readFileSync(log, 'utf8'); + expect(fs.statSync(log).ino).toBe(ino); + expect(kept.length).toBeLessThanOrEqual(256 * 1024); + expect(kept.length).toBeGreaterThan(255 * 1024 - 200); + expect(text.endsWith(kept)).toBe(true); + expect(text[text.length - kept.length - 1]).toBe('\n'); + expect(kept.startsWith('line ')).toBe(true); + expect(r.out).toMatch(new RegExp('^cut +browse-daemon\\.log \\(freed ' + (text.length - kept.length) + ' bytes\\)', 'm')); + }); + + test('--audit-days keeps newer and unparseable lines byte for byte; a malformed file is left alone', () => { + const f = fixture(); + const iso = (ageMs: number) => new Date(Date.now() - ageMs).toISOString(); + const oldLine = '{"ts":"' + iso(40 * DAY) + '","cmd":"goto"}\n'; + const keepLines = [ + '{"ts":"' + iso(DAY) + '","cmd":"text","args":"caf\u00e9"}\n', + '{"cmd":"no-ts"}\n', + '{"ts":"not a date","cmd":"bad-ts"}\n', + '\n', + '{"ts":"' + iso(2 * DAY) + '", "cmd":"spaced"}', + ]; + const audit = put(path.join(f.gs, 'browse-audit.jsonl'), oldLine + keepLines[0] + oldLine + keepLines.slice(1).join(''), 2 * 60 * 60 * 1000); + const ino = fs.statSync(audit).ino; + const untouched = run(f, ['--apply']); + expect(untouched.out).not.toContain('browse-audit.jsonl'); + const r = run(f, ['--apply', '--audit-days', '30']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/^trimmed +browse-audit\.jsonl \(freed \d+ bytes\): audit entries older than 30 days/m); + expect(fs.readFileSync(audit, 'utf8')).toBe(keepLines.join('')); + expect(fs.statSync(audit).ino).toBe(ino); + + const g = fixture(); + put(path.join(g.gs, 'browse-audit.jsonl'), oldLine + '{"ts": truncated\n', 2 * 60 * 60 * 1000); + const before = snapshot(g.root); + const bad = run(g, ['--apply', '--audit-days=30']); + expect(bad.code).toBe(0); + expect(bad.out).toContain('skip browse-audit.jsonl: line 2 is not JSON, so the file was left as it is'); + expect(changedKeys(before, snapshot(g.root))).toEqual([]); + }); + + describe('exit codes', () => { + test('2: an unknown flag or a bad --audit-days quotes the value; the last line is usage', () => { + const f = fixture(); + for (const [args, quoted] of [[['--force'], '"--force"'], [['--audit-days', 'abc'], '"abc"'], [['--audit-days=0'], '"0"'], [['--audit-days'], '""']] as const) { + const r = run(f, [...args]); + expect(r.code).toBe(2); + expect(r.out).toBe(''); + const lines = r.err.trimEnd().split('\n'); + expect(lines[0]).toContain(quoted); + expect(lines[0]).toEndWith('(GSTACK_CLEAN_USAGE)'); + expect(lines.at(-1)).toBe('usage: gstack-clean [--apply] [--audit-days N] [--json]'); + } + }); + + test('1: a read-only folder and a read-only file give FAILED lines; rerunning is safe', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const log = put(path.join(f.gs, 'browse-dialog.log'), logText(1.1 * 1024 * 1024), 2 * 60 * 60 * 1000); + fs.chmodSync(log, 0o444); + fs.chmodSync(path.join(f.gs, 'tmp'), 0o555); + try { + const r = run(f, ['--apply']); + expect(r.code).toBe(1); + expect(r.out).toContain('FAILED ' + path.join(f.gs, 'tmp', 'codex-err-old') + ' EACCES'); + expect(r.out).toContain('FAILED ' + log + ' EACCES'); + expect(r.err).toContain('rerunning is safe'); + expect(r.err.trim()).toEndWith('(GSTACK_CLEAN_PARTIAL)'); + } finally { + fs.chmodSync(path.join(f.gs, 'tmp'), 0o755); + } + expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(true); + }); + + test('--help lists the fixed thresholds and exits 0', () => { + const f = fixture(); + const r = run(f, ['--help']); + expect(r.code).toBe(0); + for (const needle of ['older than 7 days', 'over 1 MB', 'last 256 KB', '--audit-days N', 'in the last hour']) expect(r.out).toContain(needle); + }); + }); + + test('--json prints one versioned object with the same content', () => { + const f = fixture(); + populate(f); + const dry = run(f, ['--json']); + expect(dry.code).toBe(0); + const plan = JSON.parse(dry.out); + expect(Object.keys(plan)).toEqual(['schema_version', 'tool', 'mode', 'status', 'target', 'chosen_by', 'audit_days', 'actions', 'skips', 'total', 'error', 'exit_code']); + expect(plan).toMatchObject({ schema_version: 1, tool: 'gstack-clean', mode: 'dry-run', status: 'planned', target: f.gs, audit_days: null, error: null, exit_code: 0 }); + expect(plan.actions.map((a: any) => [a.rel, a.group, a.action, a.status])).toEqual([ + ['tmp/codex-err-old1', 'tmp', 'remove', 'planned'], + ['tmp/codex-resp-old2', 'tmp', 'remove', 'planned'], + ['browse-console.log', 'browse', 'cut', 'planned'], + ]); + expect(plan.skips).toEqual([{ path: path.join(f.gs, 'tmp', 'codex-dir'), rel: 'tmp/codex-dir', group: 'tmp', reason: 'is not a regular file; left alone' }]); + expect(plan.total).toEqual({ actions: 3, bytes: plan.actions.reduce((n: number, a: any) => n + a.bytes, 0), failed: 0, skipped: 1 }); + const applied = JSON.parse(run(f, ['--json', '--apply']).out); + expect(applied).toMatchObject({ mode: 'apply', status: 'done', exit_code: 0 }); + expect(applied.actions.map((a: any) => a.status)).toEqual(['done', 'done', 'done']); + expect(JSON.parse(run(f, ['--json']).out)).toMatchObject({ status: 'nothing_to_do', actions: [] }); + const refused = run(f, ['--json'], { cwd: f.root }); + expect(refused.code).toBe(3); + expect(JSON.parse(refused.out)).toMatchObject({ schema_version: 1, status: 'refused', error: { code: 'GSTACK_CLEAN_TARGET_REFUSED' }, exit_code: 3 }); + }); +});