Skip to content

browse: ensureStateDir appends .gstack/ to a tracked .gitignore even when git already ignores it globally #2385

Closed

Description

@kraftbj

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 .gitignore

Reverting .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

  1. added a commit that references this issue on Aug 15, 2026
    008dd65
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions