v1.91.303.0 fix(browse): pr-shots no longer edits the tracked .gitignore mid-/ship (#3560) (#3562)

* fix(browse): record .gstack/ in info/exclude, never the tracked .gitignore (#3560)

The browse daemon's state-dir setup appended .gstack/ to the worktree's .gitignore when the repository tracks files under .gstack/, so gstack-pr-shots changed the tree after pregate and /ship's receipt refused with PREGATE_STALE. The ignore line now goes to the repository's info/exclude (git rev-parse --git-path), which is outside the tree.

Fixes #3560

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): pr-shots no longer edits .gitignore (#3560)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pr-shots): clear BROWSE_STATE_FILE so the #3560 case runs under the shard runner

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-29 02:20:13 +00:00
1 parent 8b4f5aaf5c
commit 128dd06f09
7 files changed
+117 -70

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog
## [1.91.303.0] - 2026-10-29
**`gstack-pr-shots` no longer edits your `.gitignore` in the middle of /ship, so the pregate stays fresh and the ship receipt gets written.** When a repository tracks files under `.gstack/` (`pregate.json`, `ship-policy.json`), git does not read `.gstack/` as ignored, and the browse daemon that `gstack-pr-shots` starts appended `.gstack/` to the tracked `.gitignore`. The tree changed after pregate and the receipt writer refused with PREGATE_STALE (#3560, seen on gstack-evals#13).
### Fixed
- **`browse/src/config.ts`**: the browse state dir still writes its own `.gstack/.gitignore` (`*`), and when git does not already ignore `.gstack/` the line goes to the repository's `info/exclude` (resolved with `git rev-parse --git-path`, so linked worktrees share it). The project's `.gitignore` is never written. Outside a git repository nothing is written beyond the state dir.
### Tests
- `test/pr-shots.test.ts`: a repository that tracks `.gstack/pregate.json` runs `gstack-pr-shots` with a browse stub that runs the daemon's real state-dir setup; the `gstack-wtree` fingerprint and the `.gitignore` are unchanged afterwards. It failed before this change.
- `browse/test/config.test.ts`: the cases that pinned the `.gitignore` append now pin `info/exclude`: written once, no duplicate, a missing trailing newline added, `info/` created when absent, a warning in `browse-server.log` when it cannot be written, and nothing written outside a repository.
## [1.91.302.0] - 2026-10-29
**Three flaky gate cases from the 2026-10-28 daily flaky pass are repaired: shared-libs-review-index-flags, ship-exploratory-late-input (with ship-exploratory-plan-checks, which failed the same way), and the real-Chromium cookie-auth positive case.** Each red was diagnosed from its captured run before the fix, and each repair has a free test that fails without it.
+1 -1
View File
@@ -1 +1 @@
1.91.302.0
1.91.303.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.302.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.303.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+34 -23
View File
@@ -100,6 +100,21 @@ function isIgnoredByGit(projectDir: string, relPath: string): boolean {
}
}
function gitExcludePath(projectDir: string): string | null {
try {
const proc = Bun.spawnSync(['git', 'rev-parse', '--git-path', 'info/exclude'], {
windowsHide: true,
cwd: projectDir, stdout: 'pipe', stderr: 'pipe',
timeout: 2_000,
});
if (proc.exitCode !== 0) return null;
const rel = proc.stdout.toString().trim();
return rel ? path.resolve(projectDir, rel) : null;
} catch {
return null;
}
}
/**
* Create the .gstack/ state directory if it doesn't exist.
* Throws with a clear message on permission errors.
@@ -119,40 +134,36 @@ export function ensureStateDir(config: BrowseConfig): void {
// Load-bearing guard: a self-contained ignore INSIDE the state dir so its
// contents can NEVER be `git add`-ed, regardless of the project's own
// .gitignore (which may be absent, or the append below may silently fail).
// The state dir holds session-state.json (live cookies + localStorage/
// sessionStorage tokens) and browse-network.log / browse-audit.jsonl
// (captured request headers can carry bearer tokens). Written unconditionally,
// synchronously, before return — the project-.gitignore dance below is now
// redundant safety, kept so `.gstack/` still reads as ignored in git status.
// ignore rules. The state dir holds session-state.json (live cookies +
// localStorage/sessionStorage tokens) and browse-network.log /
// browse-audit.jsonl (captured request headers can carry bearer tokens).
// Written unconditionally, synchronously, before return.
try {
fs.writeFileSync(path.join(config.stateDir, '.gitignore'), '*\n');
} catch {
// Best-effort; the project-.gitignore path below is the fallback.
// Best-effort; the repository's info/exclude below is the fallback.
}
// Ensure .gstack/ is in the project's .gitignore
// First, check if git already ignores .gstack/ (via global excludes, .git/info/exclude, or parent .gitignore)
// Ensure git reads .gstack/ as ignored through the repository's own
// info/exclude, never the project's .gitignore: that file is tracked, and
// editing it mid-/ship changes the tree after pregate (PREGATE_STALE, #3560).
if (isIgnoredByGit(config.projectDir, '.gstack/')) return;
const gitignorePath = path.join(config.projectDir, '.gitignore');
const excludePath = gitExcludePath(config.projectDir);
if (!excludePath) return;
try {
const content = fs.readFileSync(gitignorePath, 'utf-8');
const content = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf-8') : '';
if (!content.match(/^\.gstack\/?$/m)) {
const separator = content.endsWith('\n') ? '' : '\n';
fs.appendFileSync(gitignorePath, `${separator}.gstack/\n`);
const separator = content === '' || content.endsWith('\n') ? '' : '\n';
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
fs.appendFileSync(excludePath, `${separator}.gstack/\n`);
}
} catch (err: any) {
if (err.code !== 'ENOENT') {
// Write warning to server log (visible even in daemon mode)
const logPath = path.join(config.stateDir, 'browse-server.log');
try {
fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update .gitignore at ${gitignorePath}: ${err.message}\n`);
} catch {
// stateDir write failed too — nothing more we can do
}
const logPath = path.join(config.stateDir, 'browse-server.log');
try {
fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update ${excludePath}: ${err.message}\n`);
} catch {
// stateDir write failed too — nothing more we can do
}
// ENOENT (no .gitignore) — skip silently
}
}
+48 -44
View File
@@ -95,68 +95,72 @@ describe('config', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('adds .gstack/ to .gitignore if not present', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
// A repository that tracks files under .gstack/ (pregate.json, ship-policy.json) is the case
// where git does not already read .gstack/ as ignored, so the ignore line must land somewhere.
const trackedGstackRepo = (prefix: string) => {
const { spawnSync } = require('child_process');
const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
const git = (...args: string[]) => spawnSync('git', args, { cwd: tmpDir, timeout: 30_000, encoding: 'utf-8' });
git('init', '-q');
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/tmp/\n');
fs.mkdirSync(path.join(tmpDir, '.gstack'));
fs.writeFileSync(path.join(tmpDir, '.gstack', 'pregate.json'), '{}\n');
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
const exclude = path.resolve(tmpDir, git('rev-parse', '--git-path', 'info/exclude').stdout.trim());
return { tmpDir, git, exclude };
};
test('records .gstack/ in .git/info/exclude and leaves the tracked .gitignore unchanged (#3560)', () => {
const { tmpDir, git, exclude } = trackedGstackRepo('browse-exclude-test-');
fs.writeFileSync(exclude, '# local\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toContain('.gstack/');
expect(content).toBe('node_modules/\n.gstack/\n');
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n');
expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n.gstack/\n');
expect(git('status', '--porcelain').stdout).toBe('');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('does not duplicate .gstack/ in .gitignore', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
test('does not duplicate .gstack/ in info/exclude, adds a missing trailing newline, and creates info/ when absent', () => {
const dup = trackedGstackRepo('browse-exclude-dup-');
fs.writeFileSync(dup.exclude, 'build/');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(dup.tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules/\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
ensureStateDir(config);
expect(fs.readFileSync(dup.exclude, 'utf-8')).toBe('build/\n.gstack/\n');
fs.rmSync(dup.tmpDir, { recursive: true, force: true });
const bare = trackedGstackRepo('browse-exclude-noinfo-');
fs.rmSync(path.dirname(bare.exclude), { recursive: true, force: true });
ensureStateDir(resolveConfig({ BROWSE_STATE_FILE: path.join(bare.tmpDir, '.gstack', 'browse.json') }));
expect(fs.readFileSync(bare.exclude, 'utf-8')).toBe('.gstack/\n');
fs.rmSync(bare.tmpDir, { recursive: true, force: true });
});
test('handles .gitignore without trailing newline', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('logs warning to browse-server.log on non-ENOENT gitignore error', () => {
test('logs warning to browse-server.log when info/exclude cannot be written', () => {
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Create a read-only .gitignore (no .gstack/ entry → would try to append)
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o444);
const { tmpDir, exclude } = trackedGstackRepo('browse-exclude-ro-');
fs.writeFileSync(exclude, '# local\n');
fs.chmodSync(exclude, 0o444);
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config); // should not throw
// Verify warning was written to server log
const logPath = path.join(config.stateDir, 'browse-server.log');
expect(fs.existsSync(logPath)).toBe(true);
const logContent = fs.readFileSync(logPath, 'utf-8');
expect(logContent).toContain('Warning: could not update .gitignore');
// .gitignore should remain unchanged
const gitignoreContent = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(gitignoreContent).toBe('node_modules/\n');
// Cleanup
fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o644);
const logContent = fs.readFileSync(path.join(config.stateDir, 'browse-server.log'), 'utf-8');
expect(logContent).toContain(`Warning: could not update ${exclude}`);
expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n');
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n');
fs.chmodSync(exclude, 0o644);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('skips if no .gitignore exists', () => {
test('outside a git repository nothing is written beyond the state dir', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false);
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
expect(fs.readdirSync(tmpDir).sort()).toEqual(['.gitignore', '.gstack']);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.302",
"version": "1.91.303",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+21
View File
@@ -10,6 +10,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildPage, cleanText, diffLines, hunks, pathSides, resolveCommit } from '../lib/pr-shots';
import { wtreeFingerprint } from '../lib/script-invocation';
const ROOT = path.resolve(import.meta.dir, '..');
const dirs: string[] = [];
@@ -120,6 +121,26 @@ describe('gstack-pr-shots CLI', () => {
expect(text).toStartWith('<details><summary>Before/after as text: The record renders as a table</summary>');
});
// #3560: pr-shots starts the browse daemon from the worktree; the daemon's state-dir setup
// appended .gstack/ to the tracked .gitignore in a repository that tracks .gstack/ config, so the
// tree changed after pregate and the /ship receipt refused with PREGATE_STALE. The stub browse
// runs the daemon's real state-dir setup (browse/src/config.ts), then fails like a browser-less host.
test.skipIf(process.platform === 'win32')('the working-tree fingerprint is unchanged after a run that starts browse (#3560)', () => {
const { dir } = repo({ '.gitignore': 'node_modules/\n.gstack/tmp/\n', '.gstack/pregate.json': '{}\n', 'doc.md': '# Doc\n\nold\n' });
fs.writeFileSync(path.join(dir, 'doc.md'), '# Doc\n\nnew\n');
const stubDir = tmp('shots-browse-');
fs.writeFileSync(path.join(stubDir, 'state-dir.ts'), `import { ensureStateDir, resolveConfig } from ${JSON.stringify(path.join(ROOT, 'browse/src/config.ts'))};\nensureStateDir(resolveConfig());\nprocess.exit(1);\n`);
const browse = path.join(stubDir, 'browse');
fs.writeFileSync(browse, `#!/usr/bin/env bash\nexec ${JSON.stringify(process.execPath)} --no-env-file --config=/dev/null ${JSON.stringify(path.join(stubDir, 'state-dir.ts'))}\n`, { mode: 0o755 });
const before = wtreeFingerprint({ cwd: dir });
expect('tree' in before).toBe(true);
const r = cli(['--path', 'doc.md', '--caption', 'c'], dir, { GSTACK_STATE_ROOT: tmp('shots-state-'), GSTACK_BROWSE_BIN: browse, BROWSE_STATE_FILE: '' });
expect(r.out).toMatch(/^SHOT_PNG: failed /m);
expect(fs.existsSync(path.join(dir, '.gstack', '.gitignore'))).toBe(true);
expect(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')).toBe('node_modules/\n.gstack/tmp/\n');
expect(wtreeFingerprint({ cwd: dir })).toEqual(before);
});
test('a secret in a capture is refused before anything is written, without echoing it', () => {
const { dir } = repo({ 'x.txt': 'x' });
const secret = 'ghp_' + 'b'.repeat(36);