mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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:
1 parent
8b4f5aaf5c
commit
128dd06f09
7 files changed
+117
-70
No files matched your search
@@ -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,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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user