mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
Found while closing the wave's eval gate: a test exporting GSTACK_PROJECT_SLUG from the repo root persisted the override into the cwd slug cache, silently rebinding the ENTIRE repo's session state (evals, decisions, timelines) to the test's slug for every later env-less run. The escape hatch is per-invocation by contract — it no longer writes the cache. The cache dir also hardcoded $HOME while lib/bin-context.ts's native port (#2561) reads it GSTACK_HOME-aware, so temp-home test runs littered the real ~/.gstack (observed: 2,528 stale temp-cwd entries, swept). Writer and reader now key the same GSTACK_HOME-aware cache; regression tests pin both behaviors. Also raises the cso --diff eval budget (240s/25t → 360s/40t): transcript-verified, the wave's legitimately-grown audit session completes the report and dies in closing telemetry at ~215s under the old budget; the full-audit sibling already runs at 300s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
192 lines
9.2 KiB
Bash
Executable File
192 lines
9.2 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. 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
|
|
|
|
# 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
|
|
}
|
|
|
|
# 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/<slug>/. 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" && "$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"
|