Files
gstack/bin/gstack-slug
T
Garry Tanandcapy-ai[bot] 482ffd1440 v1.92.14.0 feat: a .gstack-project file makes a nested repo or umbrella folder its own project (#2649, #1923) (#3777)
* feat: a .gstack-project marker makes a nested repo or umbrella folder its own project (#2649, #1923)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: marker tests ignore an ambient GSTACK_STATE_ROOT

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (per-repo records ignore every marker, worktree binding in remote-slug and under a git HOME)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): a linked worktree finds its main checkout's marker on its first run

The worktree binding was written only when the main checkout resolved, so a
worktree outside an umbrella resolved first kept its old bucket and later
switched. Walk up from the main checkout on a miss instead (under its own
$HOME rule) and drop the binding file.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs/test: pregate user policy names REPO_SLUG; pin greptile consent to REPO_SLUG

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): no-clobber checkpoint recovery, unambiguous marker cache keys, ignore unusable folder names; remote-slug resolves once

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): remote-slug reads a quoted marker path; repo mode stays per repository

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): the review log and /ship spend-cap pointer stay with the repository under a marker; recovered checkpoints keep their repository; a linked worktree's own marker stands for its main checkout

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(captain): approvals live under the repository's own slug

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 05:57:18 +00:00

1089 lines
54 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
# gstack-slug --get NAME → print one raw value (SLUG, BRANCH or an
# --identity field); skill bash blocks
# use this form (#2763: worktree-isolated Claude
# Code sessions refuse eval/source)
# 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:/head: into a checkpoint's frontmatter
# --classify-checkpoints read checkpoint paths on stdin, print "<status>\t<path>"
# --checkpoints-below list other buckets' checkpoints saved below the cwd, plus pointers
# --point-from <dir> <file> leave a pointer to <file> in <dir>'s bucket (nested-repo saves)
# --newer-task <branch> read restore candidates on stdin, name a newer same-task checkpoint
# --adopt-legacy [--copy <relpath>...] review/copy files from the pre-#3003 bucket
# --adopt-legacy --from <slug> merge a former bucket (e.g. a worktree's) into this one
# --adopt-legacy --dismiss <slug> stop naming a former bucket at skill start
#
# 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 key (#2767): inside a git repository the entry is keyed on the git
# common dir, which the main checkout, its subdirectories and every linked
# worktree share, so a worktree can no longer resolve a different slug than
# the main checkout. It is read from .git files without spawning git. Entries
# written before this keyed on pwd; on a miss the main checkout's old entry
# wins over the current directory's own old entry (deterministic: the main
# checkout holds the project's original, possibly pre-remote, identity). When
# a worktree's own old entry named a different, non-empty bucket, that slug is
# recorded in projects/<slug>/.former-slugs until --adopt-legacy --from merges
# it or --dismiss drops it. Both keys are written, so pwd-keyed readers agree.
#
# 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)
# 0m. Project marker (#2649, #1923): the innermost counted .gstack-project on
# the cwd chain makes its folder the project root; nothing above it is
# consulted (see _find_project_marker for when a marker counts). Its
# result has its own cache entry, so removing the marker restores the
# folder's earlier slug untouched.
# 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=""
MODE_ARG2=""
COPY_PATHS=()
case "${1:-}" in
"") ;;
--identity) MODE=identity ;;
--get) MODE=get; MODE_ARG="${2:-}"
case "$MODE_ARG" in SLUG|REPO_SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|PROJECT_MARKER|LEGACY_SLUG|FORMER_SLUGS) ;;
*) echo "usage: gstack-slug --get SLUG|REPO_SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|PROJECT_MARKER|LEGACY_SLUG|FORMER_SLUGS" >&2; exit 2 ;;
esac ;;
--stamp-checkpoint) MODE=stamp; MODE_ARG="${2:-}"
[[ -n "$MODE_ARG" ]] || { echo "usage: gstack-slug --stamp-checkpoint <file>" >&2; exit 2; } ;;
--classify-checkpoints) MODE=classify ;;
--checkpoints-below) MODE=below ;;
--newer-task) MODE=newer-task; MODE_ARG="${2:-}" ;;
--point-from) MODE=point; MODE_ARG="${2:-}"; MODE_ARG2="${3:-}"
[[ -n "$MODE_ARG" && -n "$MODE_ARG2" ]] || { echo "usage: gstack-slug --point-from <start dir> <checkpoint file>" >&2; exit 2; } ;;
--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 [[ "${1:-}" == "--from" || "${1:-}" == "--dismiss" ]]; then
MODE=$([[ "$1" == "--from" ]] && echo adopt-from || echo adopt-dismiss)
MODE_ARG=$(printf '%s' "${2:-}" | tr -cd 'a-zA-Z0-9._-')
case "$MODE_ARG" in ""|.|..) echo "usage: gstack-slug --adopt-legacy $1 <slug>" >&2; exit 2 ;; esac
ADOPT_APPLY=0
if [[ "$1" == "--from" && "${3:-}" == "--under-root" ]]; then
MODE=adopt-under
case "${4:-}" in "") ;; --apply) ADOPT_APPLY=1 ;; *) echo "usage: gstack-slug --adopt-legacy --from <slug> --under-root [--apply]" >&2; exit 2 ;; esac
fi
elif [[ -n "${1:-}" ]]; then
echo "usage: gstack-slug --adopt-legacy [--copy <relpath>... | --from <slug> | --dismiss <slug>]" >&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
SLUG=$(gstack-slug --get SLUG) print one raw value (SLUG, BRANCH, or an --identity
field); no eval needed
gstack-slug --get REPO_SLUG the slug ignoring every .gstack-project marker, for records
that belong to one repository (policy, approvals, consent)
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:/head: 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 --checkpoints-below outside git: other buckets' checkpoints whose project_root
is below the cwd (newest 200 read); always: this bucket's
pointers. Prints "BELOW_CWD <path>" / "POINTER <path>"
gstack-slug --point-from DIR FILE record FILE in DIR's bucket when DIR files elsewhere
gstack-slug --newer-task BRANCH read restore candidates on stdin (BRANCH's first); print
"NEWER_TASK <path>" for a newer same-task other-branch save
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)
gstack-slug --adopt-legacy --from S merge bucket S into this project's: JSONL logs merge with
de-duplication, missing files copy, differing files are
listed as conflicts and never overwritten; S is kept
gstack-slug --adopt-legacy --dismiss S stop naming former bucket S at skill start
gstack-slug --adopt-legacy --from S --under-root [--apply]
preview (or copy and restamp) S's checkpoints saved inside
the folder a .gstack-project marks
Marker: an empty .gstack-project file makes its folder a project of its own (nested repos,
umbrella folders over several repos); --identity shows which marker applied or why one
was ignored. Docs: docs/state-root.md#nested-repos-and-umbrella-folders
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}"
# Physical git common dir of the repository holding $1 (nearest .git entry),
# or nothing outside git. A worktree's .git file names its gitdir, whose
# `commondir` file points at the shared dir. No git spawn.
_git_common_dir() {
local dir="$1" parent="" gitdir="" common="" depth=0
while [[ -n "$dir" && $depth -lt 64 ]]; do
if [[ -d "$dir/.git" ]]; then (cd "$dir/.git" 2>/dev/null && pwd -P); return 0; fi
if [[ -f "$dir/.git" ]]; then
gitdir=$(sed -n 's/^gitdir: *//p' "$dir/.git" 2>/dev/null | head -1)
[[ -n "$gitdir" ]] || return 0
[[ "$gitdir" == /* || "$gitdir" =~ ^[A-Za-z]: ]] || gitdir="$dir/$gitdir"
common="$gitdir"
if [[ -f "$gitdir/commondir" ]]; then
common=$(head -1 "$gitdir/commondir" 2>/dev/null)
[[ "$common" == /* || "$common" =~ ^[A-Za-z]: ]] || common="$gitdir/$common"
fi
(cd "$common" 2>/dev/null && pwd -P); return 0
fi
parent=$(dirname "$dir")
[[ "$parent" == "$dir" ]] && break
dir="$parent"
depth=$((depth + 1))
done
return 0
}
LEGACY_CACHE_FILE="$CACHE_FILE"
MAIN_CACHE_FILE=""
GIT_COMMON_DIR=$(_git_common_dir "$PROJECT_DIR")
if [[ -n "$GIT_COMMON_DIR" ]]; then
CACHE_FILE="${CACHE_DIR}/$(printf '%s' "$GIT_COMMON_DIR" | tr '/' '_')"
if [[ "$(basename "$GIT_COMMON_DIR")" == .git ]]; then
MAIN_CACHE_FILE="${CACHE_DIR}/$(dirname "$GIT_COMMON_DIR" | tr '/' '_')"
fi
fi
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
if [[ -n "${MARKER_ROOT:-}" ]]; then
# A marked folder consults only its own remote, never an outer repo's.
REMOTE_ROOT=""
[[ -e "$MARKER_ROOT/.git" ]] && git -C "$MARKER_ROOT" remote get-url origin >/dev/null 2>&1 && REMOTE_ROOT="$MARKER_ROOT"
else
REMOTE_ROOT=$(_outermost_remote_repo "$PROJECT_DIR")
fi
if [[ -n "$REMOTE_ROOT" ]]; then
REMOTE_URL=$(git -C "$REMOTE_ROOT" remote get-url origin 2>/dev/null) || REMOTE_URL=""
fi
return 0
}
# 0m. Project marker (#2649, #1923). A .gstack-project counts when it is a
# regular file (not a link or a directory) owned by this user, below $HOME
# when the cwd is under $HOME, and its folder is a git toplevel or not inside
# any git work tree. So a cloned repo can't plant one in a subfolder to pick a
# bucket by folder name, and a stray ~/.gstack-project can't swallow every
# project. Ignored markers are listed by --identity only.
MARKER_ROOT=""
MARKER_IGNORED=""
_HOME_LOG="${HOME:-}"
_HOME_PHYS=$(cd "$_HOME_LOG" 2>/dev/null && pwd -P) || _HOME_PHYS=""
_UNDER_HOME=0
for _h in "$_HOME_LOG" "$_HOME_PHYS"; do
[[ -n "$_h" && "$_h" != / && "$PROJECT_DIR" == "$_h"/* ]] && _UNDER_HOME=1
done
_is_home_or_above() {
local h
for h in "$_HOME_LOG" "$_HOME_PHYS"; do
[[ -n "$h" && ( "$h" == "$1" || "$h" == "$1"/* || "$1" == / ) ]] && return 0
done
return 1
}
# Prints why the marker in folder $1 does not count; nothing when it counts.
# $2 overrides whether the walk stops at $HOME (default: the cwd is under it).
_marker_reason() {
local m="$1/.gstack-project" d parent depth=0 under="${2:-$_UNDER_HOME}"
if [[ -L "$m" ]]; then echo "a symbolic link"; return 0; fi
if [[ -d "$m" ]]; then echo "a directory, not a file: rmdir '$m' && touch '$m'"; return 0; fi
[[ -f "$m" ]] || { echo "not a regular file"; return 0; }
[[ -O "$m" ]] || { echo "owned by another user"; return 0; }
case "$(basename "$1" | tr -cd 'a-zA-Z0-9._-')" in
""|.|..) echo "its folder name has no letter or digit gstack can use in a project name; rename the folder or set GSTACK_PROJECT_SLUG"; return 0 ;;
esac
if [[ "$under" -eq 1 ]] && _is_home_or_above "$1"; then echo "at or above \$HOME"; return 0; fi
[[ ! -e "$1/.git" ]] || return 0
d=$(dirname "$1")
while [[ $depth -lt 64 ]]; do
if [[ "$under" -eq 1 ]] && _is_home_or_above "$d"; then break; fi
if [[ -e "$d/.git" ]]; then echo "inside the git work tree at $d; a marker counts only at a repository's top folder"; return 0; fi
parent=$(dirname "$d"); [[ "$parent" == "$d" ]] && break
d="$parent"; depth=$((depth + 1))
done
return 0
}
_find_project_marker() {
local dir="$1" parent="" why="" depth=0
while [[ -n "$dir" && $depth -lt 64 ]]; do
if [[ -e "$dir/.gstack-project" || -L "$dir/.gstack-project" ]]; then
why=$(_marker_reason "$dir")
if [[ -z "$why" ]]; then MARKER_ROOT=$(cd "$dir" 2>/dev/null && pwd -P) || MARKER_ROOT=""; [[ -z "$MARKER_ROOT" ]] || return 0; why="unreadable folder"; fi
MARKER_IGNORED="${MARKER_IGNORED}$dir/.gstack-project ($why)"$'\n'
fi
if [[ "$_UNDER_HOME" -eq 1 ]] && _is_home_or_above "$dir"; then break; fi
parent=$(dirname "$dir"); [[ "$parent" == "$dir" ]] && break
dir="$parent"; depth=$((depth + 1))
done
return 0
}
# A linked worktree outside the marked folder follows its main checkout: on a
# miss the walk repeats from the main checkout, under that folder's $HOME rule.
if [[ -z "$SLUG" && ! ( "$MODE" == get && "$MODE_ARG" == REPO_SLUG ) ]]; then
_find_project_marker "$PROJECT_DIR"
_main=""
[[ "$(basename "${GIT_COMMON_DIR:-x}")" != .git ]] || _main=$(dirname "$GIT_COMMON_DIR")
if [[ -z "$MARKER_ROOT" && -n "$_main" && "$PROJECT_DIR" != "$_main" && "$PROJECT_DIR" != "$_main"/* ]]; then
_cwd_under=$_UNDER_HOME
_UNDER_HOME=0
for _h in "$_HOME_LOG" "$_HOME_PHYS"; do
[[ -n "$_h" && "$_h" != / && "$_main" == "$_h"/* ]] && _UNDER_HOME=1
done
_find_project_marker "$_main"
_UNDER_HOME=$_cwd_under
fi
# A marker at a linked worktree's top folder stands for its main checkout.
if [[ -n "$MARKER_ROOT" && -f "$MARKER_ROOT/.git" ]]; then
_mc=$(_git_common_dir "$MARKER_ROOT")
[[ "$(basename "${_mc:-x}")" != .git ]] || MARKER_ROOT=$(dirname "$_mc")
fi
fi
_write_cache_entry() { # $1 file, $2 content; atomic, best-effort
local tmp
mkdir -p "$(dirname "$1")" 2>/dev/null || return 0
tmp=$(mktemp "$(dirname "$1")/.slug-XXXXXX" 2>/dev/null) || return 0
printf '%s' "$2" > "$tmp" && mv "$tmp" "$1" 2>/dev/null || rm -f "$tmp" 2>/dev/null
return 0
}
if [[ -n "$MARKER_ROOT" ]]; then
PROJECT_ROOT="$MARKER_ROOT"
MARKER_CACHE="$CACHE_DIR/marker/$(printf '%s' "$MARKER_ROOT" | sed -e 's/%/%25/g' -e 's/_/%5f/g' -e 's#/#_#g')"
_m=$(cat "$MARKER_CACHE" 2>/dev/null || true)
if [[ "$_m" == v2:* ]]; then
SLUG=$(printf '%s' "${_m#v2:}" | tr -cd 'a-zA-Z0-9._-')
case "$SLUG" in .|..) SLUG="" ;; esac
fi
if [[ -z "$SLUG" ]]; then
_resolve_remote
if [[ -n "$REMOTE_URL" ]]; then
gstack_remote_slug "$REMOTE_URL"
SLUG="$_gri_slug"
case "$SLUG" in ""|.|..|*/*) SLUG="" ;; esac
fi
SLUG="${SLUG:-$(basename "$MARKER_ROOT" | tr -cd 'a-zA-Z0-9._-')}"
case "$SLUG" in .|..) SLUG="" ;; esac
if [[ -n "$SLUG" ]]; then
_write_cache_entry "$MARKER_CACHE" "v2:$SLUG"
# One notice when the marker first moves this folder away from a bucket
# that holds checkpoints (stderr only; later runs are marker-cache hits).
for _f in "$CACHE_FILE" "$MAIN_CACHE_FILE" "$LEGACY_CACHE_FILE"; do
[[ -n "$_f" && -f "$_f" ]] || continue
_old=$(cat "$_f" 2>/dev/null || true); _old=$(printf '%s' "${_old#v2:}" | tr -cd 'a-zA-Z0-9._-')
if [[ -n "$_old" && "$_old" != "$SLUG" && "$_old" != . && "$_old" != .. && -n "$(ls -A "$GSTACK_STATE_ROOT/projects/$_old/checkpoints" 2>/dev/null)" ]]; then
echo "gstack: $MARKER_ROOT/.gstack-project files this folder under projects/$SLUG; checkpoints saved here before are in projects/$_old (preview recovering them with: gstack-slug --adopt-legacy --from $_old --under-root)" >&2
fi
break
done
fi
fi
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>/. 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=""
_ENTRY_FILE=""
for _f in "$CACHE_FILE" "$MAIN_CACHE_FILE" "$LEGACY_CACHE_FILE"; do
if [[ -n "$_f" && -f "$_f" ]]; then _ENTRY_FILE="$_f"; break; fi
done
if [[ -z "$SLUG" && -n "$_ENTRY_FILE" ]]; then
_RAW_CACHE=$(cat "$_ENTRY_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
# One hint on a fresh resolve: this checkout has its own differing remote
# but files under an outer repo's (stderr only; the cache keeps it quiet).
if [[ -n "$SLUG" && -n "$REMOTE_ROOT" && "$(basename "${GIT_COMMON_DIR:-x}")" == .git ]]; then
_here=$(dirname "$GIT_COMMON_DIR")
_outer=$(cd "$REMOTE_ROOT" 2>/dev/null && pwd -P) || _outer=""
if [[ -n "$_outer" && "$_here" != "$_outer" ]]; then
_own=$(git -C "$_here" remote get-url origin 2>/dev/null) || _own=""
if [[ -n "$_own" ]] && gstack_remote_slug "$_own" && [[ -n "$_gri_slug" && "$_gri_slug" != "$SLUG" ]]; then
echo "gstack: $_here has its own remote but files under projects/$SLUG (the outer repo at $_outer); to give it its own memory: touch '$_here/.gstack-project' (docs/state-root.md#nested-repos-and-umbrella-folders)" >&2
fi
gstack_remote_slug "$REMOTE_URL"
fi
fi
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._-')
# A worktree whose own pre-#2767 entry named a different bucket that still
# holds data: remember it so skill start can name the merge command.
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && -z "$MARKER_ROOT" && "$_ENTRY_FILE" != "$LEGACY_CACHE_FILE" && -f "$LEGACY_CACHE_FILE" ]]; then
_OWN=$(cat "$LEGACY_CACHE_FILE" 2>/dev/null || true)
_OWN=$(printf '%s' "${_OWN#v2:}" | tr -cd 'a-zA-Z0-9._-')
_FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
if [[ -n "$_OWN" && "$_OWN" != "$SLUG" && "$_OWN" != . && "$_OWN" != .. && -n "$(ls -A "$GSTACK_STATE_ROOT/projects/$_OWN" 2>/dev/null)" ]] \
&& ! grep -qxF -- "$_OWN" "$_FORMER_FILE" 2>/dev/null; then
mkdir -p "$(dirname "$_FORMER_FILE")" 2>/dev/null && printf '%s\n' "$_OWN" >> "$_FORMER_FILE" 2>/dev/null || true
fi
fi
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && -z "$MARKER_ROOT" ]]; then
for _f in "$CACHE_FILE" "$LEGACY_CACHE_FILE"; do
CURRENT_CACHE=""
if [[ -f "$_f" ]]; then
CURRENT_CACHE=$(cat "$_f" 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" "$_f" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null
fi
fi
[[ "$CACHE_FILE" == "$LEGACY_CACHE_FILE" ]] && break
done
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" == "get" && ( "$MODE_ARG" == SLUG || "$MODE_ARG" == REPO_SLUG || "$MODE_ARG" == BRANCH ) ]]; then
if [[ "$MODE_ARG" == BRANCH ]]; then printf '%s\n' "$BRANCH"; else printf '%s\n' "$SLUG"; fi
exit 0
fi
FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
if [[ "$MODE" == "get" && "$MODE_ARG" == FORMER_SLUGS ]]; then
# Former buckets still holding data and not yet adopted or dismissed.
while IFS= read -r _former || [[ -n "$_former" ]]; do
_former=$(printf '%s' "$_former" | tr -cd 'a-zA-Z0-9._-')
case "$_former" in ""|.|..) continue ;; esac
[[ -n "$(ls -A "$GSTACK_STATE_ROOT/projects/$_former" 2>/dev/null)" ]] && printf '%s\n' "$_former"
done < <(cat "$FORMER_FILE" 2>/dev/null || true)
exit 0
fi
_forget_former() {
[[ -f "$FORMER_FILE" ]] || return 0
local tmp
tmp=$(mktemp "$FORMER_FILE.XXXXXX") || return 1
grep -vxF -- "$1" "$FORMER_FILE" > "$tmp" || true
mv "$tmp" "$FORMER_FILE"
}
if [[ "$MODE" == "adopt-dismiss" ]]; then
_forget_former "$MODE_ARG" && echo "Dismissed projects/$MODE_ARG; its files are kept and skill start no longer names it."
exit $?
fi
if [[ "$MODE" == "adopt-from" ]]; then
FROM_BUCKET="$GSTACK_STATE_ROOT/projects/$MODE_ARG"
TO_BUCKET="$GSTACK_STATE_ROOT/projects/$SLUG"
[[ "$MODE_ARG" != "$SLUG" ]] || { echo "gstack-slug: projects/$MODE_ARG is this project's own bucket." >&2; exit 2; }
[[ -d "$FROM_BUCKET" && ! -L "$FROM_BUCKET" ]] || { echo "gstack-slug: no bucket at $FROM_BUCKET." >&2; exit 1; }
mkdir -p "$TO_BUCKET" || exit 1
MIGRATED=0; PENDING=0; CONFLICTING=0; UNCHANGED=0; CONFLICTS=""; PENDINGS=""
while IFS= read -r -d '' rel; do
rel="${rel#./}"
src="$FROM_BUCKET/$rel"; dst="$TO_BUCKET/$rel"
if [[ -L "$src" || ! -f "$src" ]]; then
PENDING=$((PENDING + 1)); PENDINGS="$PENDINGS $rel (not a regular file; review by hand)"$'\n'; continue
fi
if [[ ! -e "$dst" ]]; then
mkdir -p "$(dirname "$dst")" && cp -p "$src" "$dst" && MIGRATED=$((MIGRATED + 1)) && continue
PENDING=$((PENDING + 1)); PENDINGS="$PENDINGS $rel (copy failed)"$'\n'; continue
fi
if cmp -s "$src" "$dst"; then UNCHANGED=$((UNCHANGED + 1)); continue; fi
if [[ "$rel" == *.jsonl && -f "$dst" && ! -L "$dst" ]]; then
# Append-only logs: add the lines this bucket lacks, keeping its order.
tmp=$(mktemp "$dst.XXXXXX") || { PENDING=$((PENDING + 1)); continue; }
if { cat "$dst"; awk 'FNR == NR { seen[$0] = 1; next } !($0 in seen) { seen[$0] = 1; print }' "$dst" "$src"; } > "$tmp"; then
if cmp -s "$tmp" "$dst"; then rm -f "$tmp"; UNCHANGED=$((UNCHANGED + 1)); else mv "$tmp" "$dst"; MIGRATED=$((MIGRATED + 1)); fi
else
rm -f "$tmp"; PENDING=$((PENDING + 1)); PENDINGS="$PENDINGS $rel (merge failed)"$'\n'
fi
continue
fi
CONFLICTING=$((CONFLICTING + 1)); CONFLICTS="$CONFLICTS $rel"$'\n'
done < <(cd "$FROM_BUCKET" && find . \( -type f -o -type l \) ! -name .former-slugs -print0 2>/dev/null)
echo "adopt-legacy --from $MODE_ARG into projects/$SLUG: migrated $MIGRATED, pending $PENDING, conflicting $CONFLICTING, unchanged $UNCHANGED"
[[ -z "$PENDINGS" ]] || printf 'Pending (left in projects/%s):\n%s' "$MODE_ARG" "$PENDINGS"
[[ -z "$CONFLICTS" ]] || printf 'Conflicting (both differ; nothing overwritten, compare by hand):\n%s' "$CONFLICTS"
echo "projects/$MODE_ARG is kept; delete it yourself once you are satisfied."
_forget_former "$MODE_ARG" || true
[[ "$PENDING" -eq 0 ]]
exit $?
fi
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
}
# Physical root of the checkout holding $1 (nearest .git entry), or nothing.
_git_toplevel() {
local dir="$1" parent="" depth=0
while [[ -n "$dir" && $depth -lt 64 ]]; do
if [[ -e "$dir/.git" ]]; then (cd "$dir" 2>/dev/null && pwd -P); return 0; fi
parent=$(dirname "$dir")
[[ "$parent" == "$dir" ]] && break
dir="$parent"
depth=$((depth + 1))
done
return 0
}
# Prints "<branch>\037<project_root>\037<worktree>\037<title>": frontmatter
# fields and the "## Working on:" heading (the filename's title part without it).
_checkpoint_meta() {
local name
name=$(basename "$1" .md)
awk -v fallback="$name" '
function val(key) { v = $0; sub("^" key ":[ \t]*", "", v); sub(/[ \t\r]+$/, "", v); return v }
NR == 1 && $0 == "---" { fm = 1; next }
fm && $0 == "---" { fm = 0; next }
fm && /^branch:/ && !hb { b = val("branch"); hb = 1 }
fm && /^project_root:/ && !hr { r = val("project_root"); hr = 1 }
fm && /^worktree:/ && !hw { w = val("worktree"); hw = 1 }
!fm && /^## Working on:/ { t = val("## Working on"); exit }
END {
if (t == "") { t = fallback; sub(/^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-?/, "", t) }
printf "%s\037%s\037%s\037%s\n", b, r, w, t
}' "$1" 2>/dev/null || true
}
# Task-title form: lowercased, a leading date removed, punctuation and
# whitespace collapsed to single spaces. Generic titles normalize to nothing.
_norm_title() {
local t
t=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
| sed -E 's/^[[:space:]]*[0-9]{4}-?[0-9]{2}-?[0-9]{2}([-_t ]?[0-9]{2}:?[0-9]{2}(:?[0-9]{2})?)?//; s/[[:punct:][:space:]]+/ /g; s/^ //; s/ $//')
case "$t" in wip|checkpoint|save|notes|todo) t="" ;; esac
printf '%s' "$t"
}
# Ticket tokens (#123, ABC-123), one per line.
_ticket_tokens() {
printf '%s\n' "$@" | grep -oE '#[0-9]+|[A-Z][A-Z0-9]+-[0-9]+' 2>/dev/null | sort -u || true
}
_json_str() {
local v="${1//\\/\\\\}"
v="${v//\"/\\\"}"
printf '"%s"' "$v"
}
# Prints the checkpoint path recorded in pointer $1 when it names a regular
# file inside a bucket's checkpoints directory; nothing otherwise.
_pointer_target() {
local v dir
v=$(sed -n 's/^ "checkpoint": "\(.*\)",$/\1/p' "$1" 2>/dev/null | head -1)
v="${v//\\\"/\"}"
v="${v//\\\\/\\}"
[[ -n "$v" && -f "$v" && ! -L "$v" ]] || return 0
dir=$(cd "$(dirname "$v")" 2>/dev/null && pwd -P) || return 0
[[ "$(basename "$dir")" == checkpoints && "$(dirname "$(dirname "$dir")")" == "$STATE_PHYS/projects" ]] || return 0
printf '%s\n' "$v"
}
_resolve_identity
STATE_PHYS=$(cd "$GSTACK_STATE_ROOT" 2>/dev/null && pwd -P) || STATE_PHYS="$GSTACK_STATE_ROOT"
case "$MODE" in
get)
case "$MODE_ARG" in
PROJECT_REMOTE) printf '%s\n' "$ID_REMOTE" ;;
PROJECT_ROOT) printf '%s\n' "$ID_ROOT" ;;
PROJECT_MARKER) [[ -z "$MARKER_ROOT" ]] && echo || printf '%s\n' "$MARKER_ROOT/.gstack-project" ;;
LEGACY_SLUG) printf '%s\n' "$ID_LEGACY" ;;
esac
;;
identity)
echo "SLUG=$SLUG"
echo "BRANCH=$BRANCH"
printf 'PROJECT_REMOTE=%q\n' "$ID_REMOTE"
printf 'PROJECT_ROOT=%q\n' "$ID_ROOT"
printf 'PROJECT_MARKER=%q\n' "${MARKER_ROOT:+$MARKER_ROOT/.gstack-project}"
echo "LEGACY_SLUG=$ID_LEGACY"
while IFS= read -r _ign; do
if [[ -n "$_ign" ]]; then printf 'MARKER_IGNORED=%q\n' "$_ign"; fi
done <<<"$MARKER_IGNORED"
;;
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
STAMP_WORKTREE=$(_git_toplevel "$PROJECT_DIR")
[[ "$STAMP_WORKTREE" != "$ID_ROOT" ]] || STAMP_WORKTREE=""
# The commit the save describes, so /context-restore can tell when work landed after it.
STAMP_HEAD=$(git -C "$PROJECT_DIR" rev-parse --verify -q HEAD 2>/dev/null) || STAMP_HEAD=""
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" GRI_WORKTREE="$STAMP_WORKTREE" GRI_HEAD="$STAMP_HEAD" awk '
function ids() {
if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]
print "project_root: " ENVIRON["GRI_ROOT"]
if (ENVIRON["GRI_WORKTREE"] != "") print "worktree: " ENVIRON["GRI_WORKTREE"]
if (ENVIRON["GRI_HEAD"] != "") print "head: " ENVIRON["GRI_HEAD"]
}
NR == 1 { if ($0 == "---") { infm = 1; print; next } print "---"; ids(); print "---" }
infm && $0 == "---" { ids(); print; infm = 0; next }
infm && (/^remote:/ || /^project_root:/ || /^worktree:/ || /^head:/) { 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
;;
below)
# Checkpoints a session saved in a repository below this directory: the
# pointers a /context-save started here left, and (outside git) other
# buckets' checkpoints whose project_root lies below the cwd. Only the 200
# newest are read; from $HOME each project shows only its newest.
BELOW_CWD=$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P) || BELOW_CWD="$PROJECT_DIR"
BELOW_HOME=$(cd "${HOME:-/nonexistent}" 2>/dev/null && pwd -P) || BELOW_HOME=""
BELOW_SEEN=$'\n'; BELOW_ROOTS=$'\n'; BELOW_OUT=""
while IFS= read -r ptr; do
[[ -n "$ptr" ]] || continue
target=$(_pointer_target "$ptr")
[[ -n "$target" && "$BELOW_SEEN" != *$'\n'"$target"$'\n'* ]] || continue
BELOW_SEEN="$BELOW_SEEN$target"$'\n'
BELOW_OUT="$BELOW_OUT$(basename "$target")"$'\t'"POINTER $target"$'\n'
done < <(find "$STATE_PHYS/projects/$SLUG/checkpoint-pointers" -maxdepth 1 -name '*.json' -type f 2>/dev/null | LC_ALL=C sort -r | head -200)
if [[ -z "$GIT_COMMON_DIR" ]]; then
while IFS= read -r f; do
[[ -n "$f" && "$BELOW_SEEN" != *$'\n'"$f"$'\n'* ]] || continue
meta=$(_checkpoint_meta "$f")
IFS=$'\037' read -r _b root _w _t <<<"$meta"
[[ -n "$root" && "$root" == "$BELOW_CWD"/* ]] || continue
if [[ "$BELOW_CWD" == "$BELOW_HOME" ]]; then
[[ "$BELOW_ROOTS" != *$'\n'"$root"$'\n'* ]] || continue
BELOW_ROOTS="$BELOW_ROOTS$root"$'\n'
fi
BELOW_OUT="$BELOW_OUT$(basename "$f")"$'\t'"BELOW_CWD $f"$'\n'
done < <(find "$STATE_PHYS/projects" -mindepth 3 -maxdepth 3 -path '*/checkpoints/*.md' -type f \
! -path "$STATE_PHYS/projects/$SLUG/*" 2>/dev/null \
| awk -F/ '{ print $NF "\t" $0 }' | LC_ALL=C sort -r | head -200 | cut -f2-)
fi
printf '%s' "$BELOW_OUT" | LC_ALL=C sort -r | head -20 | cut -f2-
;;
point)
# A /context-save that started in DIR but filed elsewhere (a nested repo)
# leaves a JSON pointer in DIR's bucket so /context-restore there finds it.
[[ -d "$MODE_ARG" ]] || { echo "gstack-slug: no such start directory: $MODE_ARG" >&2; exit 1; }
[[ -f "$MODE_ARG2" && ! -L "$MODE_ARG2" ]] || { echo "gstack-slug: no such checkpoint file: $MODE_ARG2" >&2; exit 1; }
PT_FILE="$(cd "$(dirname "$MODE_ARG2")" && pwd -P)/$(basename "$MODE_ARG2")"
PT_BUCKET=$(basename "$(dirname "$(dirname "$PT_FILE")")")
[[ "$(basename "$(dirname "$PT_FILE")")" == checkpoints && "$(dirname "$(dirname "$(dirname "$PT_FILE")")")" == "$STATE_PHYS/projects" ]] \
|| { echo "gstack-slug: $MODE_ARG2 is not a saved checkpoint under $GSTACK_STATE_ROOT/projects" >&2; exit 1; }
PT_START=$(cd "$MODE_ARG" && "$0" --get SLUG 2>/dev/null) || exit 1
case "$PT_START" in ""|.|..) exit 1 ;; esac
[[ "$PT_START" != "$PT_BUCKET" ]] || exit 0
IFS=$'\037' read -r _b PT_ROOT _w _t <<<"$(_checkpoint_meta "$PT_FILE")"
PT_DIR="$GSTACK_STATE_ROOT/projects/$PT_START/checkpoint-pointers"
mkdir -p "$PT_DIR" || exit 1
PT_OUT="$PT_DIR/$(basename "$PT_FILE" .md)--$PT_BUCKET.json"
printf '{\n "checkpoint": %s,\n "bucket": %s,\n "project_root": %s\n}\n' \
"$(_json_str "$PT_FILE")" "$(_json_str "$PT_BUCKET")" "$(_json_str "$PT_ROOT")" > "$PT_OUT" || exit 1
echo "POINTER_WRITTEN $PT_OUT"
;;
newer-task)
# Restore candidates arrive current-branch first. When another branch of
# this repository holds a newer checkpoint for the same task (a worktree
# below the main tree, the same title, or the same ticket token), name it
# so /context-restore shows both instead of silently taking the branch match.
NT_FIRST=""; NT_FB=""; NT_FR=""; NT_FW=""; NT_FT=""; NT_FTOK=""
NT_HERE=$(_git_toplevel "$PROJECT_DIR")
NT_MAIN=""
[[ "$(basename "$GIT_COMMON_DIR")" != .git ]] || NT_MAIN=$(dirname "$GIT_COMMON_DIR")
while IFS= read -r f || [[ -n "$f" ]]; do
[[ -n "$f" ]] || continue
if [[ -n "$NT_FIRST" ]]; then
nb=$(basename "$f"); fb=$(basename "$NT_FIRST")
[[ "${nb:0:15}" > "${fb:0:15}" ]] || continue
fi
IFS=$'\037' read -r b root wt title <<<"$(_checkpoint_meta "$f")"
if [[ -z "$NT_FIRST" ]]; then
[[ -n "$MODE_ARG" && "$b" == "$MODE_ARG" ]] || exit 0
NT_FIRST="$f"; NT_FB="$b"; NT_FT=$(_norm_title "$title"); NT_FTOK=$(_ticket_tokens "$title" "$b")
continue
fi
[[ "$b" != "$NT_FB" ]] || continue
wt="${wt:-$root}"
if [[ -n "$NT_MAIN" && "$NT_HERE" == "$NT_MAIN" && "$wt" == "$NT_MAIN"/* ]]; then
echo "NEWER_TASK $f"; exit 0
fi
nt=$(_norm_title "$title")
if [[ -n "$nt" && "$nt" == "$NT_FT" ]]; then echo "NEWER_TASK $f"; exit 0; fi
if [[ -n "$NT_FTOK" ]] && [[ -n "$(comm -12 <(printf '%s\n' "$NT_FTOK") <(_ticket_tokens "$title" "$b") 2>/dev/null)" ]]; then
echo "NEWER_TASK $f"; exit 0
fi
done
;;
adopt-under)
# Recover checkpoints a former bucket holds for the marked folder: only
# those stamped (worktree:, else project_root:) at or below it. Preview
# unless --apply; copies are restamped with this identity and keep head:.
[[ -n "$MARKER_ROOT" ]] || { echo "gstack-slug: no .gstack-project applies here, so there is no marked folder to recover checkpoints for (see gstack-slug --identity)" >&2; exit 2; }
[[ "$MODE_ARG" != "$SLUG" ]] || { echo "gstack-slug: projects/$MODE_ARG is this project's own bucket." >&2; exit 2; }
AU_FROM="$STATE_PHYS/projects/$MODE_ARG/checkpoints"
AU_TO="$STATE_PHYS/projects/$SLUG/checkpoints"
if [[ ! -d "$AU_FROM" || -L "$AU_FROM" ]]; then echo "Nothing to recover: no checkpoints in projects/$MODE_ARG."; exit 0; fi
AU_WANT=0; AU_HAVE=0; AU_UNSTAMPED=0; AU_RC=0
while IFS= read -r f; do
[[ -n "$f" && -f "$f" && ! -L "$f" ]] || continue
IFS=$'\037' read -r _b root wt _t <<<"$(_checkpoint_meta "$f")"
if [[ -z "$root" && -z "$wt" ]]; then AU_UNSTAMPED=$((AU_UNSTAMPED + 1)); continue; fi
where="${wt:-$root}"
[[ "$where" == "$MARKER_ROOT" || "$where" == "$MARKER_ROOT"/* ]] || continue
dst="$AU_TO/$(basename "$f")"
if [[ -e "$dst" || -L "$dst" ]]; then echo "ALREADY $dst"; AU_HAVE=$((AU_HAVE + 1)); continue; fi
AU_WANT=$((AU_WANT + 1))
if [[ "$ADOPT_APPLY" -eq 0 ]]; then echo "WOULD_COPY $f"; continue; fi
AU_WT="${wt:-$root}"; [[ "$AU_WT" != "$ID_ROOT" ]] || AU_WT=""
mkdir -p "$AU_TO" && AU_TMP=$(mktemp "$AU_TO/.adopt-XXXXXX") || { echo "FAILED $f"; AU_RC=1; continue; }
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" GRI_WORKTREE="$AU_WT" awk '
function ids() {
if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]
print "project_root: " ENVIRON["GRI_ROOT"]
if (ENVIRON["GRI_WORKTREE"] != "") print "worktree: " ENVIRON["GRI_WORKTREE"]
}
NR == 1 && $0 == "---" { infm = 1; print; next }
infm && $0 == "---" { ids(); print; infm = 0; next }
infm && (/^remote:/ || /^project_root:/ || /^worktree:/) { next }
{ print }' "$f" > "$AU_TMP" && touch -r "$f" "$AU_TMP" && ln "$AU_TMP" "$dst" 2>/dev/null; then
rm -f "$AU_TMP"; echo "COPIED $dst"
elif [[ -e "$dst" || -L "$dst" ]]; then
rm -f "$AU_TMP"; echo "ALREADY $dst"; AU_WANT=$((AU_WANT - 1)); AU_HAVE=$((AU_HAVE + 1))
else
rm -f "$AU_TMP"; echo "FAILED $f"; AU_RC=1
fi
done < <(find "$AU_FROM" -maxdepth 1 -name '*.md' -type f 2>/dev/null | LC_ALL=C sort)
if [[ "$ADOPT_APPLY" -eq 1 ]]; then
echo "recovered $AU_WANT checkpoint(s) from projects/$MODE_ARG into projects/$SLUG ($AU_HAVE already there); projects/$MODE_ARG is unchanged"
else
echo "$AU_WANT checkpoint(s) in projects/$MODE_ARG were saved inside $MARKER_ROOT ($AU_HAVE already here); nothing copied. To copy and restamp them: gstack-slug --adopt-legacy --from $MODE_ARG --under-root --apply"
fi
[[ "$AU_UNSTAMPED" -eq 0 ]] || echo "$AU_UNSTAMPED checkpoint(s) predate identity stamps, so their folder is unknown; they stay in projects/$MODE_ARG"
exit "$AU_RC"
;;
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