From ff5cbbbfefb7a25d365b1f03ad57cd997a5a68ed Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 14 Mar 2026 00:10:00 -0500 Subject: [PATCH] feat: add remote slug helper and auto-gitignore for .gstack/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getRemoteSlug() in config.ts: parses git remote origin → owner-repo format - browse/bin/remote-slug: shell helper for SKILL.md use (BSD sed compatible) - ensureStateDir() now appends .gstack/ to project .gitignore if not present - setup creates ~/.gstack/projects/ global state directory - 7 new tests: 4 gitignore behavior + 3 remote slug parsing --- browse/bin/remote-slug | 14 +++++++ browse/src/config.ts | 36 ++++++++++++++++++ browse/test/config.test.ts | 76 +++++++++++++++++++++++++++++++++++++- setup | 5 ++- 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100755 browse/bin/remote-slug diff --git a/browse/bin/remote-slug b/browse/bin/remote-slug new file mode 100755 index 0000000000000000000000000000000000000000..5f687595bbd4224fb4675f1258884f8ddbe02a4d --- /dev/null +++ b/browse/bin/remote-slug @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Output the remote slug (owner-repo) for the current git repo. +# Used by SKILL.md files to derive project-specific paths in ~/.gstack/projects/. +set -e +URL=$(git remote get-url origin 2>/dev/null || true) +if [ -n "$URL" ]; then + # Strip trailing .git if present, then extract owner/repo + URL="${URL%.git}" + # Handle both SSH (git@host:owner/repo) and HTTPS (https://host/owner/repo) + OWNER_REPO=$(echo "$URL" | sed -E 's#.*[:/]([^/]+)/([^/]+)$#\1-\2#') + echo "$OWNER_REPO" +else + basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +fi diff --git a/browse/src/config.ts b/browse/src/config.ts index 76892918c9820604c5a55681d3a7c5c24691a33b..e6fb71756b1ce9d4427e6e98c801d1a697b2a034 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -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()); + } } /** diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index 780385f4ae21ea53a445c45150d1f48d3aa1d1fc..2de5d071e5e1d6ffbf7944ead371242a7eb9d082 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -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', () => { diff --git a/setup b/setup index 1f1ad0970a37ac189f670c5a33ed4af8bb03af4d..d1ee8f07940bfd20890b50b2ce057ff0c8592478 100755 --- a/setup +++ b/setup @@ -57,7 +57,10 @@ if ! ensure_playwright_browser; then exit 1 fi -# 3. Only create skill symlinks if we're inside a .claude/skills directory +# 3. Ensure ~/.gstack global state directory exists +mkdir -p "$HOME/.gstack/projects" + +# 4. Only create skill symlinks if we're inside a .claude/skills directory SKILLS_BASENAME="$(basename "$SKILLS_DIR")" if [ "$SKILLS_BASENAME" = "skills" ]; then linked=()