@@ 89,6 89,42 @@ export function ensureStateDir(config: BrowseConfig): void {
}
throw err;
}
+
+ // Ensure .gstack/ is in the project's .gitignore
+ const gitignorePath = path.join(config.projectDir, '.gitignore');
+ try {
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
+ if (!content.match(/^\.gstack\/?$/m)) {
+ const separator = content.endsWith('\n') ? '' : '\n';
+ fs.appendFileSync(gitignorePath, `${separator}.gstack/\n`);
+ }
+ } catch {
+ // No .gitignore or unreadable — skip
+ }
+}
+
+/**
+ * Derive a slug from the git remote origin URL (owner-repo format).
+ * Falls back to the directory basename if no remote is configured.
+ */
+export function getRemoteSlug(): string {
+ try {
+ const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], {
+ stdout: 'pipe',
+ stderr: 'pipe',
+ timeout: 2_000,
+ });
+ if (proc.exitCode !== 0) throw new Error('no remote');
+ const url = proc.stdout.toString().trim();
+ // SSH: git@github.com:owner/repo.git → owner-repo
+ // HTTPS: https://github.com/owner/repo.git → owner-repo
+ const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
+ if (match) return `${match[1]}-${match[2]}`;
+ throw new Error('unparseable');
+ } catch {
+ const root = getGitRoot();
+ return path.basename(root || process.cwd());
+ }
}
/**
@@ 1,5 1,5 @@
import { describe, test, expect } from 'bun:test';
-import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot } from '../src/config';
+import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug } from '../src/config';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ 60,6 60,80 @@ describe('config', () => {
// Cleanup
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');
+ 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');
+ 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') });
+ 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('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('skips if no .gitignore exists', () => {
+ const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
+ fs.mkdirSync(tmpDir, { recursive: true });
+ const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
+ ensureStateDir(config);
+ expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false);
+ 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._-]+$/);
+ });
+
+ test('parses SSH remote URLs', () => {
+ // Test the regex directly since we can't mock Bun.spawnSync easily
+ const url = 'git@github.com:garrytan/gstack.git';
+ const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
+ expect(match).not.toBeNull();
+ expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
+ });
+
+ test('parses HTTPS remote URLs', () => {
+ const url = 'https://github.com/garrytan/gstack.git';
+ const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
+ expect(match).not.toBeNull();
+ expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
+ });
+
+ test('parses HTTPS remote URLs without .git suffix', () => {
+ const url = 'https://github.com/garrytan/gstack';
+ const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
+ expect(match).not.toBeNull();
+ expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
+ });
});
describe('readVersionHash', () => {