mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
feat: add remote slug helper and auto-gitignore for .gstack/
- 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
This commit is contained in:
Executable
+14
@@ -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
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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=()
|
||||
|
||||
Reference in New Issue
Block a user