fix(browse): skip the .gitignore append when git already ignores .gstack/

ensureStateDir appended ".gstack/" to a tracked .gitignore even when git
already ignored the directory via global excludes, .git/info/exclude, or a
parent .gitignore — dirtying the working tree on every daemon start. Run
`git check-ignore -q -- .gstack/` first and return early when git says it's
covered; git-missing/not-a-repo/timeout all fall through to the existing
text-check append (the safe default).

Closes #2385.

Contributed by @gregario (PR #2430).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 19:21:36 -07:00
parent b50aa963ec
commit 0350b2d758
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 52 additions and 0 deletions

View File

@ -78,6 +78,20 @@ export function resolveConfig(
};
}
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,
});
return proc.exitCode === 0;
} catch {
// git not found, timed out, or not a repo (exit 128). Fall through to
// the text-check path — appending is the safe default when unsure.
return false;
}
}
/**
* Create the .gstack/ state directory if it doesn't exist.
* Throws with a clear message on permission errors.
@ -96,6 +110,9 @@ export function ensureStateDir(config: BrowseConfig): void {
}
// Ensure .gstack/ is in the project's .gitignore
// First, check if git already ignores .gstack/ (via global excludes, .git/info/exclude, or parent .gitignore)
if (isIgnoredByGit(config.projectDir, '.gstack/')) return;
const gitignorePath = path.join(config.projectDir, '.gitignore');
try {
const content = fs.readFileSync(gitignorePath, 'utf-8');

View File

@ -124,6 +124,41 @@ describe('config', () => {
expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('leaves .gitignore alone when git already ignores .gstack/ globally', () => {
const { spawnSync } = require('child_process');
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-global-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Set up a real git repo
spawnSync('git', ['init', '-q'], { cwd: tmpDir });
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir });
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
// Write a global excludes file that ignores .gstack/
const excludesFile = path.join(tmpDir, 'global-gitignore');
fs.writeFileSync(excludesFile, '.gstack/\n');
spawnSync('git', ['config', 'core.excludesFile', excludesFile], { cwd: tmpDir });
// .gitignore exists but does NOT contain .gstack/
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
spawnSync('git', ['add', '.gitignore'], { cwd: tmpDir });
spawnSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir });
// Verify git knows .gstack/ is ignored
const check = spawnSync('git', ['check-ignore', '-q', '.gstack/'], { cwd: tmpDir });
expect(check.status).toBe(0);
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
// .gitignore must NOT have been modified
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules/\n');
expect(fs.existsSync(path.join(tmpDir, '.gstack'))).toBe(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});
describe('getRemoteSlug', () => {