mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-12 16:08:59 +02:00
Merge origin/main (v1.64.0.0) into garrytan/time-attack-fork-review
Both waves fixed several of the same bugs; resolutions keep whichever shape this branch's tests pin (#2018 jq bind, #1798 set-- pattern, stop-ack, lock errors, polyfill windowsHide) and take main's richer codex Step 2A (it absorbed the same mktemp fix). True unions: memory- ingest keeps main's capability-probed --include-gitignored inside our GIT_CEILING defense; setup wraps main's Playwright platform override in our stale-healing install lock; package.json takes main's diff@^9 and the combined test glob (design/test + ios-qa/daemon/test, 30s timeout). Generated SKILL.md files regenerated from resolved templates, never hand-picked. Ship goldens refreshed; parity/carve budgets re-measured for the summed preamble growth of both waves (itemized per entry).
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
#
|
||||
# Usage:
|
||||
# gstack-artifacts-init [--remote <url>] [--host github|gitlab|manual]
|
||||
# [--push-protocol auto|https|ssh]
|
||||
# [--url-form-supported true|false]
|
||||
#
|
||||
# Interactive by default. Pass --remote to skip the host prompt.
|
||||
@@ -52,17 +53,25 @@ _artifacts_host() {
|
||||
|
||||
REMOTE_URL=""
|
||||
HOST_PREF=""
|
||||
PUSH_PROTOCOL="auto"
|
||||
REMOTE_SOURCE="provider"
|
||||
URL_FORM_SUPPORTED="false"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--remote) REMOTE_URL="$2"; shift 2 ;;
|
||||
--remote) REMOTE_URL="$2"; REMOTE_SOURCE="explicit"; shift 2 ;;
|
||||
--host) HOST_PREF="$2"; shift 2 ;;
|
||||
--push-protocol) PUSH_PROTOCOL="$2"; shift 2 ;;
|
||||
--url-form-supported) URL_FORM_SUPPORTED="$2"; shift 2 ;;
|
||||
--help|-h) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "Unknown flag: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$PUSH_PROTOCOL" in
|
||||
auto|https|ssh) ;;
|
||||
*) echo "Invalid --push-protocol: $PUSH_PROTOCOL (expected auto|https|ssh)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ---- preconditions ----
|
||||
mkdir -p "$GSTACK_HOME"
|
||||
|
||||
@@ -99,6 +108,7 @@ if command -v glab >/dev/null 2>&1 && glab auth status >/dev/null 2>&1; then gla
|
||||
# ---- choose remote URL ----
|
||||
if [ -z "$REMOTE_URL" ] && [ -n "$EXISTING_REMOTE" ]; then
|
||||
REMOTE_URL="$EXISTING_REMOTE"
|
||||
REMOTE_SOURCE="existing"
|
||||
echo "Using existing remote: $REMOTE_URL"
|
||||
fi
|
||||
|
||||
@@ -174,6 +184,7 @@ if [ -z "$REMOTE_URL" ]; then
|
||||
echo "No URL provided. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
REMOTE_SOURCE="manual"
|
||||
;;
|
||||
*) echo "Unknown --host: $HOST_PREF (expected github|gitlab|manual)" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -181,7 +192,7 @@ fi
|
||||
|
||||
# ---- canonicalize to HTTPS form ----
|
||||
# We store HTTPS in ~/.gstack-artifacts-remote.txt (codex Finding #10:
|
||||
# canonical form, derive SSH at push time via gstack-artifacts-url --to ssh).
|
||||
# canonical form, derive the configured push form via gstack-artifacts-url).
|
||||
# Unrecognized forms (local bare paths, file:// URLs, self-hosted gitea, etc.)
|
||||
# pass through verbatim so unusual remotes still work.
|
||||
CANONICAL_HTTPS=$("$URL_BIN" --to https "$REMOTE_URL" 2>/dev/null || echo "")
|
||||
@@ -189,21 +200,50 @@ if [ -z "$CANONICAL_HTTPS" ]; then
|
||||
CANONICAL_HTTPS="$REMOTE_URL"
|
||||
fi
|
||||
|
||||
# Use SSH for git push (more reliable for repeated pushes than HTTPS+token).
|
||||
# Fall back to the canonical input if derivation fails.
|
||||
PUSH_URL=$("$URL_BIN" --to ssh "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICAL_HTTPS")
|
||||
# Choose the push protocol without overriding an explicit URL. Provider-created
|
||||
# remotes honor the provider CLI's git protocol; GitHub CLI defaults to HTTPS.
|
||||
# Unknown/local URL forms pass through unchanged.
|
||||
RESOLVED_PUSH_PROTOCOL="$PUSH_PROTOCOL"
|
||||
if [ "$RESOLVED_PUSH_PROTOCOL" = "auto" ]; then
|
||||
case "$REMOTE_SOURCE" in
|
||||
explicit|existing|manual)
|
||||
case "$REMOTE_URL" in
|
||||
git@*|ssh://*) RESOLVED_PUSH_PROTOCOL="ssh" ;;
|
||||
http://*|https://*) RESOLVED_PUSH_PROTOCOL="https" ;;
|
||||
*) RESOLVED_PUSH_PROTOCOL="preserve" ;;
|
||||
esac
|
||||
;;
|
||||
provider)
|
||||
CONFIGURED_PROTOCOL=""
|
||||
case "$HOST_PREF" in
|
||||
github) CONFIGURED_PROTOCOL=$(gh config get git_protocol 2>/dev/null || echo "") ;;
|
||||
gitlab) CONFIGURED_PROTOCOL=$(glab config get git_protocol 2>/dev/null || echo "") ;;
|
||||
esac
|
||||
case "$CONFIGURED_PROTOCOL" in
|
||||
ssh|https) RESOLVED_PUSH_PROTOCOL="$CONFIGURED_PROTOCOL" ;;
|
||||
*) RESOLVED_PUSH_PROTOCOL="https" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "$RESOLVED_PUSH_PROTOCOL" = "preserve" ]; then
|
||||
PUSH_URL="$REMOTE_URL"
|
||||
else
|
||||
PUSH_URL=$("$URL_BIN" --to "$RESOLVED_PUSH_PROTOCOL" "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICAL_HTTPS")
|
||||
fi
|
||||
|
||||
# ---- verify push URL is reachable ----
|
||||
echo "Verifying remote connectivity: $PUSH_URL"
|
||||
if ! _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-remote-ls-remote "user ran gstack-artifacts-init" \
|
||||
bash -c 'git ls-remote "$1" >/dev/null 2>&1' _ "$PUSH_URL"; then
|
||||
cat >&2 <<EOF
|
||||
Remote not reachable via SSH: $PUSH_URL
|
||||
Remote not reachable via $RESOLVED_PUSH_PROTOCOL: $PUSH_URL
|
||||
This could mean:
|
||||
- Wrong URL
|
||||
- SSH key not added to your git host (GitHub: gh ssh-key list; GitLab: glab ssh-key list)
|
||||
- Credentials for $RESOLVED_PUSH_PROTOCOL are not configured for your git host
|
||||
- Network issue
|
||||
Fix and re-run gstack-artifacts-init.
|
||||
Fix and re-run gstack-artifacts-init, or choose --push-protocol https|ssh.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
@@ -389,7 +429,7 @@ cat <<EOF
|
||||
gstack-artifacts-init complete.
|
||||
Repo: $GSTACK_HOME (git)
|
||||
Remote: $CANONICAL_HTTPS (canonical form, in ~/.gstack-artifacts-remote.txt)
|
||||
Push: $PUSH_URL (derived SSH form for git push)
|
||||
Push: $PUSH_URL ($RESOLVED_PUSH_PROTOCOL form for git push)
|
||||
|
||||
EOF
|
||||
|
||||
|
||||
+18
-8
@@ -259,6 +259,16 @@ resolve_user_slug() {
|
||||
printf '%s' "$_slug"
|
||||
}
|
||||
|
||||
read_config_value() {
|
||||
local key="$1"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
return 0
|
||||
fi
|
||||
grep -E "^${key}:" "$CONFIG_FILE" 2>/dev/null \
|
||||
| tail -1 \
|
||||
| sed -E "s/^${key}:[[:space:]]*//; s/[[:space:]]+$//"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
get)
|
||||
KEY="${2:?Usage: gstack-config get <key>}"
|
||||
@@ -266,12 +276,11 @@ case "${1:-}" in
|
||||
# endpoint-namespaced keys introduced by the brain-aware planning layer).
|
||||
# Endpoint ids are sha8/sha16 hex for remote MCP URLs, or the literal
|
||||
# "local" for stdio/PGLite engines (see endpoint_hash).
|
||||
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
|
||||
if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
|
||||
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Use literal match for keys containing @ (endpoint ids), regex otherwise
|
||||
VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-zA-Z0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
||||
VALUE=$(read_config_value "$KEY" || true)
|
||||
if [ -z "$VALUE" ]; then
|
||||
VALUE=$(lookup_default "$KEY")
|
||||
fi
|
||||
@@ -282,7 +291,7 @@ case "${1:-}" in
|
||||
VALUE="${3:?Usage: gstack-config set <key> <value>}"
|
||||
# Validate key (alphanumeric + underscore + optional @<endpoint-id> suffix).
|
||||
# Accepts hex hashes and the literal "local" from endpoint_hash.
|
||||
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
|
||||
if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
|
||||
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -336,14 +345,15 @@ case "${1:-}" in
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE"
|
||||
fi
|
||||
# Escape sed special chars in value and drop embedded newlines
|
||||
ESC_VALUE="$(printf '%s' "$VALUE" | head -1 | sed 's/[&/\]/\\&/g')"
|
||||
# Drop embedded newlines, then escape sed replacement metacharacters.
|
||||
SAFE_VALUE="$(printf '%s' "$VALUE" | head -1)"
|
||||
ESC_VALUE="$(printf '%s' "$SAFE_VALUE" | sed 's/[&/\]/\\&/g')"
|
||||
if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then
|
||||
# Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg)
|
||||
_tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")"
|
||||
sed "/^${KEY}:/s/.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE"
|
||||
else
|
||||
echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE"
|
||||
echo "${KEY}: ${SAFE_VALUE}" >> "$CONFIG_FILE"
|
||||
fi
|
||||
# Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call)
|
||||
if [ "$KEY" = "skill_prefix" ] && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then
|
||||
@@ -361,7 +371,7 @@ case "${1:-}" in
|
||||
skill_prefix checkpoint_mode checkpoint_push explain_level \
|
||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do
|
||||
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
||||
VALUE=$(read_config_value "$KEY" || true)
|
||||
SOURCE="default"
|
||||
if [ -n "$VALUE" ]; then
|
||||
SOURCE="set"
|
||||
|
||||
@@ -55,7 +55,7 @@ do_migrate() {
|
||||
|
||||
# Run migration in a temp file, then atomic rename.
|
||||
local TMPOUT
|
||||
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.XXXXXX.tmp")
|
||||
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
|
||||
trap 'rm -f "$TMPOUT"' EXIT
|
||||
|
||||
cat "$LEGACY_FILE" | bun -e "
|
||||
@@ -182,7 +182,7 @@ do_log_session() {
|
||||
ensure_profile
|
||||
|
||||
local TMPOUT
|
||||
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.XXXXXX.tmp")
|
||||
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
|
||||
trap 'rm -f "$TMPOUT"' EXIT
|
||||
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun -e "
|
||||
|
||||
@@ -1408,9 +1408,43 @@ export function resolveImportTimeoutMs(
|
||||
return n;
|
||||
}
|
||||
|
||||
function runGbrainImport(
|
||||
/**
|
||||
* True when the import failed because the installed gbrain predates
|
||||
* --include-gitignored. gbrain's subcommand --help is generic (no flag list),
|
||||
* so the only reliable probe is the attempt itself.
|
||||
*/
|
||||
function failedOnUnknownIncludeGitignored(status: number | null, stderr: string): boolean {
|
||||
if (status === 0 || status === null) return false;
|
||||
return /(unknown|unexpected|unrecognized|invalid)[^\n]*--include-gitignored|--include-gitignored[^\n]*(unknown|unexpected|unrecognized|invalid)/i.test(
|
||||
stderr,
|
||||
);
|
||||
}
|
||||
|
||||
async function runGbrainImport(
|
||||
stagingDir: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean }> {
|
||||
const first = await runGbrainImportOnce(stagingDir, timeoutMs, true);
|
||||
if (failedOnUnknownIncludeGitignored(first.status, first.stderr)) {
|
||||
// Older gbrain: retry without the flag. If .gitignore then hides the
|
||||
// staged pages, the imported<staged reconciliation guard below refuses
|
||||
// to advance state and names the remedy — loud failure, never silent
|
||||
// loss, and never a hard-block for gbrain versions that don't need the
|
||||
// flag's semantics.
|
||||
console.error(
|
||||
"[memory-ingest] installed gbrain does not support --include-gitignored — " +
|
||||
"retrying without it. If the import then collects 0 files, upgrade gbrain " +
|
||||
"(gstack-gbrain-install) so staged pages inside gitignored dirs are visible.",
|
||||
);
|
||||
return runGbrainImportOnce(stagingDir, timeoutMs, false);
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
function runGbrainImportOnce(
|
||||
stagingDir: string,
|
||||
timeoutMs: number,
|
||||
includeGitignored: boolean,
|
||||
): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean }> {
|
||||
installSignalForwarder();
|
||||
return new Promise((resolve) => {
|
||||
@@ -1450,7 +1484,13 @@ function runGbrainImport(
|
||||
: ceiling,
|
||||
};
|
||||
const child = spawnGbrainAsync(
|
||||
["import", stagingDir, "--no-embed", "--include-gitignored", "--json"],
|
||||
[
|
||||
"import",
|
||||
stagingDir,
|
||||
"--no-embed",
|
||||
...(includeGitignored ? ["--include-gitignored"] : []),
|
||||
"--json",
|
||||
],
|
||||
{ baseEnv },
|
||||
);
|
||||
_activeImportChild = child;
|
||||
@@ -1848,6 +1888,49 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
|
||||
);
|
||||
failed += failedSources.size;
|
||||
|
||||
// Reconcile gbrain's own accounting against what we staged. Without this,
|
||||
// a batch that gbrain never SAW is indistinguishable from a batch that
|
||||
// succeeded: readNewFailures() only reports PER-FILE failures, so when
|
||||
// `gbrain import` collects zero files it writes nothing to
|
||||
// sync-failures.jsonl, failedSources is empty, and every prepared file
|
||||
// gets state-recorded as ingested. The pass then reports "N written"
|
||||
// while the brain gained nothing — and because state now says "done",
|
||||
// no future run retries. Silent, permanent data loss.
|
||||
//
|
||||
// Observed cause: `gbrain import` honours .gitignore, and
|
||||
// `gstack-artifacts-init` writes `.gitignore = "*"` into $GSTACK_HOME.
|
||||
// makeStagingDir() stages under $GSTACK_HOME, so on any machine that has
|
||||
// run artifacts-init, collect_files returns 0 for every batch.
|
||||
//
|
||||
// `skipped` counts content_hash no-ops, which ARE successful landings.
|
||||
const expectedLandings = prep.prepared.length - failedSources.size;
|
||||
const accountedLandings =
|
||||
(importJson.imported ?? 0) + (importJson.skipped ?? 0);
|
||||
if (accountedLandings < expectedLandings) {
|
||||
const collected =
|
||||
importJson.total_files !== undefined
|
||||
? ` gbrain collected ${importJson.total_files} file(s) from the staging dir.`
|
||||
: "";
|
||||
const msg =
|
||||
`gbrain import accounted for ${accountedLandings} of ${expectedLandings} staged page(s) ` +
|
||||
`(imported=${importJson.imported ?? 0}, unchanged=${importJson.skipped ?? 0}).${collected} ` +
|
||||
`Refusing to advance state — the unaccounted pages would be marked ingested without ` +
|
||||
`landing in the brain. If the count is 0, check whether ${stagingDir} is inside a git ` +
|
||||
`repo that ignores it (gbrain import honours .gitignore).`;
|
||||
console.error(`[memory-ingest] ERR: ${msg}`);
|
||||
failed += prep.prepared.length;
|
||||
return {
|
||||
written: 0,
|
||||
skipped_secret: prep.skippedSecret,
|
||||
skipped_dedup: prep.skippedDedup,
|
||||
skipped_unattributed: prep.skippedUnattributed,
|
||||
failed,
|
||||
duration_ms: Date.now() - t0,
|
||||
partial_pages: prep.partialPages,
|
||||
system_error: msg,
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 3: state recording. Only files that landed in gbrain get
|
||||
// their mtime+sha256 stamped. Failed source paths are deliberately
|
||||
// left un-state'd so the next run re-prepares them and gbrain's
|
||||
|
||||
@@ -88,6 +88,20 @@ function parseProviders(s: string | undefined): Array<'claude' | 'gpt' | 'gemini
|
||||
return seen.size ? Array.from(seen) : ['claude'];
|
||||
}
|
||||
|
||||
function parsePositiveIntegerFlag(name: string, def: string): number {
|
||||
const raw = arg(name, def);
|
||||
if (!raw || !/^\+?[1-9]\d*$/.test(raw)) {
|
||||
console.error(`${name} requires a positive integer`);
|
||||
process.exit(1);
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
console.error(`${name} requires a positive integer`);
|
||||
process.exit(1);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolvePrompt(positional: string | undefined): string {
|
||||
const inline = arg('--prompt');
|
||||
if (inline) return inline;
|
||||
@@ -107,7 +121,7 @@ async function main(): Promise<void> {
|
||||
const prompt = resolvePrompt(positional);
|
||||
const providers = parseProviders(arg('--models'));
|
||||
const workdir = arg('--workdir', process.cwd())!;
|
||||
const timeoutMs = parseInt(arg('--timeout-ms', '300000')!, 10);
|
||||
const timeoutMs = parsePositiveIntegerFlag('--timeout-ms', '300000');
|
||||
const output = (arg('--output', 'table') as OutputFormat);
|
||||
const skipUnavailable = flag('--skip-unavailable');
|
||||
const doJudge = flag('--judge');
|
||||
|
||||
+21
-6
@@ -13,9 +13,15 @@
|
||||
# PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans
|
||||
# TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort)
|
||||
#
|
||||
# Security: output values are not sanitized — callers may receive paths with
|
||||
# shell-special characters if env vars contain them. Skills should always quote
|
||||
# expansions ("$GSTACK_STATE_ROOT", not $GSTACK_STATE_ROOT).
|
||||
# Output: values are emitted shell-quoted (printf %q) so `eval` round-trips them
|
||||
# byte-for-byte. This matters on Windows, where $TMP is a backslash path like
|
||||
# C:\Users\me\AppData\Local\Temp — with a bare `echo`, eval consumes the
|
||||
# backslashes as escapes and the caller gets C:UsersmeAppDataLocalTemp. A value
|
||||
# containing a space (C:\Program Files\Temp) is worse: eval word-splits it and
|
||||
# the variable ends up empty. Quoting here is the only fix that works, because
|
||||
# the corruption happens during eval, before the caller has anything to quote.
|
||||
# Callers should still quote expansions ("$GSTACK_STATE_ROOT") for the same
|
||||
# reason any path variable needs quoting.
|
||||
set -u
|
||||
|
||||
# State root: where gstack writes projects/, sessions/, analytics/.
|
||||
@@ -62,10 +68,19 @@ case "$_tmp_root" in
|
||||
*/) [ "$_tmp_root" != "/" ] && _tmp_root="${_tmp_root%/}" ;;
|
||||
esac
|
||||
|
||||
# Strip any trailing slash so consumers can safely concatenate "$TMP_ROOT/name"
|
||||
# without producing a double slash. On macOS $TMPDIR ends in `/` by default
|
||||
# (e.g. /var/folders/.../T/), which would otherwise yield paths like
|
||||
# `…/T//codex-err-…`. Normalizing at the source means every consumer benefits,
|
||||
# not just /codex.
|
||||
_tmp_root="${_tmp_root%/}"
|
||||
# A value of "/" collapses to "" above; restore it so TMP_ROOT is never empty.
|
||||
[ -z "$_tmp_root" ] && _tmp_root="/"
|
||||
|
||||
# Best-effort mkdir; if it fails (read-only fs, permission denied), the caller
|
||||
# will discover that on their own write attempt. Don't fail the eval here.
|
||||
mkdir -p "$_tmp_root" 2>/dev/null || true
|
||||
|
||||
echo "GSTACK_STATE_ROOT=$_state_root"
|
||||
echo "PLAN_ROOT=$_plan_root"
|
||||
echo "TMP_ROOT=$_tmp_root"
|
||||
printf 'GSTACK_STATE_ROOT=%q\n' "$_state_root"
|
||||
printf 'PLAN_ROOT=%q\n' "$_plan_root"
|
||||
printf 'TMP_ROOT=%q\n' "$_tmp_root"
|
||||
|
||||
@@ -5,10 +5,18 @@
|
||||
# Output: corrected title on stdout.
|
||||
#
|
||||
# Rule: PR titles MUST start with v<NEW_VERSION>. Three cases:
|
||||
# 1. Already starts with "v<NEW_VERSION> " -> no change.
|
||||
# 2. Starts with a different "v<digits and dots> " prefix -> replace prefix.
|
||||
# 1. Already starts with "v<NEW_VERSION>" -> no change.
|
||||
# 2. Starts with a different "v<digits and dots>" prefix -> replace prefix.
|
||||
# 3. No version prefix -> prepend "v<NEW_VERSION> ".
|
||||
#
|
||||
# Each version prefix may be followed by a space (then a description) OR sit at
|
||||
# the end of the title as a bare version with no description (e.g. "v1.2.3", the
|
||||
# format ship/CHANGELOG uses for version-only bumps). Both forms must be handled
|
||||
# in cases 1 and 2, otherwise a bare version falls through to case 3 and gets a
|
||||
# second prefix prepended, e.g. "v1.2.3" -> "v1.2.3.4 v1.2.3". The CI workflow
|
||||
# .github/workflows/pr-title-sync.yml feeds real PR titles through this and then
|
||||
# `gh pr edit`s the result, so the duplicated title would be written back.
|
||||
#
|
||||
# The version-prefix regex matches two or more dot-separated digit segments
|
||||
# (covers v1.2, v1.2.3, v1.2.3.4) so the rule is portable across repos that
|
||||
# use 3-part or 4-part versions, but does NOT strip plain words like
|
||||
@@ -33,12 +41,20 @@ fi
|
||||
|
||||
# Literal prefix match (case statement is glob-quoted by bash, but our
|
||||
# regex-validated NEW_VERSION has no glob metacharacters so this is safe).
|
||||
# Match both "v<NEW_VERSION> <description>" and a bare "v<NEW_VERSION>" title.
|
||||
case "$TITLE" in
|
||||
"v$NEW_VERSION "*)
|
||||
"v$NEW_VERSION "*|"v$NEW_VERSION")
|
||||
printf '%s\n' "$TITLE"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
REST=$(printf '%s' "$TITLE" | sed -E 's/^v[0-9]+(\.[0-9]+)+ //')
|
||||
printf 'v%s %s\n' "$NEW_VERSION" "$REST"
|
||||
# Strip an existing different version prefix whether it is followed by a space
|
||||
# (then a description) or sits at the end of the title (bare version).
|
||||
REST=$(printf '%s' "$TITLE" | sed -E 's/^v[0-9]+(\.[0-9]+)+( |$)//')
|
||||
if [ -n "$REST" ]; then
|
||||
printf 'v%s %s\n' "$NEW_VERSION" "$REST"
|
||||
else
|
||||
# Title was nothing but a (different) version prefix; emit the bare new one.
|
||||
printf 'v%s\n' "$NEW_VERSION"
|
||||
fi
|
||||
|
||||
+11
-3
@@ -168,9 +168,17 @@ if (j.recommended !== undefined) {
|
||||
if (j.recommended.length > 64) j.recommended = j.recommended.slice(0, 64);
|
||||
}
|
||||
|
||||
// followed_recommendation — compute if both sides present.
|
||||
if (j.recommended !== undefined && j.user_choice !== undefined) {
|
||||
j.followed_recommendation = j.user_choice === j.recommended;
|
||||
// followed_recommendation — compute if both sides present. An __unknown__
|
||||
// choice means extraction failed, not that the user rejected the
|
||||
// recommendation — leave the field absent so metrics can't be poisoned.
|
||||
// Strip a trailing (Recommended) marker from BOTH sides before comparing:
|
||||
// recommended usually arrives pre-stripped while user_choice is the raw
|
||||
// option label, so a user who picked the recommended option was scored as
|
||||
// NOT following it (#2400). NB: this JS lives inside a double-quoted
|
||||
// bun -e string — never use double quotes in it.
|
||||
if (j.recommended !== undefined && j.user_choice !== undefined && j.user_choice !== '__unknown__') {
|
||||
const stripRec = (s) => String(s).replace(/\s*\(recommended\)\s*$/i, '').trim();
|
||||
j.followed_recommendation = stripRec(j.user_choice) === stripRec(j.recommended);
|
||||
}
|
||||
|
||||
// session_id — kebab-friendly; <=64 chars
|
||||
|
||||
@@ -174,8 +174,8 @@ do_write() {
|
||||
process.exit(2);
|
||||
}
|
||||
if (!ALLOWED_SOURCES.includes(j.source)) {
|
||||
process.stderr.write('gstack-question-preference: invalid source \"' + j.source + '\"; allowed: ' + ALLOWED_SOURCES.join(', ') + '\n');
|
||||
process.exit(1);
|
||||
process.stderr.write('gstack-question-preference: rejected — source \"' + j.source + '\" is not user-originated (profile poisoning defense)\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Optional free_text — sanitize (no injection patterns, no newlines, <=300 chars)
|
||||
|
||||
+6
-1
@@ -73,10 +73,15 @@ function installPrepushHook(): void {
|
||||
}
|
||||
|
||||
// stdin is single-consume: capture it once, feed both the chained hook and ours.
|
||||
// The `printf x` sentinel preserves the trailing newline that `$(cat)` strips.
|
||||
// Without it, a chained shell pre-push.local built on `while read` silently
|
||||
// drops the final (often only) ref line and exits 0 — the guard reports
|
||||
// success having scanned nothing, i.e. it fails OPEN.
|
||||
const wrapper = `#!/usr/bin/env bash
|
||||
${MANAGED_MARKER}
|
||||
set -euo pipefail
|
||||
_input="$(cat)"
|
||||
_input="$(cat; printf x)"
|
||||
_input="\${_input%x}"
|
||||
_local="$(git rev-parse --git-path hooks/pre-push.local)"
|
||||
if [ -x "$_local" ]; then
|
||||
printf '%s' "$_input" | "$_local" "$@" || exit $?
|
||||
|
||||
+188
-27
@@ -80,21 +80,57 @@ function defaultRemoteBranch(): string {
|
||||
return "origin/main";
|
||||
}
|
||||
|
||||
/**
|
||||
* Base commit for a push whose remote tip we cannot use directly, ordered from
|
||||
* most precise to most conservative. Returns null when nothing can anchor the
|
||||
* range, i.e. the whole history really is new content.
|
||||
*/
|
||||
function unknownRemoteTipBase(localSha: string): string | null {
|
||||
// 1. The common case: a merge-base with the remote's default branch.
|
||||
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
|
||||
if (base) return base;
|
||||
|
||||
// 2. No merge-base. defaultRemoteBranch() guessed a ref that does not exist
|
||||
// (default branch named trunk/develop, origin/HEAD unset), or history is
|
||||
// disjoint. Anything reachable from localSha but from NO remote-tracking
|
||||
// branch is what this push actually adds; the parent of its oldest commit
|
||||
// is the real base.
|
||||
//
|
||||
// Without this we drop straight to EMPTY_TREE and re-scan content that is
|
||||
// already on the remote. That is not merely wasteful, it is wrong in two
|
||||
// ways: a secret pushed long ago gets re-reported as if THIS push
|
||||
// introduced it (telling the operator to rotate a key over someone else's
|
||||
// old commit), and on any real repository the input overshoots the
|
||||
// engine's byte cap, so `engine.input_too_large` blocks having scanned
|
||||
// NOTHING — "scans more, never less" inverted into "scans nothing".
|
||||
//
|
||||
// `--remotes` covers every remote, not just the push target: content
|
||||
// already published anywhere has already left this machine, so treating it
|
||||
// as pre-existing is deliberate. Git hands the remote name to pre-push in
|
||||
// argv, which this hook does not read; narrowing to it would only matter
|
||||
// for a repo that pushes secrets to one remote but not another.
|
||||
const newCommits = git(["rev-list", "--reverse", localSha, "--not", "--remotes"]).trim();
|
||||
if (newCommits) {
|
||||
const oldest = newCommits.split("\n")[0];
|
||||
const parent = git(["rev-parse", "--verify", `${oldest}^`]).trim();
|
||||
if (parent) return parent;
|
||||
// Oldest new commit is a root commit: there is no parent to anchor on.
|
||||
}
|
||||
|
||||
// 3. Nothing to anchor on — a genuinely fresh repository with no remote refs.
|
||||
// Every commit IS new content, so scanning it all is the correct answer.
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Return the added-line text for a ref update being pushed. */
|
||||
function addedLinesFor(localSha: string, remoteSha: string): string {
|
||||
let range: string;
|
||||
if (ZERO.test(remoteSha)) {
|
||||
// New branch: prefer what's unique to localSha vs the remote default branch.
|
||||
// With no merge-base (e.g. no remote yet), diff against the empty tree so ALL
|
||||
// branch content is scanned as added — fail-safe (scans more, never less).
|
||||
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
|
||||
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
|
||||
} else if (!objectExists(remoteSha)) {
|
||||
// Remote tip object absent locally (shallow clone, force-push without a
|
||||
// prior fetch, CI checkout): remote..local can't resolve. Fall back to
|
||||
// the merge-base/empty-tree path — scans MORE, never less — instead of
|
||||
// hard-blocking a legitimate push (adversarial review finding 8).
|
||||
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
|
||||
if (ZERO.test(remoteSha) || !objectExists(remoteSha)) {
|
||||
// Either a new branch (zero remote sha), or the remote tip object is absent
|
||||
// locally (shallow clone, force-push without a prior fetch, CI checkout) so
|
||||
// remote..local cannot resolve. Both need a base derived locally; scan MORE
|
||||
// rather than hard-blocking a legitimate push (adversarial review finding 8).
|
||||
const base = unknownRemoteTipBase(localSha);
|
||||
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
|
||||
} else {
|
||||
// Existing branch (incl. force-push): net new content remote..local.
|
||||
@@ -104,16 +140,90 @@ function addedLinesFor(localSha: string, remoteSha: string): string {
|
||||
// +++ file header. Unified diff added lines start with a single '+'.
|
||||
// Strict (#1946): a failed diff used to return "" and the push sailed
|
||||
// through unscanned — fail open on the exact path the guard exists for.
|
||||
const diff = gitStrict(["diff", "--unified=0", "--no-color", range]);
|
||||
//
|
||||
// --no-ext-diff: a user's `diff.external` driver replaces the entire diff
|
||||
// with its own output — with one set, `git diff` emits zero '+' lines, so an
|
||||
// unhardened scanner reads an empty diff and exits 0 on a push full of
|
||||
// secrets. Reachable from ordinary user config, not hypothetical. (#2498)
|
||||
// --no-textconv: a .gitattributes textconv driver can likewise rewrite
|
||||
// content before we ever see it. (#2498)
|
||||
const diff = gitStrict([
|
||||
"diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
|
||||
range,
|
||||
]);
|
||||
const added: string[] = [];
|
||||
// Hunk-aware header skip (#2498): `+++ ` is only a FILE HEADER outside a
|
||||
// hunk. Inside a hunk, an added content line whose text begins with "++"
|
||||
// renders as "+++<content>" — the old blanket startsWith("+++") skip
|
||||
// silently dropped exactly those lines from the scan.
|
||||
let inHunk = false;
|
||||
for (const line of diff.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
added.push(line.slice(1));
|
||||
}
|
||||
if (line.startsWith("diff --git")) { inHunk = false; continue; }
|
||||
if (line.startsWith("@@")) { inHunk = true; continue; }
|
||||
if (!inHunk && (line.startsWith("+++") || line.startsWith("---"))) continue;
|
||||
if (line.startsWith("+")) added.push(line.slice(1));
|
||||
}
|
||||
return added.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte budget per scan() call. Kept comfortably under redact-engine's
|
||||
* DEFAULT_MAX_BYTES (1 MiB) so a slice never trips its oversize guard.
|
||||
*/
|
||||
const SCAN_CHUNK_BYTES = 768 * 1024;
|
||||
|
||||
/**
|
||||
* Scan added lines in line-aligned slices, unioning the findings.
|
||||
*
|
||||
* Why: the engine refuses input over its byte cap and fails closed, which is
|
||||
* right for one scan() call but wrong as a push policy — a feature branch
|
||||
* catching up to a busy main legitimately produces more added lines than the
|
||||
* cap (1,146,782 bytes against the 1 MiB default in the push that prompted
|
||||
* this, and only ~7% of that was the lockfile). The push then blocked on
|
||||
* `engine.input_too_large` — a size error naming no credential — which trains
|
||||
* people to reach for --no-verify, defeating the guardrail far more thoroughly
|
||||
* than a large diff does.
|
||||
*
|
||||
* Slicing loses NO detection coverage, because every pattern is single-line:
|
||||
* none in redact-patterns.ts carries the `m` or `s` flag, the
|
||||
* BEGIN-PRIVATE-KEY patterns capture only the header line rather than the key
|
||||
* body, and the engine itself iterates line by line. A line boundary therefore
|
||||
* cannot bisect a detectable secret, so no inter-slice overlap is needed.
|
||||
*
|
||||
* Fail-closed is preserved: a SINGLE line over the budget is still passed to
|
||||
* the engine intact, so a genuinely unscannable blob (minified bundle,
|
||||
* embedded base64) trips input_too_large and blocks exactly as before.
|
||||
*
|
||||
* Findings' line/col are slice-relative, which is fine here — this hook only
|
||||
* reads severity, id and preview. Do not lift this into the engine, where
|
||||
* callers rely on absolute line numbers.
|
||||
*/
|
||||
function scanAddedLines(added: string, opts: Parameters<typeof scan>[1]): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
let slice: string[] = [];
|
||||
let sliceBytes = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (slice.length === 0) return;
|
||||
findings.push(...scan(slice.join("\n"), opts).findings);
|
||||
slice = [];
|
||||
sliceBytes = 0;
|
||||
};
|
||||
|
||||
for (const line of added.split("\n")) {
|
||||
// +1 for the newline that rejoins it.
|
||||
const lineBytes = Buffer.byteLength(line, "utf8") + 1;
|
||||
// Close the current slice BEFORE overflowing it. A single oversized line
|
||||
// lands in a slice of its own and is handed to the engine as-is.
|
||||
if (sliceBytes > 0 && sliceBytes + lineBytes > SCAN_CHUNK_BYTES) flush();
|
||||
slice.push(line);
|
||||
sliceBytes += lineBytes;
|
||||
}
|
||||
flush();
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function logSkip(reason: string): void {
|
||||
try {
|
||||
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
|
||||
@@ -145,8 +255,23 @@ function main() {
|
||||
const allHigh: Finding[] = [];
|
||||
let mediumCount = 0;
|
||||
|
||||
for (const [, localSha, , remoteSha] of refs) {
|
||||
if (!localSha || ZERO.test(localSha)) continue; // branch delete → nothing pushed
|
||||
for (const fields of refs) {
|
||||
// Fail CLOSED on a ref line we cannot parse (#2498): git hands pre-push
|
||||
// exactly "<local ref> <local sha> <remote ref> <remote sha>" — anything
|
||||
// else means we cannot tell WHAT is being pushed, and silently skipping
|
||||
// it would leave that ref unscanned.
|
||||
const [, localSha, , remoteSha] = fields;
|
||||
const shaShaped = (s: string | undefined) => !!s && /^[0-9a-f]{40,64}$/i.test(s);
|
||||
if (fields.length !== 4 || !shaShaped(localSha) || !shaShaped(remoteSha)) {
|
||||
process.stderr.write(
|
||||
"\n⛔ gstack-redact-prepush BLOCKED the push — could not parse a pre-push ref line, " +
|
||||
"so its content cannot be scanned.\n" +
|
||||
` line: ${JSON.stringify(fields.join(" "))}\n` +
|
||||
"Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (ZERO.test(localSha!)) continue; // branch delete → nothing pushed
|
||||
let added: string;
|
||||
try {
|
||||
added = addedLinesFor(localSha, remoteSha || "0");
|
||||
@@ -165,8 +290,9 @@ function main() {
|
||||
if (!added.trim()) continue;
|
||||
// Visibility doesn't change HIGH behavior; pass private so nothing is treated
|
||||
// as public-strict (HIGH blocks regardless either way).
|
||||
const result = scan(added, { repoVisibility: "private" });
|
||||
for (const f of result.findings) {
|
||||
// Sliced (see scanAddedLines) so a large-but-legitimate diff is actually
|
||||
// scanned rather than blocked unscanned on the engine's size cap.
|
||||
for (const f of scanAddedLines(added, { repoVisibility: "private" })) {
|
||||
if (f.severity === "HIGH") allHigh.push(f);
|
||||
else if (f.severity === "MEDIUM") mediumCount++;
|
||||
}
|
||||
@@ -180,15 +306,50 @@ function main() {
|
||||
}
|
||||
|
||||
if (allHigh.length > 0) {
|
||||
process.stderr.write(
|
||||
"\n⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:\n\n",
|
||||
);
|
||||
for (const f of allHigh) {
|
||||
process.stderr.write(` HIGH ${f.id} ${f.preview}\n`);
|
||||
// A scan that could not RUN is not a scan that FOUND something. Reporting
|
||||
// "credential(s) in the pushed diff — rotate the credential" for an
|
||||
// `engine.*` finding tells the operator to rotate a secret that was never
|
||||
// detected, on a diff that was never read. Blocking is still right (fail
|
||||
// closed), but the reason must be the true one: a guardrail that cries wolf
|
||||
// is a guardrail that gets bypassed by reflex, which is worse than none.
|
||||
// Seen live 2026-07-30: a diff of a few hundred bytes reported HIGH
|
||||
// engine.input_too_large, because an unresolvable base branch made the hook
|
||||
// fall back to EMPTY_TREE..local — i.e. the WHOLE repo (~7 MiB) as "added
|
||||
// lines". The size the operator sees and the size the hook measures can
|
||||
// therefore differ by four orders of magnitude.
|
||||
const unscanned = allHigh.filter((f) => f.id.startsWith("engine."));
|
||||
const secrets = allHigh.filter((f) => !f.id.startsWith("engine."));
|
||||
|
||||
if (secrets.length > 0) {
|
||||
process.stderr.write(
|
||||
"\n⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:\n\n",
|
||||
);
|
||||
for (const f of secrets) {
|
||||
process.stderr.write(` HIGH ${f.id} ${f.preview}\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
"\nRotate the credential (a pushed secret is compromised) and remove it from the diff.\n",
|
||||
);
|
||||
}
|
||||
|
||||
if (unscanned.length > 0) {
|
||||
process.stderr.write(
|
||||
"\n⛔ gstack-redact-prepush BLOCKED the push — the diff could NOT be scanned.\n" +
|
||||
" No credential was found; none was looked for. Blocking fail-closed.\n\n",
|
||||
);
|
||||
for (const f of unscanned) {
|
||||
process.stderr.write(` ${f.id}: ${f.description}\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
"\nLikely cause: the base branch could not be resolved, so the whole repo was\n" +
|
||||
"treated as added lines. Check `git rev-parse --abbrev-ref origin/HEAD` and\n" +
|
||||
"`git merge-base HEAD origin/main`, then push again. Scan the diff yourself\n" +
|
||||
"before bypassing: `git diff <base>..HEAD | grep -inE \'password|secret|token|api.?key\'`.\n",
|
||||
);
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
"\nRotate the credential (a pushed secret is compromised) and remove it from the diff.\n" +
|
||||
"This is a guardrail: `git push --no-verify` or `GSTACK_REDACT_PREPUSH=skip git push` bypass it.\n",
|
||||
"This is a guardrail: `git push --no-verify` or `GSTACK_REDACT_PREPUSH=skip git push` bypass it.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -82,8 +82,15 @@ fi
|
||||
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||
UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "")
|
||||
UPDATE_HOST="${UPDATE_URL#*://}"; UPDATE_HOST="${UPDATE_HOST#*@}"; UPDATE_HOST="${UPDATE_HOST%%[/:]*}"
|
||||
# --autostash: locally-patched TRACKED files are the NORM on installs, not
|
||||
# the exception — skill-prefix mode rewrites frontmatter names and
|
||||
# `gstack-config gbrain-refresh` renders brain blocks into SKILL.md. A bare
|
||||
# --ff-only refuses over those edits, so auto-upgrade wedged permanently
|
||||
# (observed: 308 consecutive PULL_FAILED with the reason discarded, #2566).
|
||||
# Capture stderr: the log must carry WHY a pull failed, never just the code.
|
||||
PULL_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-session-pull-XXXXXX" 2>/dev/null || echo "")
|
||||
GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \
|
||||
bash -c 'git -C "$1" pull --ff-only -q 2>/dev/null' _ "$GSTACK_DIR"
|
||||
bash -c 'git -C "$1" pull --ff-only --autostash -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE"
|
||||
PULL_EXIT=$?
|
||||
NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||
|
||||
@@ -91,9 +98,30 @@ fi
|
||||
date +%s > "$THROTTLE_FILE" 2>/dev/null
|
||||
|
||||
if [ "$PULL_EXIT" -ne 0 ]; then
|
||||
log_entry "PULL_FAILED exit=$PULL_EXIT"
|
||||
PULL_REASON=$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null | tr '\n' ' ' | tr -s ' ')
|
||||
log_entry "PULL_FAILED exit=$PULL_EXIT reason=${PULL_REASON:-unknown}"
|
||||
# Autostash pop conflict leaves the stash behind and the tree half-merged.
|
||||
# The local patches are REGENERABLE (prefix renames, gbrain blocks), so
|
||||
# recover to a clean upstream tree and re-render them below rather than
|
||||
# leaving conflict markers in a live install.
|
||||
if grep -qi "autostash" "$PULL_ERR_FILE" 2>/dev/null; then
|
||||
git -C "$GSTACK_DIR" checkout -q -- . 2>/dev/null
|
||||
git -C "$GSTACK_DIR" stash drop -q 2>/dev/null
|
||||
log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1"
|
||||
_PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false)
|
||||
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true
|
||||
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f "$PULL_ERR_FILE" 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$PULL_ERR_FILE" 2>/dev/null
|
||||
# Re-render local patches over the fresh tree (both tools are idempotent
|
||||
# no-ops when the feature is unconfigured); the autostash pop usually
|
||||
# preserves them, but a clean re-render costs nothing and self-heals.
|
||||
_PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false)
|
||||
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true
|
||||
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true
|
||||
|
||||
# ── If HEAD moved, run setup -q ──
|
||||
if [ "$OLD_HEAD" != "$NEW_HEAD" ]; then
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
ACTION="${1:-}"
|
||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"
|
||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}"
|
||||
|
||||
if [ -z "$ACTION" ]; then
|
||||
cat <<EOF >&2
|
||||
|
||||
+148
-22
@@ -3,8 +3,28 @@
|
||||
# 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.
|
||||
# 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"
|
||||
@@ -13,43 +33,149 @@ PROJECT_DIR="$(pwd)"
|
||||
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")
|
||||
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
|
||||
|
||||
# 2. If no cache, compute from git remote (separated from pipeline to avoid
|
||||
# pipefail swallowing the error and producing an empty slug)
|
||||
if [[ -z "${SLUG:-}" ]]; then
|
||||
REMOTE_URL=$(git remote get-url origin 2>/dev/null) || REMOTE_URL=""
|
||||
# 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. Fallback to basename only when there's truly no git remote configured
|
||||
SLUG="${SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
|
||||
# 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. 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).
|
||||
# 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._-')
|
||||
|
||||
# 4. 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
|
||||
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 -cd 'a-zA-Z0-9._-')
|
||||
BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
|
||||
BRANCH="${BRANCH:-unknown}"
|
||||
echo "SLUG=$SLUG"
|
||||
echo "BRANCH=$BRANCH"
|
||||
|
||||
@@ -127,8 +127,8 @@ Install it:
|
||||
|
||||
Then restart your AI coding tool.
|
||||
MSG
|
||||
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
|
||||
exit 0
|
||||
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"gstack is required but not installed. See stderr for install instructions."}}'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo '{}'
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
# GSTACK_STATE_DIR — override ~/.gstack state directory
|
||||
set -euo pipefail
|
||||
|
||||
# A crash must not read as "up to date" (#1974). With set -e, any unguarded
|
||||
# failure used to exit silently — and silence IS the up-to-date signal, so a
|
||||
# broken check was indistinguishable from a current install (observed live as
|
||||
# a 45-release silent-staleness incident). -E propagates the trap into
|
||||
# functions/subshells; exit 0 keeps callers' `|| true` from eating the line.
|
||||
set -E
|
||||
trap 'rc=$?; echo "CHECK_FAILED gstack-update-check crashed (line $LINENO, rc=$rc) — update status UNKNOWN, not up-to-date"; exit 0' ERR
|
||||
|
||||
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user