mirror of https://github.com/garrytan/gstack.git
Merge 133c6cbd98 into c43c850cae
This commit is contained in:
commit
e63234399c
|
|
@ -8,38 +8,71 @@
|
|||
set -euo pipefail
|
||||
|
||||
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
|
||||
CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
|
||||
CACHE_FILE="${CACHE_DIR}/${CACHE_KEY}"
|
||||
|
||||
# 1. Try cached slug first (guarantees consistency across sessions)
|
||||
if [[ -f "$CACHE_FILE" ]]; then
|
||||
SLUG=$(cat "$CACHE_FILE")
|
||||
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 no cache, compute from git remote (separated from pipeline to avoid
|
||||
# pipefail swallowing the error and producing an empty slug)
|
||||
# 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=$(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
|
||||
|
||||
# 3. Fallback to basename only when there's truly no git remote configured
|
||||
SLUG="${SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
|
||||
# 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
|
||||
|
||||
# 3b. Re-sanitize unconditionally before the value is echoed into `eval`/`source`
|
||||
# output. The compute (2) and fallback (3) paths already filter, but a value
|
||||
# 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. Fallback to basename only when there is no usable override, repo, or cache.
|
||||
SLUG="${SLUG:-$(sanitize_slug "$(basename "$PROJECT_DIR")")}"
|
||||
|
||||
# 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
|
||||
mkdir -p "$CACHE_DIR" 2>/dev/null || true
|
||||
CACHE_TMP=$(mktemp "$CACHE_DIR/.slug-XXXXXX" 2>/dev/null) || CACHE_TMP=""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ALL_COMMANDS, COMMAND_DESCRIPTIONS, READ_COMMANDS, WRITE_COMMANDS, META
|
|||
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
|
|
@ -989,6 +990,68 @@ describe('gstack-slug', () => {
|
|||
expect(slug).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', () => {
|
||||
const result = Bun.spawnSync(
|
||||
['bash', '-c', 'set -euo pipefail; eval "$(./bin/gstack-slug 2>/dev/null)"; echo "SLUG=$SLUG"; echo "BRANCH=$BRANCH"'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue