Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [0.16.1.1] - 2026-04-08

### Fixed
- Browse no longer edits your tracked `.gitignore` just to store its `.gstack/` state. It now uses repo-local Git exclude metadata instead, so starting browse no longer leaves surprise worktree diffs or duplicate `.gstack/` lines when multiple instances start at once. Works in both normal repos and Git worktrees.

## [0.16.1.0] - 2026-04-08

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.16.1.0
0.16.1.1
6 changes: 5 additions & 1 deletion browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import * as fs from 'fs';
import * as path from 'path';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
import { resolveConfig, ensureProjectIgnoreEntry, ensureStateDir, readVersionHash } from './config';

const config = resolveConfig();
const IS_WINDOWS = process.platform === 'win32';
Expand Down Expand Up @@ -373,6 +373,10 @@ async function ensureServer(): Promise<ServerState> {
return freshState;
}

// Mutate repo-local git ignore metadata only once from the locked CLI path
// to avoid CLI/server startup races appending duplicate .gstack/ entries.
ensureProjectIgnoreEntry(config);

// Kill the old server to avoid orphaned chromium processes
if (state && state.pid) {
await killServer(state.pid);
Expand Down
79 changes: 65 additions & 14 deletions browse/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,26 +89,77 @@ export function ensureStateDir(config: BrowseConfig): void {
}
throw err;
}
}

/**
* Ensure .gstack/ is ignored by git.
*
* Call this from a serialized startup path. It mutates repo-local git
* ignore metadata, so it should not run from both the CLI and server process.
*/
function resolveGitExcludePath(projectDir: string): string | null {
const dotGitPath = path.join(projectDir, '.git');

try {
const stat = fs.statSync(dotGitPath);
if (stat.isDirectory()) {
return path.join(dotGitPath, 'info', 'exclude');
}
if (!stat.isFile()) {
return null;
}
} catch (err: any) {
if (err.code === 'ENOENT') {
return null;
}
throw err;
}

const dotGitFile = fs.readFileSync(dotGitPath, 'utf-8');
const prefix = 'gitdir: ';
const gitDirLine = dotGitFile
.split(/\r?\n/)
.find(line => line.toLowerCase().startsWith(prefix));

if (!gitDirLine) {
return null;
}

const gitDir = gitDirLine.slice(prefix.length).trim();
if (!gitDir) {
return null;
}

const resolvedGitDir = path.isAbsolute(gitDir)
? gitDir
: path.resolve(projectDir, gitDir);
return path.join(resolvedGitDir, 'info', 'exclude');
}

export function ensureProjectIgnoreEntry(config: BrowseConfig): void {
// Keep .gstack/ out of git status without mutating the tracked .gitignore file.
const excludePath = resolveGitExcludePath(config.projectDir);
if (!excludePath) {
return;
}

// Ensure .gstack/ is in the project's .gitignore
const gitignorePath = path.join(config.projectDir, '.gitignore');
try {
const content = fs.readFileSync(gitignorePath, 'utf-8');
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
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.length > 0 && !content.endsWith('\n') ? '\n' : '';
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
}
// 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 git exclude at ${excludePath}: ${err.message}\n`);
} catch {
// stateDir write failed too — nothing more we can do
}
// ENOENT (no .gitignore) — skip silently
}
}

Expand Down
152 changes: 114 additions & 38 deletions browse/test/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,58 @@
import { describe, test, expect } from 'bun:test';
import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug } from '../src/config';
import { resolveConfig, ensureProjectIgnoreEntry, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug } from '../src/config';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';

function withTempGitRepo(
setup: (tmpDir: string) => void,
run: (tmpDir: string) => void,
): void {
const prevCwd = process.cwd();
const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'browse-git-repo-')));

