#!/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 # # 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 -P 2>/dev/null || 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}" sanitize_slug() { printf '%s' "$1" | tr -cd 'a-zA-Z0-9._-' } find_slug_override() { local dir="$PROJECT_DIR" while [[ -n "$dir" && "$dir" != "/" ]]; do if [[ -f "$dir/.gstack-slug" ]]; then head -n 1 "$dir/.gstack-slug" 2>/dev/null | tr -d '\r\n' || true return 0 fi dir="$(dirname "$dir")" done return 1 } # 1. Explicit project overrides beat cache and git inference. This lets users # recover from stale slug-cache entries without editing cache internals. OVERRIDE_SLUG=$(find_slug_override 2>/dev/null || true) if [[ -n "$OVERRIDE_SLUG" ]]; then SLUG=$(sanitize_slug "$OVERRIDE_SLUG") fi # 2. If the current directory is the git root, compute from that repo's remote. # If it is only a subdirectory of a parent repo, do not inherit the parent # repo's identity; use the directory basename instead. if [[ -z "${SLUG:-}" ]]; then GIT_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null) || GIT_TOPLEVEL="" if [[ -n "$GIT_TOPLEVEL" ]]; then GIT_TOPLEVEL=$(cd "$GIT_TOPLEVEL" 2>/dev/null && pwd -P) || GIT_TOPLEVEL="" fi if [[ -n "$GIT_TOPLEVEL" && "$GIT_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=$(sanitize_slug "$RAW_SLUG") fi elif [[ -n "$GIT_TOPLEVEL" ]]; then SLUG=$(sanitize_slug "$(basename "$PROJECT_DIR")") fi fi # 3. Cache is a fallback for transient git/remote failures, not an immutable # source of truth when override or current repo inference is available. if [[ -z "${SLUG:-}" && -f "$CACHE_FILE" ]]; then SLUG=$(sanitize_slug "$(cat "$CACHE_FILE")") fi # 4. Fallback to basename only when there is no usable override, repo, or cache. SLUG="${SLUG:-$(sanitize_slug "$(basename "$PROJECT_DIR")")}" # 4b. Unconditional final sanitize before the value is echoed into `eval`/`source` # output or written to cache. Every source above (override, remote, basename, # and the cache read at step 3) already runs sanitize_slug, but filtering here # too keeps the [a-zA-Z0-9._-] invariant promised in the header on every path — # preserving the defense against a poisoned ~/.gstack/slug-cache/ injecting # shell into `eval "$(gstack-slug)"` — and heals such a cache on the next write. SLUG=$(sanitize_slug "${SLUG:-}") # 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="" 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 RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || RAW_BRANCH="" BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr -cd 'a-zA-Z0-9._-') BRANCH="${BRANCH:-unknown}" echo "SLUG=$SLUG" echo "BRANCH=$BRANCH"