diff --git a/CHANGELOG.md b/CHANGELOG.md index d78f0464a..4f8090db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/VERSION b/VERSION index 79fda26f5..184cc85e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.302.0 +1.91.303.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 0b685966d..0afaf21a3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, 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/package.json b/package.json index 1485ec75c..75e44f893 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/pr-shots.test.ts b/test/pr-shots.test.ts index d142521e0..90d2459ec 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, 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);