From 73833961d7ae8b59cc2bb93eb9056349673df671 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 22:19:47 +0000 Subject: [PATCH 1/3] 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> --- browse/src/config.ts | 57 +++++++++++++---------- browse/test/config.test.ts | 92 ++++++++++++++++++++------------------ test/pr-shots.test.ts | 21 +++++++++ 3 files changed, 103 insertions(+), 67 deletions(-) diff --git a/browse/src/config.ts b/browse/src/config.ts index 89cd21418..17f29dd82 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -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 } } diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index 53a2081a6..f93a802cf 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -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 }); }); diff --git a/test/pr-shots.test.ts b/test/pr-shots.test.ts index d142521e0..e81268c92 100644 --- a/test/pr-shots.test.ts +++ b/test/pr-shots.test.ts @@ -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('
Before/after as text: The record renders as a table'); }); + // #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 }); + 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); From 572480ac79c55dfadd7efc52cd4b1e73594c5a6f Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 22:21:07 +0000 Subject: [PATCH 2/3] docs(changelog): pr-shots no longer edits .gitignore (#3560) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d9de8fe..1ba3c679c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +**`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.298.0] - 2026-10-28 **Dia is gone from gstack.** `/setup-browser-cookies` and `cookie-import-browser` no longer list Dia as a cookie source, and the Dia qualification harness is deleted. That harness was a set of macOS campaigns that never qualified a native import, and its `dia-launch-comparison` free test was failing the merge train on Ubicloud runners. Cookie import from Comet, Chrome, Chromium, Arc, Brave, Edge, Opera and Opera GX is unchanged. From f1ca48f7afe18ceab0e2c6307a36e8b3190e3a3e Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 22:42:31 +0000 Subject: [PATCH 3/3] 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> --- test/pr-shots.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pr-shots.test.ts b/test/pr-shots.test.ts index e81268c92..90d2459ec 100644 --- a/test/pr-shots.test.ts +++ b/test/pr-shots.test.ts @@ -134,7 +134,7 @@ describe('gstack-pr-shots CLI', () => { 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 }); + 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');