Files
gstack/bin/gstack-slug
T
Garry TanandClaude Fable 5 e57b2798fd fix(slug): gstack-slug matches remote-slug's owner-repo canonical form (live misfile bug)
Found live during this wave's CEO review: bin/gstack-slug emitted
SLUG=garrytan for this garrytan/gstack worktree while remote-slug correctly
gave garrytan-gstack — decisions, timeline, ceo-plans, and learnings were
filing into the wrong project store (observed polluting Context Recovery with
another repo's decisions). Root cause: a stray empty ~/.git directory made
the walk-up crown $HOME as the outermost project root; the remote lookup ran
only against that root, failed silently, and the basename fallback cached
'garrytan' sticky. NOT worktree-specific — any strong marker on a non-repo
ancestor triggered it.

Fix: the walk now finds the outermost ancestor whose .git actually resolves
an origin remote and derives owner-repo with remote-slug's byte-identical
parse; marker-only ancestors keep anchoring the basename fallback but can no
longer shadow a real remote. A new cache self-heal recomputes the poisoned
shape (cached == basename of a marker root while a remote-bearing repo exists
below), preserving legit #2212 stickiness. Nested-repo walk-up, no-remote and
non-git fallbacks, and the SLUG=/BRANCH= eval contract are unchanged, pinned
by a 10-case parity suite. Store migration for pre-fix data is tracked in
TODOS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:43:54 -07:00

268 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# 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
#
# 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. Derive the slug from the canonical git remote: the OUTERMOST ancestor
# that is an actual git repo (`.git` directory, or `.git` FILE for
# worktrees/submodules) with an `origin` remote wins. The slug is
# `owner-repo`, parsed EXACTLY like browse/bin/remote-slug so the two
# bins can never disagree on a canonical-remote repo. Marker-only
# ancestors that are NOT remote-bearing repos (a stray empty ~/.git,
# a stray package.json in $HOME) still anchor the basename FALLBACK,
# but they can no longer shadow a real remote. (2026-08-17 bug fix:
# a stray empty ~/.git made the walk-up pick $HOME as project root;
# $HOME has no origin, so EVERY repo under it degraded to
# SLUG=<username> — one shared bucket for all projects.)
# 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).
#
# MIGRATION NOTE (2026-08-17): sessions run BEFORE the remote-first fix above,
# in repos below a stray marker-bearing ancestor, filed their session state
# (decisions / timeline / ceo-plans / learnings) under the DEGRADED slug —
# ~/.gstack/projects/<ancestor-basename>/ (e.g. `garrytan`) instead of the
# canonical ~/.gstack/projects/<owner-repo>/. The slug cache self-heals on the
# next invocation (see 1b), but already-written store data does NOT move.
# Whether/how to merge those stores is tracked in TODOS.md — do not add data
# migration code here.
#
# 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
# GSTACK_HOME-aware, matching lib/bin-context.ts's native port (#2561): the
# bash writer and the TS reader must key the SAME cache, and a test running
# with GSTACK_HOME=<temp> must write its cache junk there, not into the real
# home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack).
CACHE_DIR="${GSTACK_HOME:-$HOME/.gstack}/slug-cache"
PROJECT_DIR="$(pwd)"
# Encode absolute path as cache key: /Users/j/foo → _Users_j_foo
CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
CACHE_FILE="${CACHE_DIR}/${CACHE_KEY}"
SLUG=""
# 0. Explicit env override — wins over everything. Escape hatch for vendored
# sub-repos and other genuine "subdir IS its own project" edge cases.
SLUG_FROM_ENV=0
if [[ -n "${GSTACK_PROJECT_SLUG:-}" ]]; then
SLUG=$(printf '%s' "$GSTACK_PROJECT_SLUG" | tr -cd 'a-zA-Z0-9._-')
# Per-invocation escape hatch, never a durable identity: persisting it
# would rebind THIS cwd's slug for every later env-less run (observed: a
# test exporting GSTACK_PROJECT_SLUG from the repo root rebound the whole
# repo's session state to the test's slug).
SLUG_FROM_ENV=1
fi
# 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=""
local parent="" depth=0
# Terminate on dirname's FIXED POINT, not on a literal "/": under git-bash
# on Windows a mixed-form path walks C:/Users -> C: -> . -> . forever, which
# hung every bin that evals gstack-slug (caught by windows-free-tests CI).
# The depth cap is belt-and-braces for exotic path forms (UNC, //server).
while [[ -n "$dir" && "$dir" != "/" && $depth -lt 64 ]]; 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
parent=$(dirname "$dir")
[[ "$parent" == "$dir" ]] && break # dirname fixed point (C:/, ., //srv)
dir="$parent"
depth=$((depth + 1))
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
}
# 1a. Outermost REMOTE-BEARING repo root: walk the same ancestor chain and
# track the outermost dir that has a `.git` entry (directory for normal
# clones, FILE for git-worktrees/submodules — `git -C` resolves a
# worktree's remote through its main clone) AND whose `origin` remote
# resolves. This is the canonical-identity walk: a marker-only ancestor
# with no resolvable origin (stray empty ~/.git, stray package.json)
# cannot win here, so it cannot hijack remote-derived identity the way
# it can hijack the marker walk above. Nested-repo semantics preserved:
# an inner repo under an outer canonical-remote repo still resolves to
# the OUTER repo's remote (outermost wins), same as before.
# Note: git spawns only at `.git`-bearing ancestors — typically one.
_outermost_remote_repo() {
local dir="$1"
local outermost="" parent="" depth=0
while [[ -n "$dir" && "$dir" != "/" && $depth -lt 64 ]]; do
if [[ -e "$dir/.git" ]] && git -C "$dir" remote get-url origin >/dev/null 2>&1; then
outermost="$dir"
fi
parent=$(dirname "$dir")
[[ "$parent" == "$dir" ]] && break # dirname fixed point (C:/, ., //srv)
dir="$parent"
depth=$((depth + 1))
done
printf '%s' "$outermost"
}
# 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
# Lazy, memoized remote discovery. Needed on exactly two paths: fresh
# resolution (no usable cache) and the degraded-ancestor heal check below.
# Gating it keeps ordinary cache hits git-spawn-free.
REMOTE_ROOT=""
REMOTE_URL=""
_REMOTE_RESOLVED=0
_resolve_remote() {
if [[ "$_REMOTE_RESOLVED" -eq 1 ]]; then return 0; fi
_REMOTE_RESOLVED=1
REMOTE_ROOT=$(_outermost_remote_repo "$PROJECT_DIR")
if [[ -n "$REMOTE_ROOT" ]]; then
REMOTE_URL=$(git -C "$REMOTE_ROOT" remote get-url origin 2>/dev/null) || REMOTE_URL=""
fi
return 0
}
# 1b. Cached identity is STICKY (#2212): a project that used gstack before it
# adopted a git remote keeps its pre-origin slug — recomputing from the
# remote here would rename the project mid-life and orphan everything
# under ~/.gstack/projects/<slug>/. TWO provable bug shapes are exempt
# and fall through to recompute (self-heal):
# - Old-bug shape (#1125): the pre-walk-up resolver cached basename(pwd)
# for a SUBDIRECTORY of the real project — cached == pwd basename while
# the walk-up says pwd is NOT the project root.
# - Degraded-ancestor shape (2026-08-17): the pre-remote-first resolver
# cached basename(PROJECT_ROOT) for a marker-only ancestor (stray
# ~/.git) that is NOT the remote-bearing repo — cached == the marker
# root's basename while a remote-bearing repo BELOW it exists. Legit
# #2212 stickiness is safe: there the repo that adopted the remote IS
# the marker root (REMOTE_ROOT == PROJECT_ROOT), so the heal never fires.
if [[ -z "$SLUG" && -f "$CACHE_FILE" ]]; then
_CACHED=$(cat "$CACHE_FILE" 2>/dev/null | tr -cd 'a-zA-Z0-9._-')
if [[ -n "$_CACHED" ]]; then
_PWD_BASE=$(basename "$PROJECT_DIR" | tr -cd 'a-zA-Z0-9._-')
_ROOT_BASE=""
if [[ -n "$PROJECT_ROOT" ]]; then
_ROOT_BASE=$(basename "$PROJECT_ROOT" | tr -cd 'a-zA-Z0-9._-')
fi
if [[ "$_CACHED" == "$_PWD_BASE" && -n "$PROJECT_ROOT" && "$PROJECT_ROOT" != "$PROJECT_DIR" ]]; then
: # old-bug shape — recompute below and self-heal the cache
elif [[ -n "$PROJECT_ROOT" && "$_CACHED" == "$_ROOT_BASE" ]] \
&& { _resolve_remote; [[ -n "$REMOTE_URL" && "$REMOTE_ROOT" != "$PROJECT_ROOT" ]]; }; then
: # degraded-ancestor shape — recompute below and self-heal the cache
else
SLUG="$_CACHED"
fi
fi
fi
# 2. Canonical remote-derived slug. Sourced from the outermost remote-bearing
# repo (see 1a) — NOT from PROJECT_ROOT, which may be a marker-only
# ancestor with no remote. Parse kept byte-identical to
# browse/bin/remote-slug (strip trailing .git, then owner/repo → owner-repo)
# so the two bins agree on every canonical-remote repo, worktrees included.
# Parity pinned by test/gstack-slug-parity.test.ts.
if [[ -z "$SLUG" ]]; then
_resolve_remote
if [[ -n "$REMOTE_URL" ]]; then
RAW_SLUG=$(printf '%s' "${REMOTE_URL%.git}" | sed -E 's#.*[:/]([^/]+)/([^/]+)$#\1-\2#')
SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-')
fi
fi
# 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 — 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._-')
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 ]]; then
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
RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || RAW_BRANCH=""
BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
BRANCH="${BRANCH:-unknown}"
echo "SLUG=$SLUG"
echo "BRANCH=$BRANCH"