try {
const init = Bun.spawnSync(['git', 'init', '-q'], { cwd: tmpDir, stderr: 'pipe' });
if (init.exitCode !== 0) {
throw new Error(init.stderr.toString() || 'git init failed');
}

setup(tmpDir);
process.chdir(tmpDir);
run(tmpDir);
} finally {
process.chdir(prevCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

describe('config', () => {
describe('getGitRoot', () => {
test('returns a path when in a git repo', () => {
const root = getGitRoot();
expect(root).not.toBeNull();
expect(fs.existsSync(path.join(root!, '.git'))).toBe(true);
withTempGitRepo(
() => {},
tmpDir => {
const root = getGitRoot();
expect(root).toBe(tmpDir);
expect(fs.existsSync(path.join(root!, '.git'))).toBe(true);
},
);
});
});

describe('resolveConfig', () => {
test('uses git root by default', () => {
const config = resolveConfig({});
const gitRoot = getGitRoot();
expect(gitRoot).not.toBeNull();
expect(config.projectDir).toBe(gitRoot);
expect(config.stateDir).toBe(path.join(gitRoot!, '.gstack'));
expect(config.stateFile).toBe(path.join(gitRoot!, '.gstack', 'browse.json'));
withTempGitRepo(
() => {},
tmpDir => {
const config = resolveConfig({});
const gitRoot = getGitRoot();
expect(gitRoot).toBe(tmpDir);
expect(config.projectDir).toBe(gitRoot);
expect(config.stateDir).toBe(path.join(gitRoot!, '.gstack'));
expect(config.stateFile).toBe(path.join(gitRoot!, '.gstack', 'browse.json'));
},
);
});

test('derives paths from BROWSE_STATE_FILE when set', () => {
Expand Down Expand Up @@ -61,77 +93,121 @@ describe('config', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

test('adds .gstack/ to .gitignore if not present', () => {
test('does not mutate .gitignore as a side effect', () => {
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);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});

describe('ensureProjectIgnoreEntry', () => {
test('adds .gstack/ to .git/info/exclude if not present', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.git', 'info'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.git', 'info', 'exclude'), '*.log\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
ensureProjectIgnoreEntry(config);
const content = fs.readFileSync(path.join(tmpDir, '.git', 'info', 'exclude'), 'utf-8');
expect(content).toContain('.gstack/');
expect(content).toBe('node_modules/\n.gstack/\n');
expect(content).toBe('*.log\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});

test('does not duplicate .gstack/ in .gitignore', () => {
test('does not duplicate .gstack/ in .git/info/exclude', () => {
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');
fs.mkdirSync(path.join(tmpDir, '.git', 'info'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.git', 'info', 'exclude'), '*.log\n.gstack/\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).toBe('node_modules/\n.gstack/\n');
ensureProjectIgnoreEntry(config);
const content = fs.readFileSync(path.join(tmpDir, '.git', 'info', 'exclude'), 'utf-8');
expect(content).toBe('*.log\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});

test('handles .gitignore without trailing newline', () => {
test('handles .git/info/exclude 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');
fs.mkdirSync(path.join(tmpDir, '.git', 'info'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.git', 'info', 'exclude'), '*.log');
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');
ensureProjectIgnoreEntry(config);
const content = fs.readFileSync(path.join(tmpDir, '.git', 'info', 'exclude'), 'utf-8');
expect(content).toBe('*.log\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 on git exclude write error', () => {
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);
fs.mkdirSync(path.join(tmpDir, '.git', 'info'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.git', 'info', 'exclude'), '*.log\n');
fs.chmodSync(path.join(tmpDir, '.git', 'info', '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
ensureStateDir(config);
ensureProjectIgnoreEntry(config); // should not throw
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);
expect(logContent).toContain('Warning: could not update git exclude');
const excludeContent = fs.readFileSync(path.join(tmpDir, '.git', 'info', 'exclude'), 'utf-8');
expect(excludeContent).toBe('*.log\n');
fs.chmodSync(path.join(tmpDir, '.git', 'info', 'exclude'), 0o644);
fs.rmSync(tmpDir, { recursive: true, force: true });
});

test('skips if no .gitignore exists', () => {
test('handles worktree-style .git files', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const worktreeGitDir = path.join(tmpDir, '.git-worktree');
fs.mkdirSync(path.join(worktreeGitDir, 'info'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.git'), 'gitdir: .git-worktree\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
ensureProjectIgnoreEntry(config);
const content = fs.readFileSync(path.join(worktreeGitDir, 'info', 'exclude'), 'utf-8');
expect(content).toBe('.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});

test('skips if no git metadata exists', () => {
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);
ensureProjectIgnoreEntry(config);
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});

describe('getRemoteSlug', () => {
test('returns owner-repo format for current repo', () => {
const slug = getRemoteSlug();
// This repo has an origin remote — should return a slug
expect(slug).toBeTruthy();
expect(slug).toMatch(/^[a-zA-Z0-9._-]+-[a-zA-Z0-9._-]+$/);
withTempGitRepo(
tmpDir => {
const remote = Bun.spawnSync(
['git', 'remote', 'add', 'origin', 'https://github.com/garrytan/gstack.git'],
{ cwd: tmpDir, stderr: 'pipe' },
);
if (remote.exitCode !== 0) {
throw new Error(remote.stderr.toString() || 'git remote add failed');
}
},
() => {
const slug = getRemoteSlug();
expect(slug).toBe('garrytan-gstack');
},
);
});

test('parses SSH remote URLs', () => {
Expand Down