Repository navigation
browse: ensureStateDir appends .gstack/ to a tracked .gitignore even when git already ignores it globally #2385
Description
What happens
ensureStateDir() appends .gstack/ to the git root's .gitignore on every browse-server start where that file doesn't already contain the literal line. It decides whether to append by reading the file's text, so if you already ignore .gstack/ through your global core.excludesFile, you still get the line written into the repo's .gitignore. Revert it and the next daemon start puts it back.
The damage shows up on shared repos. Mine is a Jetpack checkout where .gitignore is tracked and team-owned, so every /browse, /qa, /design-review, /canary, or /scrape leaves a modified tracked file sitting in git status — one git add -A away from riding along in a PR that has nothing to do with gstack.
Where
browse/src/config.ts, in ensureStateDir(). Same on main as of 1.60.1.0:
const content = fs.readFileSync(gitignorePath, 'utf-8');
if (!content.match(/^\.gstack\/?$/m)) {
const separator = content.endsWith('\n') ? '' : '\n';
fs.appendFileSync(gitignorePath, `${separator}.gstack/\n`);
}That text match is the entire test. Git is never asked whether the path is already ignored, so core.excludesFile, .git/info/exclude, and any parent .gitignore are all invisible to it.
Callers: cli.ts (two sites) and server.ts (two sites), so it fires on any daemon start.
Repro
# .gstack/ is already in ~/.config/git/ignore
mkdir repro && cd repro && git init -q
printf 'node_modules/\n' > .gitignore
git add .gitignore && git commit -qm init
git check-ignore -v .gstack/
# ~/.config/git/ignore:8:.gstack/ .gstack/ <- already ignored
bun -e 'import {resolveConfig,ensureStateDir} from "<gstack>/browse/src/config.ts"; ensureStateDir(resolveConfig(process.env));'
cat .gitignore
# node_modules/
# .gstack/ <- appended anyway
git status --porcelain
# M .gitignoreReverting .gitignore and re-running reproduces it every time. It is idempotent within a checkout, so it doesn't stack duplicates; it just comes back after any revert.
Suggested fix
Ask git instead of reading the file, and bail early when the answer is yes:
export function isIgnoredByGit(projectDir: string, relPath: string): boolean {
try {
const proc = Bun.spawnSync(['git', 'check-ignore', '-q', '--', relPath], {
cwd: projectDir, stdout: 'pipe', stderr: 'pipe', timeout: 2_000,
});
// 0 = ignored, 1 = not ignored, 128 = error (not a repo, bad pathspec)
return proc.exitCode === 0;
} catch {
return false;
}
}then in ensureStateDir(), after the state dir is created:
if (isIgnoredByGit(config.projectDir, '.gstack/')) return;Returning false on exit 128 keeps the current behavior for non-repos and for a broken or missing git, so the only change is "already ignored, so don't touch the file."
Regression test that fails before and passes after — it sets core.excludesFile inside a scratch repo rather than depending on the developer's real global config:
test('leaves .gitignore alone when git already ignores .gstack/ globally', () => {
// git init tmpDir; write .gstack/ to an excludes file; git config core.excludesFile <that file>
// .gitignore starts as 'node_modules/\n'
ensureStateDir(config);
expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe('node_modules/\n');
expect(fs.existsSync(path.join(tmpDir, '.gstack'))).toBe(true);
});Running this locally: 42 pass / 0 fail in browse/test/config.test.ts with the guard, 41 pass / 1 fail without it. The four existing ensureStateDir gitignore tests use non-repo temp dirs, so check-ignore returns 128 there and they're unaffected.
Happy to open a PR if the approach looks right.
Activity
v1.64.0.0 fix wave: full tracker audit — 90 fixes, 52 issues closed, …