mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
Merge commit '28db5dce30512f8e70df01f8c591d71196b2e662' into HEAD
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # bin/gstack-doctor-components.sh # lib/result-codes.ts # scripts/pr-dependencies.ts # test/gstack-doctor.test.ts # test/plan-tune-cathedral.test.ts # test/timeline-stop-hook-slug.test.ts
This commit is contained in:
commit
8354521e55
17 files changed
+1193
-4
No files matched your search
+13
@@ -1536,6 +1536,19 @@ capacity each), flushed to disk asynchronously via `Bun.write()`:
|
||||
- Network: `.gstack/browse-network.log`
|
||||
- Dialog: `.gstack/browse-dialog.log`
|
||||
|
||||
The console, network, dialog and startup-error logs start empty at each daemon
|
||||
start and can grow while one daemon runs; `browse-daemon.log` and the
|
||||
`browse-audit.jsonl` command audit build up across restarts, 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.
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## 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.
|
||||
- **Codex scratch lands at the git toplevel.** `bin/gstack-paths` falls back to the relative `.gstack/tmp` when `TMPDIR` and `TMP` are unset, so a /codex run from a subfolder leaves scratch in `<subfolder>/.gstack/tmp`, where `gstack-clean` does not look. Make the fallback `$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp`. **Effort:** S. **Priority:** P3.
|
||||
- **`gstack-clean` reclaims `browse-daemon.log.1`.** The browse CLI renames `browse-daemon.log` to `.1` past 10 MB and nothing ever removes the copy. Add it to the browse group (remove when older than an hour and the daemon is not running). **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.
|
||||
|
||||
### P2/P3: Gemini design provider follow-ups (filed 2026-10-31, from the #990 build)
|
||||
|
||||
- **Run the live Gemini check once a key exists** — every Gemini request and response shape in `design/src/provider.ts` follows Google's docs and stubbed fixtures; nobody has sent one to the live API. `GEMINI_API_KEY=... bun run design/scripts/live-gemini-check.ts` (about $0.04) settles the image body, the thinking-image pick and the OpenAI-compatible vision endpoint. A JPEG answer is refused today. **Effort:** S. **Priority:** P2.
|
||||
|
||||
Executable
+12
@@ -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)));
|
||||
@@ -388,6 +388,10 @@ gstack_result_anchor() {
|
||||
BROWSE_DEVICE_UNKNOWN) printf '%s' 'browse-device-unknown' ;;
|
||||
BROWSE_DEVICE_CDP_FAILED) printf '%s' 'browse-device-cdp-failed' ;;
|
||||
BROWSE_TOUCH_NO_DEVICE) printf '%s' 'browse-touch-no-device' ;;
|
||||
GSTACK_CLEAN_USAGE) printf '%s' 'gstack-clean-usage' ;;
|
||||
GSTACK_CLEAN_TARGET_REFUSED) printf '%s' 'gstack-clean-target-refused' ;;
|
||||
GSTACK_CLEAN_PARTIAL) printf '%s' 'gstack-clean-partial' ;;
|
||||
GSTACK_CLEAN_INTERNAL) printf '%s' 'gstack-clean-internal' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -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 `<git toplevel>/.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, logs with more than one hard link, files it cannot read 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. Right before each change it checks that the folder is still the one it verified and that the file still has the size, time and inode it measured, and it stops that change if either moved. `--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, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, an unlistable `tmp/` skips only its group, `--json` reaches a pipe whole with hundreds of actions and a reader that closes early is not an error, 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.
|
||||
@@ -14,6 +14,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
||||
| `gstack-memorable` | **Memorable recall bridge (opt-in, third party, Claude Code only)** — connects Claude Code to the external [Memorable](https://memorable.sh) CLI *through gstack* instead of the vendor's own installer, so the hook gets gstack's guarantees: an explicit consent key (`memorable_recall`, off by default, listed by `gstack-egress grants`), a fail-closed egress receipt for every prompt handed over (`gstack-egress list --sink memorable-recall`), a HIGH-tier secret pre-scan, a trust envelope and 8 KiB cap on whatever comes back, an allowlisted environment and process-group containment for the vendor process, and clean removal. `enable` registers the hook at the stable install with a 5 s timeout and never runs the vendor's own consent command; `disable` revokes the gate first and removes the entry by identity even after Claude Code strips the tag; `status` is read-only. gstack never installs Memorable, and what its binary sends is the vendor's claim, not gstack's. Not available on Windows yet. [Full guide](memorable-workflow-memory.md). |
|
||||
| `gstack-decision-trail` | **Decision trail** — append one row (ts, phase, decision, why, evidence, result) to a run's TSV trail so a reviewer reads what was chosen and why without the transcript. Header once, append-only, cells flattened to one line, and a cell starting with `=`, `+`, `-` or `@` quoted so a spreadsheet cannot run it. `start <file> <run-id>` opens a run's stretch of a shared trail (idempotent; `start` is reserved as a phase name), and `--run <id>` refuses an append until that start row is the newest. Ported from pstack's `log.sh` (MIT, NOTICE.md). [Rule](unattended.md#the-decision-trail). |
|
||||
| `gstack-worktree-audit` | **Worktree audit** — read-only table of every git worktree beside the main one, largest first: size, age, merged into the base or not, `clean`/`clean,ignored:<n>`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh` (fork PRs ignored), the newest Claude Code chat in it, and a bucket (`prunable` for an unlocked registered worktree whose directory is gone, `review` for a locked or `broken` one, `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe` only for a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed, else `review`). `-b origin/<branch>` sets the base, `--no-fetch` skips the best-effort fetch of the base ref (its one write). Never deletes. Ported from pstack's `worktree-audit.sh` (MIT, NOTICE.md). [Guide](CONTRIBUTOR_REFERENCE.md#auditing-conductor-worktrees). |
|
||||
| `gstack-clean` | **Per-repo `.gstack/` cleanup** — run inside a repository to see what it would reclaim from that repository's `.gstack/` folder; `--apply` does it. It removes `tmp/codex-*` scratch files older than 7 days, cuts the browse logs (`browse-console.log`, `browse-network.log`, `browse-dialog.log`, `browse-daemon.log`, `browse-startup-error.log`) to their last 256 KB once they pass 1 MB, and trims `browse-audit.jsonl` only with `--audit-days N`. It never touches `browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/` or `browse-states/`, skips browse files while the daemon runs and `tmp/` while codex runs, and 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. `--json` prints one versioned object. Exit 0 done, 1 an action failed (rerunning is safe), 2 usage, 3 refused. [Error codes](troubleshooting.md#gstack-clean-usage). |
|
||||
| `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. |
|
||||
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs <token>` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. |
|
||||
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). |
|
||||
|
||||
@@ -6338,3 +6338,27 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul
|
||||
### `RENDER_DEST_NOT_OWNED: <dir> 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 `<repo>/.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.
|
||||
|
||||
<a id="gstack-clean-usage"></a>
|
||||
### `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.
|
||||
|
||||
<a id="gstack-clean-target-refused"></a>
|
||||
### `gstack-clean: the folder to clean is not a safe per-repo .gstack folder … (GSTACK_CLEAN_TARGET_REFUSED)`
|
||||
|
||||
**Meaning.** gstack-clean only cleans `<git toplevel>/.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.
|
||||
|
||||
<a id="gstack-clean-partial"></a>
|
||||
### `FAILED <path> <errno>` … `(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.
|
||||
|
||||
<a id="gstack-clean-internal"></a>
|
||||
### `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.
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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
|
||||
* `<git toplevel>/.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;
|
||||
const LIVENESS_RECHECK_MS = 2000;
|
||||
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: <git toplevel>/.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 Seen { dev: number; ino: number; size: number; mtimeMs: number }
|
||||
interface Action extends Candidate { bytes: number; reason: string; keep?: Buffer; seen: Seen }
|
||||
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;
|
||||
let real: string;
|
||||
try { real = fs.realpathSync(c.file); } catch (e: any) {
|
||||
return e?.code === 'ENOENT' ? null : { ...c, reason: `cannot resolve (${e?.code ?? 'error'})` };
|
||||
}
|
||||
if (path.dirname(real) !== 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 };
|
||||
const seen: Seen = { dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs };
|
||||
if (c.kind === 'remove') return { ...c, seen, bytes: st.size, reason: 'codex scratch older than 7 days' };
|
||||
if (st.nlink !== 1) return { ...c, reason: `has ${st.nlink} hard links, so cutting it would change a file outside this folder; left alone` };
|
||||
let content: Buffer;
|
||||
try { content = c.kind === 'cut' ? readTail(c.file, st.size) : fs.readFileSync(c.file); } catch (e: any) {
|
||||
return { ...c, reason: `cannot read (${e?.code ?? 'error'}); left alone` };
|
||||
}
|
||||
if (c.kind === 'cut') {
|
||||
const tail = content;
|
||||
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, seen, keep, bytes: st.size - keep.length, reason: 'over 1 MB; keeps the last 256 KB from a line start' };
|
||||
}
|
||||
let keep: Buffer | string;
|
||||
try { keep = auditKeep(content, now - opts.auditDays! * DAY_MS); } catch (e: any) {
|
||||
return { ...c, reason: `cannot read (${e?.code ?? e?.name ?? 'error'}); left alone` };
|
||||
}
|
||||
if (typeof keep === 'string') return { ...c, reason: keep };
|
||||
if (keep.length === st.size) return null;
|
||||
return { ...c, seen, 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) {
|
||||
let names: string[] = [];
|
||||
try { names = fs.readdirSync(tmp).sort(); } catch (e: any) {
|
||||
skips.push({ file: tmp, group: 'tmp', reason: `cannot list (${e?.code ?? 'error'}); the tmp group is skipped` });
|
||||
}
|
||||
for (const name of names) 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<Group, string | null> = {
|
||||
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 };
|
||||
}
|
||||
|
||||
const changed = () => Object.assign(new Error('the file changed after it was checked; rerunning is safe'), { code: 'ECHANGED' });
|
||||
const same = (st: fs.Stats, seen: Seen) => st.dev === seen.dev && st.ino === seen.ino && st.size === seen.size && st.mtimeMs === seen.mtimeMs;
|
||||
|
||||
function writeFully(fd: number, buf: Buffer, position: number): void {
|
||||
for (let off = 0; off < buf.length;) {
|
||||
const n = fs.writeSync(fd, buf, off, buf.length - off, position + off);
|
||||
if (n <= 0) throw Object.assign(new Error('short write'), { code: 'EIO' });
|
||||
off += n;
|
||||
}
|
||||
}
|
||||
|
||||
/** The parent folder is still the verified real folder, so a swapped link cannot redirect the change. */
|
||||
function parentIntact(file: string, dir: string): boolean {
|
||||
const parent = path.dirname(file);
|
||||
try { return !fs.lstatSync(parent).isSymbolicLink() && fs.realpathSync(parent) === dir; } catch { return false; }
|
||||
}
|
||||
|
||||
/** A cut or trim keeps the inode, so O_APPEND writers go on; refused unless the file is exactly what evaluate saw. */
|
||||
function cutInPlace(file: string, keep: Buffer, seen: Seen): void {
|
||||
const fd = fs.openSync(file, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0));
|
||||
try {
|
||||
const st = fs.fstatSync(fd);
|
||||
if (st.nlink !== 1 || !same(st, seen)) throw changed();
|
||||
writeFully(fd, keep, 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[] = [];
|
||||
const asked: Partial<Record<Group, { at: number; busy: string | null }>> = {};
|
||||
const busyNow = (group: Group): string | null => {
|
||||
const last = asked[group];
|
||||
if (last && Date.now() - last.at < LIVENESS_RECHECK_MS) return last.busy;
|
||||
const busy = busyFor(group, target);
|
||||
asked[group] = { at: Date.now(), busy };
|
||||
return busy;
|
||||
};
|
||||
for (const p of planned) {
|
||||
const fresh = evaluate({ file: p.file, group: p.group, kind: p.kind }, dirOf(p, target), busyNow(p.group), opts, Date.now());
|
||||
if (!fresh) continue;
|
||||
if (!('bytes' in fresh)) { skips.push(fresh); continue; }
|
||||
try {
|
||||
if (!parentIntact(fresh.file, dirOf(fresh, target))) throw changed();
|
||||
if (fresh.kind === 'remove') {
|
||||
if (!same(fs.lstatSync(fresh.file), fresh.seen)) throw changed();
|
||||
fs.unlinkSync(fresh.file);
|
||||
} else cutInPlace(fresh.file, fresh.keep!, fresh.seen);
|
||||
done.push({ action: fresh });
|
||||
} catch (e: any) {
|
||||
done.push({ action: fresh, errno: e?.code ?? 'UNKNOWN' });
|
||||
}
|
||||
}
|
||||
return { done, skips };
|
||||
}
|
||||
|
||||
const VERB: Record<Kind, [string, string]> = { 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, chosen_by: 'the .gstack folder at the git toplevel (missing)', audit_days: opts.auditDays, 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]);
|
||||
}
|
||||
|
||||
/** Synchronous, so a pipe gets every byte before the bin calls process.exit. */
|
||||
/** A reader that went away (EPIPE) ends the output quietly; the exit code still reports what was done. */
|
||||
function writeAll(fd: number, text: string): void {
|
||||
const buf = Buffer.from(text, 'utf8');
|
||||
for (let off = 0; off < buf.length;) {
|
||||
try { off += fs.writeSync(fd, buf, off); } catch (e: any) {
|
||||
if (e?.code === 'EPIPE') return;
|
||||
if (e?.code !== 'EAGAIN') throw e;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 => writeAll(1, `${line}\n`), err: line => writeAll(2, `${line}\n`) };
|
||||
try {
|
||||
return run(argv, env, cwd, io);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
try { io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_INTERNAL', `${err.name}: ${err.message}`.slice(0, 300))); } catch { /* stderr is gone too */ }
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -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<string, ResultCode>;
|
||||
@@ -17,6 +17,7 @@ import { EVAL_RESULT_CODES } from './result-codes-eval';
|
||||
import { PLAN_RESULT_CODES } from './result-codes-plan';
|
||||
import { AUTOPLAN_RESULT_CODES } from './result-codes-autoplan';
|
||||
import { BROWSE_RESULT_CODES } from './result-codes-browse';
|
||||
import { CLEAN_RESULT_CODES } from './result-codes-clean';
|
||||
|
||||
export interface ResultCode {
|
||||
/** Stable `<a id>` in docs/troubleshooting.md. */
|
||||
@@ -768,6 +769,7 @@ export const RESULT_CODES = {
|
||||
...PLAN_RESULT_CODES,
|
||||
...AUTOPLAN_RESULT_CODES,
|
||||
...BROWSE_RESULT_CODES,
|
||||
...CLEAN_RESULT_CODES,
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
|
||||
export type ResultCodeName = keyof typeof RESULT_CODES;
|
||||
|
||||
@@ -89,7 +89,7 @@ export const SELECTION_DATA_MODULES: readonly string[] = [
|
||||
// The build entry list: every compiled binary, which skills reach through their `--outfile` path instead.
|
||||
'scripts/build.sh',
|
||||
// The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes.
|
||||
'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts',
|
||||
'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', 'lib/result-codes-clean.ts',
|
||||
// The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case).
|
||||
'test/helpers/exercised-commands.json',
|
||||
];
|
||||
|
||||
+3
-1
@@ -189,7 +189,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" },
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
'tasks-*': 'gstack-tasks rows files (bin/gstack-tasks)',
|
||||
'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)));
|
||||
}
|
||||
|
||||
/** The .gstack/tmp entry names one source text can create, each with its offset. */
|
||||
function namesIn(text: string): Array<[string, number]> {
|
||||
const out: Array<[string, number]> = [];
|
||||
for (const m of text.matchAll(/\.gstack\/tmp\/([^\s"'`)\]}$;:,|&*\\]*)/g)) out.push([m[1]!.split('/')[0]!.replace(/\.+$/, ''), m.index!]);
|
||||
for (const m of text.matchAll(/['"]\.gstack['"],\s*['"]tmp['"](?:,\s*['"]([^'"]+)['"])?/g)) out.push([m[1] ?? '', m.index!]);
|
||||
for (const m of text.matchAll(/\$\{?TMP_ROOT\}?\/([A-Za-z0-9._<>-]+)/g)) out.push([m[1]!, m.index!]);
|
||||
for (const m of text.matchAll(/\{\{FREE_TEXT_FILE(?::([^}]*))?\}\}/g)) {
|
||||
for (const spec of (m[1] ?? 'TEXT_FILE=text').split(':')) out.push([(spec.split('=')[1] || 'text') + '.XXXXXX', m.index!]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every .gstack/tmp entry name a source can create, with where it was seen. */
|
||||
function producers(): Map<string, string> {
|
||||
const found = new Map<string, string>();
|
||||
for (const file of trackedSources()) {
|
||||
const text = fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
for (const [name, index] of namesIn(text)) if (name && !found.has(name)) found.set(name, file + ':' + (text.slice(0, index).split('\n').length));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('gstack-clean producer classification', () => {
|
||||
const found = producers();
|
||||
|
||||
test('a path followed by prose punctuation names the file, not the punctuation', () => {
|
||||
expect(namesIn('r.stop(`.gstack/tmp/x.json: ${message}`)').map(([name]) => name)).toEqual(['x.json']);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* 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);
|
||||
fs.mkdirSync(path.join(root, 'quiet-ps'));
|
||||
fs.writeFileSync(path.join(root, 'quiet-ps', 'ps'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
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!;
|
||||
}
|
||||
|
||||
/** A ps that lists no process, so an unrelated codex elsewhere on the host cannot skip tmp/; realPs keeps the host's. */
|
||||
function quietPsPath(f: Fixture): string {
|
||||
return path.join(f.root, 'quiet-ps') + path.delimiter + (process.env.PATH ?? '');
|
||||
}
|
||||
|
||||
function run(f: Fixture, args: string[], opts: { cwd?: string; env?: Record<string, string | undefined>; realPs?: boolean } = {}) {
|
||||
const env: Record<string, string | undefined> = { ...process.env, HOME: f.home, GSTACK_HOME: f.state, ...(opts.realPs ? {} : { PATH: quietPsPath(f) }), ...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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string>, after: Record<string, string>): 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<typeof run>, before: Record<string, string>, 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 failingPs = path.join(f.root, 'failing-ps');
|
||||
fs.mkdirSync(failingPs);
|
||||
fs.writeFileSync(path.join(failingPs, 'ps'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
const before = snapshot(path.join(f.repo));
|
||||
const r = run(f, ['--apply'], { env: { PATH: failingPs + path.delimiter + (process.env.PATH ?? '') } });
|
||||
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'], { realPs: true });
|
||||
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([]);
|
||||
});
|
||||
|
||||
test('a hard-linked log is left alone, and the other inode keeps every byte', () => {
|
||||
const f = fixture();
|
||||
const outside = put(path.join(f.root, 'outside.txt'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000);
|
||||
fs.linkSync(outside, path.join(f.gs, 'browse-console.log'));
|
||||
const before = fs.readFileSync(outside);
|
||||
const r = run(f, ['--apply']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/skip +browse-console\.log: .*hard link/);
|
||||
expect(fs.readFileSync(outside).equals(before)).toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(process.getuid?.() === 0)('an unlistable tmp/ skips the tmp group and the browse logs are still cut', () => {
|
||||
const f = fixture();
|
||||
const log = put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000);
|
||||
fs.chmodSync(path.join(f.gs, 'tmp'), 0o000);
|
||||
try {
|
||||
const r = run(f, ['--apply']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/skip +tmp: cannot list/);
|
||||
expect(fs.statSync(log).size).toBeLessThanOrEqual(256 * 1024);
|
||||
} finally {
|
||||
fs.chmodSync(path.join(f.gs, 'tmp'), 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
test('a log with no line break in its last 256 KB is left alone', () => {
|
||||
const f = fixture();
|
||||
put(path.join(f.gs, 'browse-network.log'), 'x'.repeat(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000);
|
||||
const before = snapshot(f.root);
|
||||
const r = run(f, ['--apply']);
|
||||
expect(r.out).toMatch(/skip +browse-network\.log: no line break/);
|
||||
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
|
||||
});
|
||||
|
||||
test('an audit line that is not a JSON object leaves the audit file alone', () => {
|
||||
const f = fixture();
|
||||
put(path.join(f.gs, 'browse-audit.jsonl'), '{"ts":"2020-01-01T00:00:00Z"}\n[1]\n', 2 * 60 * 60 * 1000);
|
||||
const before = snapshot(f.root);
|
||||
const r = run(f, ['--apply', '--audit-days', '7']);
|
||||
expect(r.out).toMatch(/skip +browse-audit\.jsonl: line 2 is not a JSON object/);
|
||||
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
|
||||
});
|
||||
|
||||
test.skipIf(process.getuid?.() === 0)('an unreadable log is skipped and the rest still runs', () => {
|
||||
const f = fixture();
|
||||
put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
|
||||
const log = put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000);
|
||||
fs.chmodSync(log, 0o000);
|
||||
try {
|
||||
const r = run(f, ['--apply']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/skip +browse-console\.log: cannot read/);
|
||||
expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(false);
|
||||
} finally {
|
||||
fs.chmodSync(log, 0o644);
|
||||
}
|
||||
});
|
||||
|
||||
test('a reader that closes early does not turn a finished run into an internal error', () => {
|
||||
const f = fixture();
|
||||
for (let i = 0; i < 300; i++) put(path.join(f.gs, 'tmp', 'codex-err-' + String(i).padStart(6, '0')), 'x', 30 * DAY);
|
||||
const env: Record<string, string> = { PATH: quietPsPath(f), HOME: f.home, GSTACK_HOME: f.state };
|
||||
const cmd = [process.execPath, ...bunIsolationArgs(), BIN, '--apply', '--json'].map(a => "'" + a.replace(/'/g, "'\\''") + "'").join(' ') + " 2>'" + path.join(f.root, 'err.txt') + "' | head -c 1 >/dev/null";
|
||||
const r = spawnSync('bash', ['-c', 'set -o pipefail; ' + cmd], { cwd: f.repo, env, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(f.root, 'err.txt'), 'utf8')).not.toContain('GSTACK_CLEAN_INTERNAL');
|
||||
expect(fs.readdirSync(path.join(f.gs, 'tmp')).filter(n => n.startsWith('codex-'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('--json reaches a pipe whole with hundreds of actions', () => {
|
||||
const f = fixture();
|
||||
for (let i = 0; i < 300; i++) put(path.join(f.gs, 'tmp', 'codex-err-' + String(i).padStart(6, '0')), 'x', 30 * DAY);
|
||||
const env: Record<string, string> = { PATH: quietPsPath(f), HOME: f.home, GSTACK_HOME: f.state };
|
||||
const cmd = [process.execPath, ...bunIsolationArgs(), BIN, '--json'].map(a => "'" + a.replace(/'/g, "'\''") + "'").join(' ') + ' | (sleep 1; cat)';
|
||||
const r = spawnSync('sh', ['-c', cmd], { cwd: f.repo, env, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(JSON.parse(r.stdout).actions.length).toBe(300);
|
||||
});
|
||||
|
||||
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.skipIf(process.getuid?.() === 0)('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 });
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ const COPIED = [
|
||||
'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks
|
||||
'bin/gstack-launch-probe.sh',
|
||||
'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts',
|
||||
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', // the windows bash row
|
||||
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', 'lib/result-codes-clean.ts', // the windows bash row
|
||||
];
|
||||
const bases: string[] = [];
|
||||
/** Registers the per-test fixture cleanup; each test file calls it at top level (a hook in this module would bind to the first importing file only). */
|
||||
|
||||
@@ -70,7 +70,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
|
||||
// These imports are resolved relative to the copied install, not the source checkout.
|
||||
const libDir = path.join(workDir, 'lib');
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts', 'result-codes-browse.ts']) {
|
||||
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts', 'result-codes-browse.ts', 'result-codes-clean.ts']) {
|
||||
fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file));
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const HOOK_FILES = [
|
||||
'lib/result-codes-plan.ts',
|
||||
'lib/result-codes-autoplan.ts',
|
||||
'lib/result-codes-browse.ts',
|
||||
'lib/result-codes-clean.ts',
|
||||
];
|
||||
const SLUG = 'stop-hook-slug-test';
|
||||
const BUDGET_MS = 2000;
|
||||
|
||||
Reference in new issue
Block a user