This commit is contained in:
Jayesh Betala 2026-06-03 06:33:09 +00:00 committed by GitHub
commit e63234399c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 116 additions and 20 deletions

View File

@ -8,38 +8,71 @@
set -euo pipefail set -euo pipefail
CACHE_DIR="$HOME/.gstack/slug-cache" CACHE_DIR="$HOME/.gstack/slug-cache"
PROJECT_DIR="$(pwd)" PROJECT_DIR="$(pwd -P 2>/dev/null || pwd)"
# Encode absolute path as cache key: /Users/j/foo → _Users_j_foo # Encode absolute path as cache key: /Users/j/foo → _Users_j_foo
CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_') CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
CACHE_FILE="${CACHE_DIR}/${CACHE_KEY}" CACHE_FILE="${CACHE_DIR}/${CACHE_KEY}"
# 1. Try cached slug first (guarantees consistency across sessions) sanitize_slug() {
if [[ -f "$CACHE_FILE" ]]; then printf '%s' "$1" | tr -cd 'a-zA-Z0-9._-'
SLUG=$(cat "$CACHE_FILE") }
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 fi
# 2. If no cache, compute from git remote (separated from pipeline to avoid # 2. If the current directory is the git root, compute from that repo's remote.
# pipefail swallowing the error and producing an empty slug) # 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 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="" REMOTE_URL=$(git remote get-url origin 2>/dev/null) || REMOTE_URL=""
if [[ -n "$REMOTE_URL" ]]; then if [[ -n "$REMOTE_URL" ]]; then
RAW_SLUG=$(printf '%s' "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') RAW_SLUG=$(printf '%s' "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-')
SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-') SLUG=$(sanitize_slug "$RAW_SLUG")
fi
elif [[ -n "$GIT_TOPLEVEL" ]]; then
SLUG=$(sanitize_slug "$(basename "$PROJECT_DIR")")
fi fi
fi fi
# 3. Fallback to basename only when there's truly no git remote configured # 3. Cache is a fallback for transient git/remote failures, not an immutable
SLUG="${SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}" # 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
# 3b. Re-sanitize unconditionally before the value is echoed into `eval`/`source` # 4. Fallback to basename only when there is no usable override, repo, or cache.
# output. The compute (2) and fallback (3) paths already filter, but a value SLUG="${SLUG:-$(sanitize_slug "$(basename "$PROJECT_DIR")")}"
# 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).
SLUG=$(printf '%s' "$SLUG" | tr -cd 'a-zA-Z0-9._-')
# 4. Cache the slug for future sessions (atomic write, fail silently) # 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/<key> 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 if [[ -n "$SLUG" ]]; then
mkdir -p "$CACHE_DIR" 2>/dev/null || true mkdir -p "$CACHE_DIR" 2>/dev/null || true
CACHE_TMP=$(mktemp "$CACHE_DIR/.slug-XXXXXX" 2>/dev/null) || CACHE_TMP="" CACHE_TMP=$(mktemp "$CACHE_DIR/.slug-XXXXXX" 2>/dev/null) || CACHE_TMP=""

View File

@ -4,6 +4,7 @@ import { ALL_COMMANDS, COMMAND_DESCRIPTIONS, READ_COMMANDS, WRITE_COMMANDS, META
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot'; import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..'); const ROOT = path.resolve(import.meta.dir, '..');
@ -989,6 +990,68 @@ describe('gstack-slug', () => {
expect(slug).toMatch(/^[a-zA-Z0-9._-]+$/); expect(slug).toMatch(/^[a-zA-Z0-9._-]+$/);
expect(branch).toMatch(/^[a-zA-Z0-9._-]+$/); expect(branch).toMatch(/^[a-zA-Z0-9._-]+$/);
}); });
test('subdirectory inside parent git repo falls back to basename and refreshes poisoned cache', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-slug-subdir-'));
try {
const home = path.join(tmp, 'home');
const outer = path.join(tmp, 'workspace');
const child = path.join(outer, 'IoTopia');
fs.mkdirSync(child, { recursive: true });
fs.mkdirSync(path.join(home, '.gstack', 'slug-cache'), { recursive: true });
Bun.spawnSync(['git', 'init', '-q'], { cwd: outer });
Bun.spawnSync(['git', 'remote', 'add', 'origin', 'git@github.com:me/workspace.git'], { cwd: outer });
const cacheKey = fs.realpathSync(child).replaceAll('/', '_');
fs.writeFileSync(path.join(home, '.gstack', 'slug-cache', cacheKey), 'me-workspace');
const result = Bun.spawnSync([SLUG_BIN], {
cwd: child,
env: { ...process.env, HOME: home },
stdout: 'pipe',
stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
expect(result.stdout.toString()).toContain('SLUG=IoTopia');
expect(fs.readFileSync(path.join(home, '.gstack', 'slug-cache', cacheKey), 'utf-8')).toBe('IoTopia');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('.gstack-slug override beats git inference and stale cache', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-slug-override-'));
try {
const home = path.join(tmp, 'home');
const project = path.join(tmp, 'project');
const nested = path.join(project, 'wp-content', 'themes', 'theme');
fs.mkdirSync(nested, { recursive: true });
fs.mkdirSync(path.join(home, '.gstack', 'slug-cache'), { recursive: true });
fs.writeFileSync(path.join(project, '.gstack-slug'), 'canonical-project');
Bun.spawnSync(['git', 'init', '-q'], { cwd: nested });
Bun.spawnSync(['git', 'remote', 'add', 'origin', 'git@github.com:org/theme.git'], { cwd: nested });
const cacheKey = fs.realpathSync(nested).replaceAll('/', '_');
fs.writeFileSync(path.join(home, '.gstack', 'slug-cache', cacheKey), 'org-theme');
const result = Bun.spawnSync([SLUG_BIN], {
cwd: nested,
env: { ...process.env, HOME: home },
stdout: 'pipe',
stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
expect(result.stdout.toString()).toContain('SLUG=canonical-project');
expect(fs.readFileSync(path.join(home, '.gstack', 'slug-cache', cacheKey), 'utf-8')).toBe('canonical-project');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('eval sets variables under bash with set -euo pipefail', () => { test('eval sets variables under bash with set -euo pipefail', () => {
const result = Bun.spawnSync( const result = Bun.spawnSync(
['bash', '-c', 'set -euo pipefail; eval "$(./bin/gstack-slug 2>/dev/null)"; echo "SLUG=$SLUG"; echo "BRANCH=$BRANCH"'], ['bash', '-c', 'set -euo pipefail; eval "$(./bin/gstack-slug 2>/dev/null)"; echo "SLUG=$SLUG"; echo "BRANCH=$BRANCH"'],