#!/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. 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" 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. if [[ -n "${GSTACK_PROJECT_SLUG:-}" ]]; then SLUG=$(printf '%s' "$GSTACK_PROJECT_SLUG" | tr -cd 'a-zA-Z0-9._-') 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 } # 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 # 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//. The ONE exception is the provable # old-bug shape (#1125): the pre-walk-up resolver cached basename(pwd) # for a SUBDIRECTORY of the real project — if the cached value equals this # pwd's basename while the walk-up says pwd is NOT the project root, the # cache came from that bug, not from legitimate identity; fall through and # recompute so it heals. 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._-') if [[ "$_CACHED" == "$_PWD_BASE" && -n "$PROJECT_ROOT" && "$PROJECT_ROOT" != "$PROJECT_DIR" ]]; then : # old-bug shape — recompute below and self-heal the cache else SLUG="$_CACHED" fi fi 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. 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" ]]; 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"