fix(slug): resolve the project root by marker walk-up — subdirectory sessions stop misfiling state

gstack-slug derived everything from pwd, so a session in a subdirectory got
the subdir's basename as its slug (or an outer monorepo's remote), misfiling
reviews/decisions/learnings under a phantom project — and the per-pwd cache
made the wrong answer permanent. The resolver now walks up from pwd:
outermost STRONG marker wins (.git, package.json, pyproject.toml, Cargo.toml,
Gemfile, go.mod, .project.yaml), weak content markers (README, LICENSE) catch
non-code project folders, deploy artifacts are deliberately not markers, and
GSTACK_PROJECT_SLUG remains the escape hatch. The cache self-heals on
mismatch. Main-side invariants preserved on top: the unconditional
[a-zA-Z0-9._-] re-sanitize before echo and slash→dash branch canonicalization.

Fixes #1125.

Contributed by @ajeenkya (PR #1702; rebased over the sanitize and
branch-canonicalization work that landed after it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 19:11:18 -07:00
parent bff69bb0a3
commit 99e2718943
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 409 additions and 21 deletions

View File

@ -3,8 +3,28 @@
# Usage: eval "$(gstack-slug)" → sets SLUG and BRANCH variables
# Or: gstack-slug → prints SLUG=... and BRANCH=... lines
#
# Security: output is sanitized to [a-zA-Z0-9._-] only, preventing
# shell injection when consumed via source or eval.
# Resolution order (highest precedence first):
# 0. $GSTACK_PROJECT_SLUG env override (documented escape hatch)
# 1. Walk UP from $(pwd) to the OUTERMOST ancestor containing a canonical
# project-identity marker (.git, .project.yaml, package.json, pyproject.toml,
# Cargo.toml, Gemfile, go.mod). Use that ancestor as the "project root".
# Build/deploy artifacts (.vercel, .next, dist, node_modules, etc.) are
# DELIBERATELY NOT markers — they're tooling output, not project identity.
# Without this walk-up, running gstack-slug from a subdir whose only
# "marker" is a deploy artifact silently resolves to the subdir's basename,
# misfiling all session state under a phantom slug. (2026-05-25 bug fix.)
# 2. If the resolved project root has a git remote, derive the slug from it.
# 3. Otherwise use the basename of the resolved project root.
# 4. If no project root was found anywhere on the chain, fall back to the
# basename of $(pwd) (preserves prior behavior for plain folders).
#
# Caching is self-healing: a cache entry for the literal pwd that differs from
# the freshly-computed slug gets opportunistically rewritten (single-shot, key-
# local — never sweeps other entries). This lets pre-existing poisoned caches
# clean themselves up without a manual `rm -rf ~/.gstack/slug-cache/`.
#
# Security: output is sanitized to [a-zA-Z0-9._-] only, preventing shell
# injection when consumed via source or eval.
set -euo pipefail
CACHE_DIR="$HOME/.gstack/slug-cache"
@ -13,38 +33,115 @@ PROJECT_DIR="$(pwd)"
CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
CACHE_FILE="${CACHE_DIR}/${CACHE_KEY}"
# 1. Try cached slug first (guarantees consistency across sessions)
if [[ -f "$CACHE_FILE" ]]; then
SLUG=$(cat "$CACHE_FILE")
SLUG=""
# 0. Explicit env override — wins over everything. Escape hatch for vendored
# sub-repos and other genuine "subdir IS its own project" edge cases.
if [[ -n "${GSTACK_PROJECT_SLUG:-}" ]]; then
SLUG=$(printf '%s' "$GSTACK_PROJECT_SLUG" | tr -cd 'a-zA-Z0-9._-')
fi
# 2. If no cache, compute from git remote (separated from pipeline to avoid
# pipefail swallowing the error and producing an empty slug)
if [[ -z "${SLUG:-}" ]]; then
REMOTE_URL=$(git remote get-url origin 2>/dev/null) || REMOTE_URL=""
# 1. Walk up from pwd, tracking the OUTERMOST ancestor with a canonical
# project-identity marker. The walk stops at "/" so we never escape the
# filesystem root. Markers are an allow-list (not a blacklist) so new
# build/deploy tools cannot silently establish phantom project roots.
#
# Markers: .git can be a directory (normal repo) or a file (worktree /
# submodule pointer). Everything else is a file at the directory's top
# level.
# Two tiers of markers:
# - STRONG markers (canonical version-control / language project files):
# .git, .project.yaml, package.json, pyproject.toml, Cargo.toml, Gemfile,
# go.mod. These signal "this directory is a real project of its own."
# - WEAK markers (content-only project signals): README.md, README, LICENSE.
# These catch content folders (markdown bundles, asset collections, AJ's
# loadout-style folders) that have no programming-language project files
# but ARE the user's project root.
# Rule: outermost STRONG marker wins. If no strong marker exists anywhere on
# the chain, outermost WEAK marker wins. This means a vendored sub-repo
# (e.g. `loadout/starter-pack/.git`) correctly keeps its own slug even when
# a weak-marker parent (`loadout/README.md`) is higher up — the sub-repo IS
# its own project. But a deploy-artifact-only subdir (`loadout/site/.vercel`)
# correctly folds into the content-project parent (`loadout/README.md`),
# because `.vercel` is not a marker at all.
_outermost_project_root() {
local dir="$1"
local outermost_strong=""
local outermost_weak=""
while [[ -n "$dir" && "$dir" != "/" ]]; do
if [[ -e "$dir/.git" \
|| -f "$dir/.project.yaml" \
|| -f "$dir/package.json" \
|| -f "$dir/pyproject.toml" \
|| -f "$dir/Cargo.toml" \
|| -f "$dir/Gemfile" \
|| -f "$dir/go.mod" ]]; then
outermost_strong="$dir"
elif [[ -f "$dir/README.md" \
|| -f "$dir/README" \
|| -f "$dir/README.rst" \
|| -f "$dir/LICENSE" \
|| -f "$dir/LICENSE.md" ]]; then
outermost_weak="$dir"
fi
dir=$(dirname "$dir")
done
# Strong markers win over weak; either wins over nothing.
if [[ -n "$outermost_strong" ]]; then
printf '%s' "$outermost_strong"
else
printf '%s' "$outermost_weak"
fi
}
# Only compute the project root if we don't already have a slug (env override
# took precedence). The walk is cheap (~10 stats on the deepest realistic cwd).
PROJECT_ROOT=""
if [[ -z "$SLUG" ]]; then
PROJECT_ROOT=$(_outermost_project_root "$PROJECT_DIR")
fi
# 2. If we found a project root and it has a git remote, derive slug from the
# remote URL (existing logic — kept verbatim, just rooted at PROJECT_ROOT
# instead of $PWD so a subdir without its own remote inherits the parent's).
if [[ -z "$SLUG" && -n "$PROJECT_ROOT" ]]; then
REMOTE_URL=$(git -C "$PROJECT_ROOT" 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
# 3. Fallback to basename only when there's truly no git remote configured
SLUG="${SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
# 3. No git remote (or no remote at all) — use the project root's basename.
if [[ -z "$SLUG" && -n "$PROJECT_ROOT" ]]; then
SLUG=$(basename "$PROJECT_ROOT" | tr -cd 'a-zA-Z0-9._-')
fi
# 4. Final fallback: no project root found anywhere on the chain. Use pwd's
# basename (preserves the old behavior for plain non-project folders).
SLUG="${SLUG:-$(basename "$PROJECT_DIR" | tr -cd 'a-zA-Z0-9._-')}"
# Cache compare/evict/write — self-healing. Compute the cache decision AFTER
# fresh resolution so a stale cached value gets corrected on next invocation
# rather than perpetuated. Single-shot: we only ever touch the cache entry for
# the literal current pwd's key, never sweep others.
# 3b. Re-sanitize unconditionally before the value is echoed into `eval`/`source`
# output. The compute (2) and fallback (3) paths already filter, but a value
# read straight from the cache file (1) does NOT — a poisoned
# ~/.gstack/slug-cache/<key> would otherwise inject shell into
# `eval "$(gstack-slug)"`. Filtering here honors the [a-zA-Z0-9._-] invariant
# promised in the header on every path, and heals a poisoned cache on write (4).
# output — honors the [a-zA-Z0-9._-] invariant promised in the header on
# every path (the fresh-compute design already prevents poisoned-cache
# injection, but the invariant should not depend on that reasoning).
SLUG=$(printf '%s' "$SLUG" | tr -cd 'a-zA-Z0-9._-')
# 4. 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=""
if [[ -n "$CACHE_TMP" ]]; then
printf '%s' "$SLUG" > "$CACHE_TMP" && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null
CURRENT_CACHE=""
if [[ -f "$CACHE_FILE" ]]; then
CURRENT_CACHE=$(cat "$CACHE_FILE" 2>/dev/null || true)
fi
if [[ "$CURRENT_CACHE" != "$SLUG" ]]; then
mkdir -p "$CACHE_DIR" 2>/dev/null || true
CACHE_TMP=$(mktemp "$CACHE_DIR/.slug-XXXXXX" 2>/dev/null) || CACHE_TMP=""
if [[ -n "$CACHE_TMP" ]]; then
printf '%s' "$SLUG" > "$CACHE_TMP" && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null
fi
fi
fi

View File

@ -0,0 +1,291 @@
/**
* Regression test bin/gstack-slug must resolve to the OUTERMOST project root
* along the cwd ancestor chain, not to a subdirectory that happens to contain
* a build/deploy marker.
*
* The bug this prevents (2026-05-25):
* `bin/gstack-slug` derived its slug from the literal `pwd` with no walk-up.
* When a session's cwd landed inside a subdir that had its own project-like
* marker (e.g. `.vercel/` dropped by `vercel --prod`, or a vendored `package.json`),
* the slug resolved to the subdir's basename silently misfiling checkpoints,
* autosave state, and operational learnings under a phantom slug like `site`
* instead of the real project's slug like `loadout`.
*
* The fix walks up from `pwd` looking for canonical project-identity markers
* (`.git`, `.project.yaml`, `package.json`, `pyproject.toml`, `Cargo.toml`,
* `Gemfile`, `go.mod`) and takes the OUTERMOST match. Build/deploy artifacts
* (`.vercel`, `.next`, `dist`, `node_modules`, etc.) are NOT in the allow-list,
* so they cannot establish a phantom project root.
*
* Caching is self-healing: a stale cache entry for the literal pwd gets
* overwritten with the freshly-computed correct slug on the next invocation
* (no manual `rm -rf ~/.gstack/slug-cache/` required).
*
* Test pattern mirrors `test/migration-checkpoint-ownership.test.ts`:
* per-test `tmpHome`, `spawnSync` against the real bash script with the
* tmpHome injected as `HOME`, fixtures built on disk.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync, type SpawnSyncReturns } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const SCRIPT = path.join(ROOT, 'bin', 'gstack-slug');
function runSlug(
cwd: string,
tmpHome: string,
extraEnv: Record<string, string> = {},
): SpawnSyncReturns<string> {
// Scrub PATH so we always use system bash + system git; pass HOME so the
// script's cache writes land in tmpHome, never AJ's real ~/.gstack.
const env = { ...process.env, HOME: tmpHome, ...extraEnv };
return spawnSync('bash', [SCRIPT], {
cwd,
env,
encoding: 'utf8',
timeout: 10_000,
});
}
function parseSlug(stdout: string): { slug: string; branch: string } {
const slugMatch = stdout.match(/^SLUG=([^\n]*)$/m);
const branchMatch = stdout.match(/^BRANCH=([^\n]*)$/m);
return {
slug: slugMatch ? slugMatch[1]! : '',
branch: branchMatch ? branchMatch[1]! : '',
};
}
function encodedCacheKey(absPath: string): string {
return absPath.replace(/\//g, '_');
}
describe('gstack-slug — outermost project-root resolution', () => {
let tmpHome: string;
let projectsRoot: string;
beforeEach(() => {
// realpathSync canonicalizes /var/folders/... -> /private/var/folders/... on
// macOS so that the cache key our test computes matches the cache key the
// bash script computes from `$(pwd)`. Without this the script writes to
// _private_var_folders_... and the test sees _var_folders_... (silent mismatch).
tmpHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-slug-test-')));
projectsRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-slug-projects-')));
});
afterEach(() => {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
try { fs.rmSync(projectsRoot, { recursive: true, force: true }); } catch {}
});
// AC-1: the canonical loadout/site/.vercel reproduction.
test('AC-1: .git at root, .vercel in subdir — slug from subdir resolves to ROOT basename', () => {
const projectRoot = path.join(projectsRoot, 'loadout');
const siteSubdir = path.join(projectRoot, 'site');
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
const result = runSlug(siteSubdir, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
expect(slug).toBe('loadout');
expect(slug).not.toBe('site');
});
// AC-1 variant: package.json at root, node_modules-only in subdir.
test('AC-1 variant: package.json at root, node_modules-only subdir — slug = ROOT basename', () => {
const projectRoot = path.join(projectsRoot, 'monorepo');
const subdir = path.join(projectRoot, 'packages', 'web');
fs.mkdirSync(subdir, { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}\n');
fs.mkdirSync(path.join(subdir, 'node_modules'), { recursive: true });
const result = runSlug(subdir, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
expect(slug).toBe('monorepo');
});
// AC-2: stale cache for the subdir's pwd gets self-healed.
test('AC-2: stale cache for subdir pwd is overwritten with correct outermost-root slug', () => {
const projectRoot = path.join(projectsRoot, 'loadout');
const siteSubdir = path.join(projectRoot, 'site');
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
// Pre-seed the cache with the WRONG value (simulating pre-fix poisoning).
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheKey = encodedCacheKey(siteSubdir);
const cacheFile = path.join(cacheDir, cacheKey);
fs.writeFileSync(cacheFile, 'site');
const result = runSlug(siteSubdir, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
expect(slug).toBe('loadout');
// The cache file itself must have been overwritten (self-healing).
const cachedAfter = fs.readFileSync(cacheFile, 'utf8').trim();
expect(cachedAfter).toBe('loadout');
});
// AC-3: no regression — cwd IS the project root.
test('AC-3: cwd is the project root with .git — slug = basename, no change in behavior', () => {
const projectRoot = path.join(projectsRoot, 'myproject');
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
const result = runSlug(projectRoot, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
expect(slug).toBe('myproject');
});
// AC-4: no regression — no markers anywhere on the cwd ancestor chain.
test('AC-4: no project markers anywhere on cwd chain — slug = pwd basename (fallback)', () => {
// projectsRoot itself is just a tmp dir with no markers; create a deeper
// path inside it that also has no markers anywhere up to it.
const deep = path.join(projectsRoot, 'just', 'a', 'plain', 'folder');
fs.mkdirSync(deep, { recursive: true });
const result = runSlug(deep, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
expect(slug).toBe('folder');
});
// AC-5: no regression — real git remote takes precedence (slug from remote URL).
test('AC-5: project root has a real git remote — slug derived from remote URL', () => {
const projectRoot = path.join(projectsRoot, 'realgit');
fs.mkdirSync(projectRoot, { recursive: true });
// Initialize a real git repo with an origin remote so `git remote get-url`
// succeeds. (The script's step 2 reads the remote when there's no cache.)
const gitInit = spawnSync('git', ['init', '-q', '-b', 'main', projectRoot], {
encoding: 'utf8',
});
expect(gitInit.status).toBe(0);
const gitRemote = spawnSync(
'git',
['-C', projectRoot, 'remote', 'add', 'origin', 'https://github.com/foo/bar.git'],
{ encoding: 'utf8' },
);
expect(gitRemote.status).toBe(0);
const result = runSlug(projectRoot, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
// Existing sed-based regex extracts "foo/bar" → "foo-bar" after tr '/' '-'.
expect(slug).toBe('foo-bar');
});
// AC-6: cache eviction is single-shot — does NOT touch other cache entries.
test('AC-6: cache eviction only rewrites the literal-pwd key, not other entries', () => {
const projectRoot = path.join(projectsRoot, 'loadout');
const siteSubdir = path.join(projectRoot, 'site');
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
// Seed the literal pwd key with the wrong value (will be evicted).
const targetKey = encodedCacheKey(siteSubdir);
fs.writeFileSync(path.join(cacheDir, targetKey), 'site');
// Seed an UNRELATED cache entry — must remain untouched.
const unrelatedKey = '_Users_someone_unrelated_project';
const unrelatedFile = path.join(cacheDir, unrelatedKey);
fs.writeFileSync(unrelatedFile, 'unrelated-value-must-survive');
const result = runSlug(siteSubdir, tmpHome);
expect(result.status).toBe(0);
// Target key got self-healed.
expect(fs.readFileSync(path.join(cacheDir, targetKey), 'utf8').trim()).toBe('loadout');
// Unrelated key is untouched.
expect(fs.readFileSync(unrelatedFile, 'utf8').trim()).toBe('unrelated-value-must-survive');
});
// AC-7: output contract is preserved exactly.
test('AC-7: stdout shape is `SLUG=<safe>\\nBRANCH=<safe>\\n`, sanitized to [a-zA-Z0-9._-]', () => {
const projectRoot = path.join(projectsRoot, 'loadout');
const siteSubdir = path.join(projectRoot, 'site');
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
const result = runSlug(siteSubdir, tmpHome);
expect(result.status).toBe(0);
// Exactly two lines (with trailing newline from the last `echo`).
expect(result.stdout).toMatch(/^SLUG=[a-zA-Z0-9._-]+\nBRANCH=[a-zA-Z0-9._-]+\n$/);
});
// Weak-marker case: content-only project folder (README.md, no .git, no package.json).
// This is the AJ-loadout shape: a folder of markdown content with a README at
// the root and a deploy-artifact-only subdir. Without README as a marker, the
// walk-up would find nothing and fall back to pwd basename = subdir name.
test('weak marker: README.md at root, .vercel-only subdir — slug = ROOT basename (loadout repro)', () => {
const projectRoot = path.join(projectsRoot, 'loadout');
const siteSubdir = path.join(projectRoot, 'site');
fs.mkdirSync(siteSubdir, { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'README.md'), '# loadout\n');
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
const result = runSlug(siteSubdir, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
expect(slug).toBe('loadout');
});
// Two-tier markers: a vendored sub-repo with its own .git keeps its own slug
// even when a weak-marker (README) parent is higher up. Strong beats weak.
test('two-tier: vendored sub-repo with .git wins over parent README (strong > weak)', () => {
const projectRoot = path.join(projectsRoot, 'loadout');
const subRepo = path.join(projectRoot, 'starter-pack');
fs.mkdirSync(subRepo, { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'README.md'), '# loadout\n');
fs.mkdirSync(path.join(subRepo, '.git'), { recursive: true });
const result = runSlug(subRepo, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
expect(slug).toBe('starter-pack');
});
// Two-tier markers: weak marker still wins when no strong marker exists
// anywhere on the chain. Confirms loadout/site/.vercel → loadout case.
test('two-tier: weak marker chain falls back correctly when no strong marker exists', () => {
const projectRoot = path.join(projectsRoot, 'loadout');
const subdir = path.join(projectRoot, 'docs');
fs.mkdirSync(subdir, { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'README.md'), '# loadout\n');
fs.writeFileSync(path.join(subdir, 'README.md'), '# docs\n');
const result = runSlug(subdir, tmpHome);
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
// Outermost weak wins → loadout, not docs.
expect(slug).toBe('loadout');
});
// Edge case: GSTACK_PROJECT_SLUG env override wins over walk-up (documented escape hatch).
test('GSTACK_PROJECT_SLUG env override beats every other resolution path', () => {
const projectRoot = path.join(projectsRoot, 'loadout');
const siteSubdir = path.join(projectRoot, 'site');
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
const result = runSlug(siteSubdir, tmpHome, { GSTACK_PROJECT_SLUG: 'custom-override' });
expect(result.status).toBe(0);
const { slug } = parseSlug(result.stdout);
expect(slug).toBe('custom-override');
});
});