From 1c2c97befa47a10f792855720a2a5e29cbf3169b Mon Sep 17 00:00:00 2001 From: Greg Jackson Date: Sun, 26 Apr 2026 20:38:34 +0100 Subject: [PATCH 1/2] fix(gstack-slug): subdir guard, .gstack-slug override, --reset Fixes #1125. `bin/gstack-slug` derives the project slug from `git remote get-url origin`, which walks up parent directories. A standalone project living inside an outer git repo silently inherited the outer's slug, contaminating `~/.gstack/projects/{slug}/` with cross-project artifacts. The cache had no invalidation path, so once wrong, it stayed wrong. Three changes: 1. Subdir guard. Compare `git rev-parse --show-toplevel` against `$PWD`. When they differ, skip the remote-URL inference and fall through to `.gstack-slug` or basename. Eliminates the silent cross-contamination. 2. Per-project override. A `.gstack-slug` file in `$PWD` (one line, sanitized to [a-zA-Z0-9._-]) wins over git inference. Cheap, explicit, survives gstack-upgrade. 3. `--reset` flag. Removes the cache entry for the current PWD so users can recover from a previously-poisoned slug without manual cleanup of `~/.gstack/slug-cache/`. Tests cover the regression (subdir does not inherit outer remote), both override paths (toplevel and subdir), input sanitization, --reset behavior, and cache stability across runs. Co-Authored-By: Claude Opus 4.7 --- bin/gstack-slug | 42 ++++++++-- test/gstack-slug.test.ts | 176 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 test/gstack-slug.test.ts diff --git a/bin/gstack-slug b/bin/gstack-slug index 6b853b6d71..753e2fa780 100755 --- a/bin/gstack-slug +++ b/bin/gstack-slug @@ -2,6 +2,14 @@ # gstack-slug — output project slug and sanitized branch name # Usage: eval "$(gstack-slug)" → sets SLUG and BRANCH variables # Or: gstack-slug → prints SLUG=... and BRANCH=... lines +# gstack-slug --reset → clears the cached slug for $PWD +# +# Per-project override: a `.gstack-slug` file in $PWD (one line, sanitized) +# is used verbatim and skips all inference. Survives gstack-upgrade. +# +# Subdir guard: if $PWD is not the git toplevel (i.e. PWD lives inside a +# parent repo), the parent's `origin` is NOT inherited. Falls through to +# `.gstack-slug` or basename, preventing cross-project contamination. # # Security: output is sanitized to [a-zA-Z0-9._-] only, preventing # shell injection when consumed via source or eval. @@ -13,25 +21,43 @@ PROJECT_DIR="$(pwd)" CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_') CACHE_FILE="${CACHE_DIR}/${CACHE_KEY}" +# 0. --reset: clear the cache entry for this PWD and exit +if [[ "${1:-}" == "--reset" ]]; then + rm -f "$CACHE_FILE" 2>/dev/null || true + echo "Reset slug cache for $PROJECT_DIR" >&2 + exit 0 +fi + # 1. Try cached slug first (guarantees consistency across sessions) if [[ -f "$CACHE_FILE" ]]; then SLUG=$(cat "$CACHE_FILE") fi -# 2. If no cache, compute from git remote (separated from pipeline to avoid -# pipefail swallowing the error and producing an empty slug) +# 2. Per-project override: .gstack-slug in PWD wins over git inference. +# Sanitized identically to the rest of the pipeline. +if [[ -z "${SLUG:-}" && -f "$PROJECT_DIR/.gstack-slug" ]]; then + RAW_SLUG=$(head -n 1 "$PROJECT_DIR/.gstack-slug" 2>/dev/null || echo "") + SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-') +fi + +# 3. If no cache and no override, compute from git remote — but only when +# PWD is the git toplevel. Subdirs of an outer repo would otherwise +# inherit the outer's slug and silently contaminate `~/.gstack/projects/`. if [[ -z "${SLUG:-}" ]]; then - REMOTE_URL=$(git remote get-url origin 2>/dev/null) || REMOTE_URL="" - if [[ -n "$REMOTE_URL" ]]; then - RAW_SLUG=$(printf '%s' "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') - SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-') + TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null) || TOPLEVEL="" + if [[ -n "$TOPLEVEL" && "$TOPLEVEL" == "$PROJECT_DIR" ]]; then + REMOTE_URL=$(git remote get-url origin 2>/dev/null) || REMOTE_URL="" + if [[ -n "$REMOTE_URL" ]]; then + RAW_SLUG=$(printf '%s' "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') + SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-') + fi fi fi -# 3. Fallback to basename only when there's truly no git remote configured +# 4. Fallback to basename when no remote, no override, or PWD is a subdir. SLUG="${SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}" -# 4. Cache the slug for future sessions (atomic write, fail silently) +# 5. Cache the slug for future sessions (atomic write, fail silently) if [[ -n "$SLUG" ]]; then mkdir -p "$CACHE_DIR" 2>/dev/null || true CACHE_TMP=$(mktemp "$CACHE_DIR/.slug-XXXXXX" 2>/dev/null) || CACHE_TMP="" diff --git a/test/gstack-slug.test.ts b/test/gstack-slug.test.ts new file mode 100644 index 0000000000..0da5d74063 --- /dev/null +++ b/test/gstack-slug.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for bin/gstack-slug — verifies subdir guard, .gstack-slug override, + * and --reset cache management. + * + * Regression coverage for #1125 (subdir inherits outer-repo slug). + */ +import { describe, test, expect, afterAll } from 'bun:test'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, realpathSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { spawnSync } from 'child_process'; + +const SCRIPT = join(import.meta.dir, '..', 'bin', 'gstack-slug'); + +const dirs: string[] = []; + +function makeDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), `${prefix}-`)); + dirs.push(dir); + return dir; +} + +function git(cwd: string, ...args: string[]) { + return spawnSync('git', args, { cwd, stdio: 'pipe', timeout: 5000 }); +} + +function initRepo(dir: string, remoteUrl?: string) { + git(dir, 'init', '-b', 'main'); + git(dir, 'config', 'user.email', 'test@test.com'); + git(dir, 'config', 'user.name', 'Test'); + if (remoteUrl) git(dir, 'remote', 'add', 'origin', remoteUrl); + writeFileSync(join(dir, 'README.md'), '# test\n'); + git(dir, 'add', '.'); + git(dir, 'commit', '-m', 'initial'); +} + +function runSlug(cwd: string, args: string[] = [], homeOverride?: string): { stdout: string; stderr: string; code: number } { + const home = homeOverride ?? makeDir('slug-home'); + const result = spawnSync('bash', [SCRIPT, ...args], { + cwd, + env: { ...process.env, HOME: home }, + stdio: 'pipe', + timeout: 5000, + }); + return { + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + code: result.status ?? -1, + }; +} + +function parseSlug(stdout: string): string | undefined { + const match = stdout.match(/^SLUG=(.+)$/m); + return match?.[1]; +} + +afterAll(() => { + for (const d of dirs) { + try { rmSync(d, { recursive: true, force: true }); } catch {} + } +}); + +describe('subdir guard (regression for #1125)', () => { + test('PWD inside outer repo does NOT inherit outer remote', () => { + const outer = makeDir('outer'); + initRepo(outer, 'git@github.com:me/workspace.git'); + const subdir = join(outer, 'IoTopia'); + mkdirSync(subdir); + + const { stdout } = runSlug(subdir); + const slug = parseSlug(stdout); + + // Must NOT be the outer slug. Falls through to basename. + expect(slug).not.toBe('me-workspace'); + expect(slug).toBe('IoTopia'); + }); + + test('PWD at repo toplevel still inherits remote slug', () => { + const repo = makeDir('toplevel'); + initRepo(repo, 'git@github.com:owner/proj.git'); + + const { stdout } = runSlug(repo); + expect(parseSlug(stdout)).toBe('owner-proj'); + }); + + test('no git repo at all falls back to basename', () => { + const dir = makeDir('plainDir'); + const { stdout } = runSlug(dir); + expect(parseSlug(stdout)).toMatch(/^plainDir-/); + }); +}); + +describe('.gstack-slug override', () => { + test('overrides git inference at toplevel', () => { + const repo = makeDir('overrideRepo'); + initRepo(repo, 'git@github.com:wrong/remote.git'); + writeFileSync(join(repo, '.gstack-slug'), 'my-real-project\n'); + + const { stdout } = runSlug(repo); + expect(parseSlug(stdout)).toBe('my-real-project'); + }); + + test('overrides basename in a subdir', () => { + const outer = makeDir('outerOverride'); + initRepo(outer, 'git@github.com:me/workspace.git'); + const subdir = join(outer, 'sub'); + mkdirSync(subdir); + writeFileSync(join(subdir, '.gstack-slug'), 'sub-project'); + + const { stdout } = runSlug(subdir); + expect(parseSlug(stdout)).toBe('sub-project'); + }); + + test('sanitizes unsafe characters', () => { + const dir = makeDir('sanitize'); + writeFileSync(join(dir, '.gstack-slug'), 'evil; rm -rf /\n'); + + const { stdout } = runSlug(dir); + const slug = parseSlug(stdout); + expect(slug).not.toContain(';'); + expect(slug).not.toContain(' '); + expect(slug).toMatch(/^[a-zA-Z0-9._-]+$/); + }); + + test('reads only first line', () => { + const dir = makeDir('multiline'); + writeFileSync(join(dir, '.gstack-slug'), 'first-line\nsecond-line\n'); + + const { stdout } = runSlug(dir); + expect(parseSlug(stdout)).toBe('first-line'); + }); +}); + +describe('--reset', () => { + test('clears cache entry for current PWD', () => { + const home = makeDir('resetHome'); + const repo = makeDir('resetRepo'); + initRepo(repo, 'git@github.com:owner/proj.git'); + + runSlug(repo, [], home); + // bash's `pwd` can resolve symlinks (e.g. /var/folders → /private/var/folders on + // macOS), so derive the cache key from the same resolved path the script uses. + const cacheKey = realpathSync(repo).replace(/\//g, '_'); + const cacheFile = join(home, '.gstack', 'slug-cache', cacheKey); + expect(existsSync(cacheFile)).toBe(true); + + const reset = runSlug(repo, ['--reset'], home); + expect(reset.code).toBe(0); + expect(existsSync(cacheFile)).toBe(false); + }); + + test('is a no-op when no cache exists', () => { + const home = makeDir('noCacheHome'); + const dir = makeDir('noCacheDir'); + + const { code } = runSlug(dir, ['--reset'], home); + expect(code).toBe(0); + }); +}); + +describe('cache stability', () => { + test('second run reuses cached slug', () => { + const home = makeDir('stableHome'); + const repo = makeDir('stableRepo'); + initRepo(repo, 'git@github.com:owner/cached.git'); + + const first = runSlug(repo, [], home); + expect(parseSlug(first.stdout)).toBe('owner-cached'); + + // Remove the remote — cached slug should still come back + git(repo, 'remote', 'remove', 'origin'); + + const second = runSlug(repo, [], home); + expect(parseSlug(second.stdout)).toBe('owner-cached'); + }); +}); From 315667856586470a7304d843fdcd526c3e9f5b13 Mon Sep 17 00:00:00 2001 From: Greg Jackson Date: Sun, 26 Apr 2026 20:39:26 +0100 Subject: [PATCH 2/2] chore: release v1.15.1.0 (gstack-slug subdir guard) Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ VERSION | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d42dd03552..a8ffce74a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [1.15.1.0] - 2026-04-26 + +## **`gstack-slug` no longer eats your project's identity when it's a subdirectory of another repo.** + +If you keep a standalone project inside a workspace repo, gstack used to inherit the workspace's slug and dump every artifact under the wrong project folder. Once cached, no recovery. Now there's a subdir guard, a `.gstack-slug` per-project override, and a `--reset` flag for when the cache went bad. + +### What this means for you + +If you've been seeing `~/.gstack/projects/me-workspace/` collect artifacts that belong to a real project living inside `me-workspace/`, you can drop a one-line `.gstack-slug` in the project, run `gstack-slug --reset` to clear the bad cache entry, and gstack will pick up the right identity from now on. The fix is opt-out: nothing changes for projects that already work. + +### The numbers that matter + +| Metric | Before | After | +|---|---|---| +| Slug for `cd outer/sub && gstack-slug` (no `.git` in `sub`) | `me-outer` (wrong) | `sub` (basename) or override | +| Cache recovery path | edit/delete files by hand | `gstack-slug --reset` | +| Per-project override file | none | `.gstack-slug` (sanitized, one line) | +| Test coverage on `bin/gstack-slug` | 0 cases | 10 cases | + +Reported by @marcosmoova in #1125 with a clean reproducer. Mirrors the shape of @snowmaker's PR #897 (deterministic slugs across sessions): one `bin/` script, one regression test file, zero behavior change for the happy path. + +### Itemized changes + +#### Fixed +- `bin/gstack-slug` no longer trusts `git remote get-url origin` when `$PWD` is not the repo toplevel. Subdirs of outer repos fall through to `.gstack-slug` or basename instead of inheriting the parent's slug. (#1125) +- Added `.gstack-slug` per-project override: a one-line sanitized slug in `$PWD/.gstack-slug` skips all git inference. Survives `gstack-upgrade`. +- Added `gstack-slug --reset` to clear the cache entry for the current PWD without manual cleanup of `~/.gstack/slug-cache/`. + +#### For contributors +- New `test/gstack-slug.test.ts` covers the subdir-guard regression, both override paths, input sanitization, `--reset` behavior, and cross-session cache stability. + ## [1.14.0.0] - 2026-04-25 ## **The gstack browser sidebar is now an interactive Claude Code REPL with live tab awareness.** diff --git a/VERSION b/VERSION index 323a9cbcfa..aca65add96 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.14.0.0 +1.15.1.0