Skip to content
Closed
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.14.0.0
1.15.1.0
42 changes: 34 additions & 8 deletions bin/gstack-slug
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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=""
Expand Down
176 changes: 176 additions & 0 deletions test/gstack-slug.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading