mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995) /land-and-deploy gated only on required checks, so a repo with none (every GitHub Free private repo, any repo without branch protection) skipped the CI wait and could merge over red CI. The verdict now comes from bin/gstack-ci-gate, a tested helper bound to the head SHA: all checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit per-head approval, and both merge commands (including --auto) run only after a PASS for the same SHA they pin with --match-head-commit. Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901) gstack-slug kept only the last two remote path segments, so customer-a/product/repo and customer-b/product/repo shared one bucket and /context-restore could present another project's checkpoint. - One canonical-remote rule (bash + TS, golden vectors) used by all six resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and Bitbucket Server normalized. 3+ segment remotes get <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged. - Versioned sticky cache (v2:) keeps cache hits git-free. - context-save stamps the credential-free remote and root; context-restore only treats this project's checkpoints as latest, reports foreign/unverified ones first, and treats a different root with the same remote (Conductor handoff) as information. - gstack-slug --adopt-legacy copies only files the user confirms. - question-preference-hook reads the same versioned cache, so never-ask preferences fire; unknown identity asks instead of auto-deciding. * fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922) - brain-context-load called 'gbrain list_pages' (an MCP tool name), so every skill's brain context was 0 bytes. It now uses 'gbrain list' with gbrain's real filter flags, and prints one line when queries fail. - The code-stage sync always passes --no-pull (managed gbrain >= 0.51 refuses without it); a gbrain that rejects the flag fails with an upgrade message and is never retried without it. stderr is streamed with a bounded tail and quoted in failure summaries. - /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by default it skips types already synced through a federated curated source, so curated pages stop being re-duplicated. Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011) - Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and never a false PASS; P2/P3-only output passes. - import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's ROOTPATH (including git-URL targets) through explicit, captured mappings; declared bases must sit inside the source root or the run's checkout, URIs are percent-decoded, and rejected results are counted with rule, base and path instead of silently dropped. * fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984) - The pre-push redact hook exempts a root VERSION file that is only a version string and the version token in CHANGELOG release headings; the engine allowlist is untouched. - gstack-update-check caches an unreadable remote as CHECK_FAILED with a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports 'latest' after a failed check. - SECURITY.md points reporters at GitHub private vulnerability reporting. - pr-title-sync (pull_request_target, write token) pins actions/checkout to a reviewed SHA. * fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006) The guard required a session journal to start with a user message, but Claude Code starts it with SessionStart hook attachments (and a forked compacted session starts with a compact_boundary), so every phase was denied for anyone with a SessionStart hook. Windows path spellings (C:/, /c/, drive case) were rejected too. - Reader accepts the captured startup, compact, clear and fork shapes (claude 2.1.284 fixture), computes root uniqueness over every candidate, and returns typed reason codes instead of one string. - Hard denials (competing root, foreign cwd, sidechain, agent, cycle, malformed, identity) say what happened and how to run the reviews by hand; unflushed journals still retry. - A well-formed, stable journal whose root shape is unrecognized now warns ('phase publication was NOT verified') and continues, without overriding Claude Code's own permission check; no bypass switch. - Windows paths fold before validation, so '..' is still rejected. - Periodic drift canary runs the reader on fresh journals from the pinned claude. Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com> * v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs * test: seed the versioned slug cache in the memory-injection deny case (#2901) The preference hook now resolves the project bucket from gstack-slug's v2 slug-cache entry instead of basename(cwd); this fixture still wrote preferences under the basename bucket without the entry the skill preamble always writes first. * fix: full-suite follow-ups for the fix wave - gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50 pipe-deadlock guard like the other bins. - Refresh the codex/factory ship goldens for the plan-gates slug change. - The periodic lane gains the autoplan journal drift canary (107 paid files); raise the eval-slices cap to 25 so every slice still starts at once until its recorded duration lets the planner pack it. * fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs The workflow judge scored plan-eng-review's instructions at actionability 3.67 (bar 4) on text unchanged from main. Resolve the ambiguities it named together, without changing any gate or output: Step 0 names the Read's order and keeps one STOP; the pending-call, read-only, forbidden-write and failed-save rules become one Write routes block; header/label limits and grid checks are concrete; Performance gets a checklist; each section has a stated output shape; Lake Score is defined. Same total length (parity cap). --------- Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
547 lines
25 KiB
Bash
Executable File
547 lines
25 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
|
|
# Modes (not on the hot path; see `gstack-slug --help`):
|
|
# --identity also print PROJECT_REMOTE, PROJECT_ROOT, LEGACY_SLUG
|
|
# --stamp-checkpoint <file> write remote:/project_root: into a checkpoint's frontmatter
|
|
# --classify-checkpoints read checkpoint paths on stdin, print "<status>\t<path>"
|
|
# --adopt-legacy [--copy <relpath>...] review/copy files from the pre-#3003 bucket
|
|
#
|
|
# Nested-group remotes (#3003): a hosted remote with 3+ path segments
|
|
# (gitlab.com/a/product/repo) files under "<last-two>-<16 hex>" instead of the
|
|
# last two segments alone, so projects in different groups stop sharing one
|
|
# bucket. 2-segment remotes (all of GitHub) and local paths are unchanged. The
|
|
# canonical-remote and slug rules live in bin/gstack-remote-identity.sh (twin of
|
|
# lib/remote-identity.ts). Earlier data stays in the old bucket until the user
|
|
# copies it with --adopt-legacy; nothing moves automatically. ssh host aliases
|
|
# (git@work-gitlab:…) are a different canonical host than the https spelling;
|
|
# pin those with GSTACK_PROJECT_SLUG.
|
|
#
|
|
# Cache format: entries are versioned ("v2:<slug>"). An unversioned entry from
|
|
# an older gstack is recomputed once (one git spawn) and rewritten as v2; it
|
|
# keeps its value unless it is exactly the pre-#3003 last-two slug of a
|
|
# 3+-segment remote, so #2212 sticky identity survives the upgrade and later
|
|
# cache hits stay git-free.
|
|
#
|
|
# 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. Derive the slug from the canonical git remote: the OUTERMOST ancestor
|
|
# that is an actual git repo (`.git` directory, or `.git` FILE for
|
|
# worktrees/submodules) with an `origin` remote wins. The slug is
|
|
# `owner-repo`, parsed EXACTLY like browse/bin/remote-slug so the two
|
|
# bins can never disagree on a canonical-remote repo. Marker-only
|
|
# ancestors that are NOT remote-bearing repos (a stray empty ~/.git,
|
|
# a stray package.json in $HOME) still anchor the basename FALLBACK,
|
|
# but they can no longer shadow a real remote. (2026-08-17 bug fix:
|
|
# a stray empty ~/.git made the walk-up pick $HOME as project root;
|
|
# $HOME has no origin, so EVERY repo under it degraded to
|
|
# SLUG=<username> — one shared bucket for all projects.)
|
|
# 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).
|
|
#
|
|
# MIGRATION NOTE (2026-08-17): sessions run BEFORE the remote-first fix above,
|
|
# in repos below a stray marker-bearing ancestor, filed their session state
|
|
# (decisions / timeline / ceo-plans / learnings) under the DEGRADED slug —
|
|
# ~/.gstack/projects/<ancestor-basename>/ (e.g. `garrytan`) instead of the
|
|
# canonical ~/.gstack/projects/<owner-repo>/. The slug cache self-heals on the
|
|
# next invocation (see 1b), but already-written store data does NOT move.
|
|
# Whether/how to merge those stores is tracked in TODOS.md — do not add data
|
|
# migration code here.
|
|
#
|
|
# 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.
|
|
# Bash 5.2+ can feed a heredoc body >=512B through a pipe with no reader
|
|
# yet, which hangs --help forever; compat level 50 restores the tempfile path
|
|
# (same guard as bin/gstack-artifacts-init). Not exported.
|
|
BASH_COMPAT=50
|
|
|
|
set -euo pipefail
|
|
|
|
# State-root aware (bin/gstack-state-root.sh), matching lib/bin-context.ts (#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).
|
|
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
|
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
|
|
. "$(dirname "$0")/gstack-remote-identity.sh" 2>/dev/null || { echo "$0: $(dirname "$0")/gstack-remote-identity.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
|
|
|
|
MODE=default
|
|
MODE_ARG=""
|
|
COPY_PATHS=()
|
|
case "${1:-}" in
|
|
"") ;;
|
|
--identity) MODE=identity ;;
|
|
--stamp-checkpoint) MODE=stamp; MODE_ARG="${2:-}"
|
|
[[ -n "$MODE_ARG" ]] || { echo "usage: gstack-slug --stamp-checkpoint <file>" >&2; exit 2; } ;;
|
|
--classify-checkpoints) MODE=classify ;;
|
|
--adopt-legacy) MODE=adopt; shift
|
|
if [[ "${1:-}" == "--copy" ]]; then
|
|
shift
|
|
[[ $# -gt 0 ]] || { echo "usage: gstack-slug --adopt-legacy --copy <relpath>..." >&2; exit 2; }
|
|
COPY_PATHS=("$@")
|
|
elif [[ -n "${1:-}" ]]; then
|
|
echo "usage: gstack-slug --adopt-legacy [--copy <relpath>...]" >&2; exit 2
|
|
fi ;;
|
|
-h|--help)
|
|
cat <<'USAGE'
|
|
gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current directory.
|
|
eval "$(gstack-slug)" set SLUG and BRANCH
|
|
gstack-slug --identity also print PROJECT_REMOTE (canonical, credential-free),
|
|
PROJECT_ROOT and LEGACY_SLUG (pre-#3003 bucket, if different)
|
|
gstack-slug --stamp-checkpoint FILE write remote:/project_root: into FILE's frontmatter
|
|
gstack-slug --classify-checkpoints read checkpoint paths on stdin; print "<status><TAB><path>",
|
|
status = match | match-root | foreign | unstamped
|
|
gstack-slug --adopt-legacy list the old bucket's files (checkpoints marked
|
|
matching/foreign/unknown); asks per file on a terminal
|
|
gstack-slug --adopt-legacy --copy P… copy the named files (paths relative to the old bucket)
|
|
Env: GSTACK_PROJECT_SLUG pins the slug (ssh host aliases, vendored sub-repos); never cached.
|
|
USAGE
|
|
exit 0 ;;
|
|
*) echo "gstack-slug: unknown argument: $1 (try --help)" >&2; exit 2 ;;
|
|
esac
|
|
CACHE_DIR="$GSTACK_STATE_ROOT/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._-')
|
|
# A pin of "." or ".." would file state outside projects/; ignore it.
|
|
case "$SLUG" in .|..) SLUG="" ;; esac
|
|
# 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
|
|
}
|
|
|
|
# 1a. Outermost REMOTE-BEARING repo root: walk the same ancestor chain and
|
|
# track the outermost dir that has a `.git` entry (directory for normal
|
|
# clones, FILE for git-worktrees/submodules — `git -C` resolves a
|
|
# worktree's remote through its main clone) AND whose `origin` remote
|
|
# resolves. This is the canonical-identity walk: a marker-only ancestor
|
|
# with no resolvable origin (stray empty ~/.git, stray package.json)
|
|
# cannot win here, so it cannot hijack remote-derived identity the way
|
|
# it can hijack the marker walk above. Nested-repo semantics preserved:
|
|
# an inner repo under an outer canonical-remote repo still resolves to
|
|
# the OUTER repo's remote (outermost wins), same as before.
|
|
# Note: git spawns only at `.git`-bearing ancestors — typically one.
|
|
_outermost_remote_repo() {
|
|
local dir="$1"
|
|
local outermost="" parent="" depth=0
|
|
while [[ -n "$dir" && "$dir" != "/" && $depth -lt 64 ]]; do
|
|
if [[ -e "$dir/.git" ]] && git -C "$dir" remote get-url origin >/dev/null 2>&1; then
|
|
outermost="$dir"
|
|
fi
|
|
parent=$(dirname "$dir")
|
|
[[ "$parent" == "$dir" ]] && break # dirname fixed point (C:/, ., //srv)
|
|
dir="$parent"
|
|
depth=$((depth + 1))
|
|
done
|
|
printf '%s' "$outermost"
|
|
}
|
|
|
|
# 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
|
|
|
|
# Lazy, memoized remote discovery. Needed on exactly two paths: fresh
|
|
# resolution (no usable cache) and the degraded-ancestor heal check below.
|
|
# Gating it keeps ordinary cache hits git-spawn-free.
|
|
REMOTE_ROOT=""
|
|
REMOTE_URL=""
|
|
_REMOTE_RESOLVED=0
|
|
_resolve_remote() {
|
|
if [[ "$_REMOTE_RESOLVED" -eq 1 ]]; then return 0; fi
|
|
_REMOTE_RESOLVED=1
|
|
REMOTE_ROOT=$(_outermost_remote_repo "$PROJECT_DIR")
|
|
if [[ -n "$REMOTE_ROOT" ]]; then
|
|
REMOTE_URL=$(git -C "$REMOTE_ROOT" remote get-url origin 2>/dev/null) || REMOTE_URL=""
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# 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>/. TWO provable bug shapes are exempt
|
|
# and fall through to recompute (self-heal):
|
|
# - Old-bug shape (#1125): the pre-walk-up resolver cached basename(pwd)
|
|
# for a SUBDIRECTORY of the real project — cached == pwd basename while
|
|
# the walk-up says pwd is NOT the project root.
|
|
# - Degraded-ancestor shape (2026-08-17), STRAY-REPO shape ONLY: the
|
|
# pre-remote-first resolver cached basename(PROJECT_ROOT) for an
|
|
# ancestor anchored by a .git entry whose origin does NOT resolve (the
|
|
# stray empty ~/.git live bug) while a remote-bearing repo BELOW it
|
|
# exists. A marker root anchored by package.json / pyproject etc. with
|
|
# NO .git is legit #2212 sticky identity (a monorepo wrapper that used
|
|
# gstack before its inner dir grew a remote) and must NOT be healed.
|
|
# Legit remote-adopting stickiness is safe too: there the repo that
|
|
# adopted the remote IS the marker root (REMOTE_ROOT == PROJECT_ROOT),
|
|
# so the heal never fires.
|
|
_CACHE_V2=0
|
|
LEGACY_NOTICE_SLUG=""
|
|
if [[ -z "$SLUG" && -f "$CACHE_FILE" ]]; then
|
|
_RAW_CACHE=$(cat "$CACHE_FILE" 2>/dev/null || true)
|
|
if [[ "$_RAW_CACHE" == v2:* ]]; then
|
|
_CACHE_V2=1
|
|
_RAW_CACHE="${_RAW_CACHE#v2:}"
|
|
fi
|
|
_CACHED=$(printf '%s' "$_RAW_CACHE" | tr -cd 'a-zA-Z0-9._-')
|
|
if [[ -n "$_CACHED" ]]; then
|
|
_PWD_BASE=$(basename "$PROJECT_DIR" | tr -cd 'a-zA-Z0-9._-')
|
|
_ROOT_BASE=""
|
|
if [[ -n "$PROJECT_ROOT" ]]; then
|
|
_ROOT_BASE=$(basename "$PROJECT_ROOT" | tr -cd 'a-zA-Z0-9._-')
|
|
fi
|
|
if [[ "$_CACHED" == "$_PWD_BASE" && -n "$PROJECT_ROOT" && "$PROJECT_ROOT" != "$PROJECT_DIR" ]]; then
|
|
: # old-bug shape — recompute below and self-heal the cache
|
|
elif [[ -n "$PROJECT_ROOT" && "$_CACHED" == "$_ROOT_BASE" && -e "$PROJECT_ROOT/.git" ]] \
|
|
&& ! git -C "$PROJECT_ROOT" remote get-url origin >/dev/null 2>&1 \
|
|
&& { _resolve_remote; [[ -n "$REMOTE_URL" && "$REMOTE_ROOT" != "$PROJECT_ROOT" ]]; }; then
|
|
: # degraded-ancestor (stray-repo) shape — recompute below and self-heal the cache
|
|
elif [[ "$_CACHE_V2" -eq 1 ]]; then
|
|
SLUG="$_CACHED"
|
|
else
|
|
# Unversioned (pre-#3003) entry: recompute once. Keep the sticky value
|
|
# unless it is exactly the old last-two slug of a 3+-segment remote.
|
|
SLUG="$_CACHED"
|
|
_resolve_remote
|
|
if [[ -n "$REMOTE_URL" ]]; then
|
|
gstack_remote_slug "$REMOTE_URL"
|
|
if [[ -n "$_gri_slug" && "$_gri_slug" != "$_gri_legacy_slug" && "$_CACHED" == "$_gri_legacy_slug" ]]; then
|
|
SLUG="$_gri_slug"
|
|
LEGACY_NOTICE_SLUG="$_gri_legacy_slug"
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# 2. Canonical remote-derived slug. Sourced from the outermost remote-bearing
|
|
# repo (see 1a) — NOT from PROJECT_ROOT, which may be a marker-only
|
|
# ancestor with no remote. The slug rule (legacy owner-repo for 2-segment
|
|
# and local remotes, "<last-two>-<16 hex>" for 3+-segment hosted remotes)
|
|
# is shared with browse/bin/remote-slug through gstack-remote-identity.sh,
|
|
# so the two bins agree on every canonical-remote repo, worktrees included.
|
|
# Parity pinned by test/gstack-slug-parity.test.ts.
|
|
if [[ -z "$SLUG" ]]; then
|
|
_resolve_remote
|
|
if [[ -n "$REMOTE_URL" ]]; then
|
|
gstack_remote_slug "$REMOTE_URL"
|
|
SLUG="$_gri_slug"
|
|
if [[ -n "$SLUG" && "$_gri_slug" != "$_gri_legacy_slug" ]]; then
|
|
LEGACY_NOTICE_SLUG="$_gri_legacy_slug"
|
|
fi
|
|
# Dot-only / degenerate guard: a hostile origin like `url = ..` (git
|
|
# accepts it) passes sed unchanged and would become SLUG=".." — filing
|
|
# state one level ABOVE ~/.gstack/projects/. Reject empty/"."/".."/
|
|
# slash-bearing slugs and fall through to the basename fallback below.
|
|
# (tr -cd already deletes "/", so */* is belt-and-braces.)
|
|
case "$SLUG" in ""|.|..|*/*) SLUG="" ;; esac
|
|
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" != "v2:$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 'v2:%s' "$SLUG" > "$CACHE_TMP" && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# One notice on the resolve that first files this project under its new
|
|
# nested-group slug while the old shared bucket still exists (stderr only, so
|
|
# eval consumers are unaffected; later runs are v2 cache hits and stay quiet).
|
|
if [[ -n "$LEGACY_NOTICE_SLUG" && "$SLUG" != "$LEGACY_NOTICE_SLUG" && -d "$GSTACK_STATE_ROOT/projects/$LEGACY_NOTICE_SLUG" ]]; then
|
|
echo "gstack now files this project under projects/$SLUG; earlier data is still in projects/$LEGACY_NOTICE_SLUG (review and copy it with: gstack-slug --adopt-legacy)" >&2
|
|
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}"
|
|
|
|
if [[ "$MODE" == "default" ]]; then
|
|
echo "SLUG=$SLUG"
|
|
echo "BRANCH=$BRANCH"
|
|
exit 0
|
|
fi
|
|
|
|
# ── Project identity (#3003) ─────────────────────────────────────────────────
|
|
# Identity = canonical remote; the root is identity only when there is no
|
|
# remote. Roots are physical (pwd -P) so a symlinked spelling compares equal.
|
|
ID_REMOTE=""
|
|
ID_ROOT=""
|
|
ID_LEGACY=""
|
|
_resolve_identity() {
|
|
_resolve_remote
|
|
if [[ -n "$REMOTE_URL" ]]; then
|
|
gstack_remote_slug "$REMOTE_URL"
|
|
ID_REMOTE="$_gri_canon"
|
|
ID_ROOT="$REMOTE_ROOT"
|
|
if [[ -n "$_gri_legacy_slug" && "$_gri_slug" != "$_gri_legacy_slug" && "$_gri_legacy_slug" != "$SLUG" ]]; then
|
|
ID_LEGACY="$_gri_legacy_slug"
|
|
fi
|
|
else
|
|
ID_ROOT="${PROJECT_ROOT:-$(_outermost_project_root "$PROJECT_DIR")}"
|
|
ID_ROOT="${ID_ROOT:-$PROJECT_DIR}"
|
|
fi
|
|
ID_ROOT=$(cd "$ID_ROOT" 2>/dev/null && pwd -P) || ID_ROOT="$PROJECT_DIR"
|
|
}
|
|
|
|
# Prints "<remote>\037<project_root>" from the file's leading frontmatter only.
|
|
_checkpoint_fields() {
|
|
awk 'NR == 1 { if ($0 != "---") exit; next }
|
|
$0 == "---" { exit }
|
|
/^remote:/ && !r { v = $0; sub(/^remote:[ \t]*/, "", v); sub(/[ \t\r]+$/, "", v); rem = v; r = 1 }
|
|
/^project_root:/ && !p { v = $0; sub(/^project_root:[ \t]*/, "", v); sub(/[ \t\r]+$/, "", v); root = v; p = 1 }
|
|
END { printf "%s\037%s\n", rem, root }' "$1" 2>/dev/null || true
|
|
}
|
|
|
|
# Sets _ck_status: match | match-root (same remote, different root: info only)
|
|
# | foreign | unstamped (checkpoint written before identity stamping existed).
|
|
_classify_checkpoint() {
|
|
local fields r root
|
|
fields=$(_checkpoint_fields "$1")
|
|
r="${fields%%$'\037'*}"
|
|
root="${fields#*$'\037'}"
|
|
if [[ -n "$ID_REMOTE" && -n "$r" ]]; then
|
|
if [[ "$r" != "$ID_REMOTE" ]]; then _ck_status=foreign
|
|
elif [[ -n "$root" && "$root" != "$ID_ROOT" ]]; then _ck_status=match-root
|
|
else _ck_status=match
|
|
fi
|
|
elif [[ -n "$root" ]]; then
|
|
if [[ "$root" == "$ID_ROOT" ]]; then _ck_status=match; else _ck_status=foreign; fi
|
|
elif [[ -n "$r" ]]; then
|
|
_ck_status=foreign
|
|
else
|
|
_ck_status=unstamped
|
|
fi
|
|
}
|
|
|
|
_resolve_identity
|
|
|
|
case "$MODE" in
|
|
identity)
|
|
echo "SLUG=$SLUG"
|
|
echo "BRANCH=$BRANCH"
|
|
printf 'PROJECT_REMOTE=%q\n' "$ID_REMOTE"
|
|
printf 'PROJECT_ROOT=%q\n' "$ID_ROOT"
|
|
echo "LEGACY_SLUG=$ID_LEGACY"
|
|
;;
|
|
|
|
stamp)
|
|
[[ -f "$MODE_ARG" ]] || { echo "gstack-slug: no such checkpoint file: $MODE_ARG" >&2; exit 1; }
|
|
STAMP_TMP=$(mktemp "${MODE_ARG}.stamp-XXXXXX") || exit 1
|
|
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" awk '
|
|
function ids() { if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]; print "project_root: " ENVIRON["GRI_ROOT"] }
|
|
NR == 1 { if ($0 == "---") { infm = 1; print; next } print "---"; ids(); print "---" }
|
|
infm && $0 == "---" { ids(); print; infm = 0; next }
|
|
infm && (/^remote:/ || /^project_root:/) { next }
|
|
{ print }
|
|
END { if (NR == 0) { print "---"; ids(); print "---" } }' "$MODE_ARG" > "$STAMP_TMP"; then
|
|
mv "$STAMP_TMP" "$MODE_ARG"
|
|
else
|
|
rm -f "$STAMP_TMP"
|
|
exit 1
|
|
fi
|
|
;;
|
|
|
|
classify)
|
|
while IFS= read -r ck_file || [[ -n "$ck_file" ]]; do
|
|
[[ -n "$ck_file" ]] || continue
|
|
_classify_checkpoint "$ck_file"
|
|
printf '%s\t%s\n' "$_ck_status" "$ck_file"
|
|
done
|
|
;;
|
|
|
|
adopt)
|
|
if [[ -z "$ID_LEGACY" ]]; then
|
|
echo "Nothing to adopt: this project's slug ($SLUG) did not change."
|
|
exit 0
|
|
fi
|
|
OLD_BUCKET="$GSTACK_STATE_ROOT/projects/$ID_LEGACY"
|
|
NEW_BUCKET="$GSTACK_STATE_ROOT/projects/$SLUG"
|
|
if [[ ! -d "$OLD_BUCKET" ]]; then
|
|
echo "Nothing to adopt: no earlier bucket at $OLD_BUCKET."
|
|
exit 0
|
|
fi
|
|
OLD_REAL=$(cd "$OLD_BUCKET" && pwd -P)
|
|
|
|
# Sets _ad_label and _ad_copyable (1/0) for one path relative to OLD_BUCKET.
|
|
_adopt_label() {
|
|
_ad_copyable=1
|
|
case "$1" in
|
|
checkpoints/*.md)
|
|
_classify_checkpoint "$OLD_BUCKET/$1"
|
|
case "$_ck_status" in
|
|
match|match-root) _ad_label="checkpoint: matching this project" ;;
|
|
foreign) _ad_label="checkpoint: another project (not copyable)"; _ad_copyable=0 ;;
|
|
*) _ad_label="checkpoint: unknown project (saved before identity stamps)" ;;
|
|
esac ;;
|
|
*) _ad_label="no project identity: the old bucket may be shared, check before copying" ;;
|
|
esac
|
|
if [[ "$_ad_copyable" -eq 1 && -e "$NEW_BUCKET/$1" ]]; then
|
|
_ad_label="$_ad_label; already in new bucket (skipped)"; _ad_copyable=0
|
|
fi
|
|
}
|
|
|
|
_adopt_copy() {
|
|
mkdir -p "$(dirname "$NEW_BUCKET/$1")" && cp -p "$OLD_BUCKET/$1" "$NEW_BUCKET/$1" && echo "COPIED $1"
|
|
}
|
|
|
|
if [[ ${#COPY_PATHS[@]} -gt 0 ]]; then
|
|
ADOPT_RC=0
|
|
for rel in "${COPY_PATHS[@]}"; do
|
|
case "/$rel/" in
|
|
*/../*|*/./*|//*) echo "REFUSED $rel (must be a plain path relative to $OLD_BUCKET)"; ADOPT_RC=1; continue ;;
|
|
esac
|
|
src="$OLD_BUCKET/$rel"
|
|
src_dir=$(cd "$(dirname "$src")" 2>/dev/null && pwd -P) || src_dir=""
|
|
if [[ ! -f "$src" || -L "$src" || -z "$src_dir" ]] || [[ "$src_dir" != "$OLD_REAL" && "$src_dir" != "$OLD_REAL"/* ]]; then
|
|
echo "REFUSED $rel (not a regular file inside $OLD_BUCKET)"; ADOPT_RC=1; continue
|
|
fi
|
|
_adopt_label "$rel"
|
|
if [[ "$_ad_copyable" -eq 0 ]]; then
|
|
echo "SKIPPED $rel ($_ad_label)"; continue
|
|
fi
|
|
_adopt_copy "$rel" || ADOPT_RC=1
|
|
done
|
|
exit "$ADOPT_RC"
|
|
fi
|
|
|
|
echo "gstack now files this project under: $NEW_BUCKET"
|
|
echo "Earlier data (shared nested-group bucket): $OLD_BUCKET"
|
|
echo "Nothing is copied unless you confirm it. Files:"
|
|
ADOPT_FILES=$(cd "$OLD_BUCKET" && find . -type f 2>/dev/null | sed 's#^\./##' | LC_ALL=C sort)
|
|
INTERACTIVE=0
|
|
if [[ -t 0 && -t 1 ]]; then INTERACTIVE=1; fi
|
|
while IFS= read -r rel; do
|
|
[[ -n "$rel" ]] || continue
|
|
_adopt_label "$rel"
|
|
echo " $rel [$_ad_label]"
|
|
if [[ "$INTERACTIVE" -eq 1 && "$_ad_copyable" -eq 1 ]]; then
|
|
printf ' copy into the new bucket? [y/N] '
|
|
ans=""
|
|
read -r ans </dev/tty || ans=""
|
|
case "$ans" in y|Y|yes|YES) _adopt_copy "$rel" ;; esac
|
|
fi
|
|
done <<ADOPT_LIST
|
|
$ADOPT_FILES
|
|
ADOPT_LIST
|
|
if [[ "$INTERACTIVE" -eq 0 ]]; then
|
|
echo "Nothing copied. Copy chosen files with: gstack-slug --adopt-legacy --copy <path> [<path>...]"
|
|
fi
|
|
;;
|
|
esac
|