mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
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>
This commit is contained in:
1 parent
066ceb084a
commit
482ffd1440
20 files changed
+964
-28
No files matched your search
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.92.14.0] - 2026-11-01
|
||||||
|
|
||||||
|
**A repository inside another repository can now be its own project: create an empty `.gstack-project` file in it.** gstack files each project's checkpoints, learnings and decisions under the outermost repository's slug, so a repo checked out inside a workspace repo filed its memory under the workspace and `/context-restore` couldn't find it later (#2649: 5 of one user's 16 nested repos, one with 73 checkpoints). The same marker in a plain folder above several repos gives them one shared memory, so a plan made in the umbrella carries into the repo where you build it (#1923). Nothing changes for anyone who doesn't create the file, and `GSTACK_PROJECT_SLUG` still wins.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`.gstack-project` marker.** The innermost marker on the way up from the current folder makes its folder the project root. The slug is that folder's own remote, or its name when it has none. A marker counts only when it is a regular file you own, in a folder whose name has a letter or digit, not in your home folder itself or above it, and at a repository's top folder or in a folder outside every repository. So a cloned repository can't plant one in a subfolder to pick another project's bucket. `gstack-slug --identity` prints `PROJECT_MARKER=` and one `MARKER_IGNORED=` line, with the reason, for each marker that didn't count; `--get PROJECT_MARKER` works too.
|
||||||
|
- **`gstack-slug --adopt-legacy --from <slug> --under-root [--apply]`** previews, then copies and restamps, the checkpoints an old bucket holds that were saved inside the marked folder, so `/context-restore` finds them. The first run after you add a marker names the command when the old bucket holds checkpoints. A nested repo with its own remote and no marker gets a one-time hint on its first resolve.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Marker results have their own cache entries** (`slug-cache/marker/`), so deleting the marker restores the folder's earlier slug byte for byte. A linked worktree outside an umbrella follows it through its main checkout, from its first run, for as long as the marker counts.
|
||||||
|
- **`browse/bin/remote-slug` follows a counted marker**, linked worktrees included, so the design-doc lookups in /plan-ceo-review, /plan-devex-review, /autoplan and the spec review find docs where /office-hours saved them.
|
||||||
|
- **Records that belong to one repository ignore every marker**: pregate policy and run scratch, approvals, `/verify` results, the review log, the `/ship` spend-cap pointer, the cached repo mode and the Greptile early-push answer stay under the repository's own slug (`gstack-slug --get REPO_SLUG`, `slugFromEnvironment(..., { scope: 'repository' })`), so a branch that commits a marker can't change which policy applies.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `test/gstack-slug-project-marker.test.ts`: 29 cases for the bash resolver, its TS twin and remote-slug (nested repo, umbrella, innermost wins, env wins, cache restored on removal, ignored subfolder, link, directory and `$HOME` markers, hostile origin, worktree outside the umbrella (resolved before or after its main checkout), scoped checkpoint recovery that never replaces an existing file, folder names with no usable letters, cache keys that differ only by `/` and `_`, a non-ASCII parent in the C locale, repo mode, the review log and the spend-cap pointer under a marker, recovered checkpoints keeping their repository, a remote-less repo's linked worktrees, hook reader, cross-writer entries, repository scope). The cases that expect a marker to take effect fail on the previous code; the ignored-marker cases are controls that pass on both.
|
||||||
|
|
||||||
## [1.92.13.0] - 2026-11-01
|
## [1.92.13.0] - 2026-11-01
|
||||||
|
|
||||||
- `/ship`'s preflight no longer runs `git merge` when `origin/<base>` is already in HEAD. It checks `git merge-base --is-ancestor` first and reports `MERGE: up to date`, so a checkout whose guard refuses `git merge` (a shared machine) gets past Step 3 when there is nothing to merge. A moved base still merges (GST-691).
|
- `/ship`'s preflight no longer runs `git merge` when `origin/<base>` is already in HEAD. It checks `git merge-base --is-ancestor` first and reports `MERGE: up to date`, so a checkout whose guard refuses `git merge` (a shared machine) gets past Step 3 when there is nothing to merge. A moved base still merges (GST-691).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.92.13.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.92.14.0 — regenerate/re-copy after upgrading gstack
|
||||||
|
|
||||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ fi
|
|||||||
# PWD-keyed slug cache matches other gstack project-state writers.
|
# PWD-keyed slug cache matches other gstack project-state writers.
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||||
[ -z "${REPO_ROOT:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
|
[ -z "${REPO_ROOT:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
|
||||||
SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" | sed -n 's/^SLUG=//p' | head -1)
|
SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" --get REPO_SLUG | head -1)
|
||||||
[ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
|
[ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
|
||||||
|
|
||||||
# Validate: only allow known values (prevent shell injection via source <(...))
|
# Validate: only allow known values (prevent shell injection via source <(...))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export GIT_OPTIONAL_LOCKS=0
|
|||||||
export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'"
|
export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||||
|
SLUG=$("$SCRIPT_DIR/gstack-slug" --get REPO_SLUG 2>/dev/null || true)
|
||||||
. "$(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; }
|
. "$(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_select
|
||||||
GSTACK_HOME="$_gstack_sr_root"
|
GSTACK_HOME="$_gstack_sr_root"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ case "$(uname -s)" in
|
|||||||
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
|
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
|
||||||
esac
|
esac
|
||||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||||
|
SLUG=$("$SCRIPT_DIR/gstack-slug" --get REPO_SLUG 2>/dev/null || true)
|
||||||
. "$(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; }
|
. "$(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_select
|
||||||
GSTACK_HOME="$_gstack_sr_root"
|
GSTACK_HOME="$_gstack_sr_root"
|
||||||
|
|||||||
+215
-7
@@ -46,6 +46,11 @@
|
|||||||
#
|
#
|
||||||
# Resolution order (highest precedence first):
|
# Resolution order (highest precedence first):
|
||||||
# 0. $GSTACK_PROJECT_SLUG env override (documented escape hatch)
|
# 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
|
# 1. Walk UP from $(pwd) to the OUTERMOST ancestor containing a canonical
|
||||||
# project-identity marker (.git, .project.yaml, package.json, pyproject.toml,
|
# project-identity marker (.git, .project.yaml, package.json, pyproject.toml,
|
||||||
# Cargo.toml, Gemfile, go.mod). Use that ancestor as the "project root".
|
# Cargo.toml, Gemfile, go.mod). Use that ancestor as the "project root".
|
||||||
@@ -108,8 +113,8 @@ case "${1:-}" in
|
|||||||
"") ;;
|
"") ;;
|
||||||
--identity) MODE=identity ;;
|
--identity) MODE=identity ;;
|
||||||
--get) MODE=get; MODE_ARG="${2:-}"
|
--get) MODE=get; MODE_ARG="${2:-}"
|
||||||
case "$MODE_ARG" in SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|LEGACY_SLUG|FORMER_SLUGS) ;;
|
case "$MODE_ARG" in SLUG|REPO_SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|PROJECT_MARKER|LEGACY_SLUG|FORMER_SLUGS) ;;
|
||||||
*) echo "usage: gstack-slug --get SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|LEGACY_SLUG|FORMER_SLUGS" >&2; exit 2 ;;
|
*) echo "usage: gstack-slug --get SLUG|REPO_SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|PROJECT_MARKER|LEGACY_SLUG|FORMER_SLUGS" >&2; exit 2 ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
--stamp-checkpoint) MODE=stamp; MODE_ARG="${2:-}"
|
--stamp-checkpoint) MODE=stamp; MODE_ARG="${2:-}"
|
||||||
[[ -n "$MODE_ARG" ]] || { echo "usage: gstack-slug --stamp-checkpoint <file>" >&2; exit 2; } ;;
|
[[ -n "$MODE_ARG" ]] || { echo "usage: gstack-slug --stamp-checkpoint <file>" >&2; exit 2; } ;;
|
||||||
@@ -127,6 +132,11 @@ case "${1:-}" in
|
|||||||
MODE=$([[ "$1" == "--from" ]] && echo adopt-from || echo adopt-dismiss)
|
MODE=$([[ "$1" == "--from" ]] && echo adopt-from || echo adopt-dismiss)
|
||||||
MODE_ARG=$(printf '%s' "${2:-}" | tr -cd 'a-zA-Z0-9._-')
|
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
|
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
|
elif [[ -n "${1:-}" ]]; then
|
||||||
echo "usage: gstack-slug --adopt-legacy [--copy <relpath>... | --from <slug> | --dismiss <slug>]" >&2; exit 2
|
echo "usage: gstack-slug --adopt-legacy [--copy <relpath>... | --from <slug> | --dismiss <slug>]" >&2; exit 2
|
||||||
fi ;;
|
fi ;;
|
||||||
@@ -136,6 +146,8 @@ gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current di
|
|||||||
eval "$(gstack-slug)" set SLUG and BRANCH
|
eval "$(gstack-slug)" set SLUG and BRANCH
|
||||||
SLUG=$(gstack-slug --get SLUG) print one raw value (SLUG, BRANCH, or an --identity
|
SLUG=$(gstack-slug --get SLUG) print one raw value (SLUG, BRANCH, or an --identity
|
||||||
field); no eval needed
|
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),
|
gstack-slug --identity also print PROJECT_REMOTE (canonical, credential-free),
|
||||||
PROJECT_ROOT and LEGACY_SLUG (pre-#3003 bucket, if different)
|
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 --stamp-checkpoint FILE write remote:/project_root:/head: into FILE's frontmatter
|
||||||
@@ -154,6 +166,12 @@ gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current di
|
|||||||
de-duplication, missing files copy, differing files are
|
de-duplication, missing files copy, differing files are
|
||||||
listed as conflicts and never overwritten; S is kept
|
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 --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.
|
Env: GSTACK_PROJECT_SLUG pins the slug (ssh host aliases, vendored sub-repos); never cached.
|
||||||
USAGE
|
USAGE
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
@@ -319,13 +337,137 @@ _REMOTE_RESOLVED=0
|
|||||||
_resolve_remote() {
|
_resolve_remote() {
|
||||||
if [[ "$_REMOTE_RESOLVED" -eq 1 ]]; then return 0; fi
|
if [[ "$_REMOTE_RESOLVED" -eq 1 ]]; then return 0; fi
|
||||||
_REMOTE_RESOLVED=1
|
_REMOTE_RESOLVED=1
|
||||||
REMOTE_ROOT=$(_outermost_remote_repo "$PROJECT_DIR")
|
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
|
if [[ -n "$REMOTE_ROOT" ]]; then
|
||||||
REMOTE_URL=$(git -C "$REMOTE_ROOT" remote get-url origin 2>/dev/null) || REMOTE_URL=""
|
REMOTE_URL=$(git -C "$REMOTE_ROOT" remote get-url origin 2>/dev/null) || REMOTE_URL=""
|
||||||
fi
|
fi
|
||||||
return 0
|
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
|
# 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
|
# adopted a git remote keeps its pre-origin slug — recomputing from the
|
||||||
# remote here would rename the project mid-life and orphan everything
|
# remote here would rename the project mid-life and orphan everything
|
||||||
@@ -408,6 +550,19 @@ if [[ -z "$SLUG" ]]; then
|
|||||||
# slash-bearing slugs and fall through to the basename fallback below.
|
# slash-bearing slugs and fall through to the basename fallback below.
|
||||||
# (tr -cd already deletes "/", so */* is belt-and-braces.)
|
# (tr -cd already deletes "/", so */* is belt-and-braces.)
|
||||||
case "$SLUG" in ""|.|..|*/*) SLUG="" ;; esac
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -432,7 +587,7 @@ SLUG=$(printf '%s' "$SLUG" | tr -cd 'a-zA-Z0-9._-')
|
|||||||
|
|
||||||
# A worktree whose own pre-#2767 entry named a different bucket that still
|
# 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.
|
# holds data: remember it so skill start can name the merge command.
|
||||||
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && "$_ENTRY_FILE" != "$LEGACY_CACHE_FILE" && -f "$LEGACY_CACHE_FILE" ]]; then
|
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=$(cat "$LEGACY_CACHE_FILE" 2>/dev/null || true)
|
||||||
_OWN=$(printf '%s' "${_OWN#v2:}" | tr -cd 'a-zA-Z0-9._-')
|
_OWN=$(printf '%s' "${_OWN#v2:}" | tr -cd 'a-zA-Z0-9._-')
|
||||||
_FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
|
_FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
|
||||||
@@ -442,7 +597,7 @@ if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && "$_ENTRY_FILE" != "$LEGACY_CACHE_F
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 ]]; then
|
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && -z "$MARKER_ROOT" ]]; then
|
||||||
for _f in "$CACHE_FILE" "$LEGACY_CACHE_FILE"; do
|
for _f in "$CACHE_FILE" "$LEGACY_CACHE_FILE"; do
|
||||||
CURRENT_CACHE=""
|
CURRENT_CACHE=""
|
||||||
if [[ -f "$_f" ]]; then
|
if [[ -f "$_f" ]]; then
|
||||||
@@ -470,8 +625,8 @@ 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=$(printf '%s' "${RAW_BRANCH:-}" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
|
||||||
BRANCH="${BRANCH:-unknown}"
|
BRANCH="${BRANCH:-unknown}"
|
||||||
|
|
||||||
if [[ "$MODE" == "get" && ( "$MODE_ARG" == SLUG || "$MODE_ARG" == BRANCH ) ]]; then
|
if [[ "$MODE" == "get" && ( "$MODE_ARG" == SLUG || "$MODE_ARG" == REPO_SLUG || "$MODE_ARG" == BRANCH ) ]]; then
|
||||||
if [[ "$MODE_ARG" == SLUG ]]; then printf '%s\n' "$SLUG"; else printf '%s\n' "$BRANCH"; fi
|
if [[ "$MODE_ARG" == BRANCH ]]; then printf '%s\n' "$BRANCH"; else printf '%s\n' "$SLUG"; fi
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
|
FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
|
||||||
@@ -665,6 +820,7 @@ case "$MODE" in
|
|||||||
case "$MODE_ARG" in
|
case "$MODE_ARG" in
|
||||||
PROJECT_REMOTE) printf '%s\n' "$ID_REMOTE" ;;
|
PROJECT_REMOTE) printf '%s\n' "$ID_REMOTE" ;;
|
||||||
PROJECT_ROOT) printf '%s\n' "$ID_ROOT" ;;
|
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" ;;
|
LEGACY_SLUG) printf '%s\n' "$ID_LEGACY" ;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
@@ -674,7 +830,11 @@ case "$MODE" in
|
|||||||
echo "BRANCH=$BRANCH"
|
echo "BRANCH=$BRANCH"
|
||||||
printf 'PROJECT_REMOTE=%q\n' "$ID_REMOTE"
|
printf 'PROJECT_REMOTE=%q\n' "$ID_REMOTE"
|
||||||
printf 'PROJECT_ROOT=%q\n' "$ID_ROOT"
|
printf 'PROJECT_ROOT=%q\n' "$ID_ROOT"
|
||||||
|
printf 'PROJECT_MARKER=%q\n' "${MARKER_ROOT:+$MARKER_ROOT/.gstack-project}"
|
||||||
echo "LEGACY_SLUG=$ID_LEGACY"
|
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)
|
stamp)
|
||||||
@@ -799,6 +959,54 @@ case "$MODE" in
|
|||||||
done
|
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)
|
adopt)
|
||||||
if [[ -z "$ID_LEGACY" ]]; then
|
if [[ -z "$ID_LEGACY" ]]; then
|
||||||
echo "Nothing to adopt: this project's slug ($SLUG) did not change."
|
echo "Nothing to adopt: this project's slug ($SLUG) did not change."
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
# bin/gstack-remote-identity.sh.
|
# bin/gstack-remote-identity.sh.
|
||||||
set -e
|
set -e
|
||||||
. "$(dirname "$0")/../../bin/gstack-remote-identity.sh" 2>/dev/null || { echo "$0: bin/gstack-remote-identity.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
|
. "$(dirname "$0")/../../bin/gstack-remote-identity.sh" 2>/dev/null || { echo "$0: bin/gstack-remote-identity.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
|
||||||
|
# A counted .gstack-project marker (#2649, #1923), found on the cwd chain or
|
||||||
|
# from a linked worktree's main checkout, decides identity the way bin/gstack-slug
|
||||||
|
# does, so design-doc lookups find docs where skills saved them.
|
||||||
|
_id=$("$(dirname "$0")/../../bin/gstack-slug" --identity 2>/dev/null || true)
|
||||||
|
_marker=$(printf '%s\n' "$_id" | sed -n 's/^PROJECT_MARKER=//p')
|
||||||
|
if [ -n "$_marker" ] && [ "$_marker" != "''" ]; then
|
||||||
|
printf '%s\n' "$_id" | sed -n 's/^SLUG=//p'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
URL=$(git remote get-url origin 2>/dev/null || true)
|
URL=$(git remote get-url origin 2>/dev/null || true)
|
||||||
_gri_slug=""
|
_gri_slug=""
|
||||||
if [ -n "$URL" ]; then
|
if [ -n "$URL" ]; then
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ gstack-approvals render # the ## Approvals se
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Where it lives.** `<state root>/projects/<slug>/approvals/<branch key>.jsonl`, one file per branch,
|
- **Where it lives.** `<state root>/projects/<slug>/approvals/<branch key>.jsonl`, one file per branch,
|
||||||
because one thread ships one branch. On Capy the state root is thrown away with the machine, so every write
|
because one thread ships one branch. `<slug>` is the repository's own slug (`gstack-slug --get
|
||||||
|
REPO_SLUG`); a `.gstack-project` marker never moves it. On Capy the state root is thrown away with the machine, so every write
|
||||||
is also mirrored to `GSTACK_APPROVALS_DIR`, else to the `approvals/` directory in the mounted Captain
|
is also mirrored to `GSTACK_APPROVALS_DIR`, else to the `approvals/` directory in the mounted Captain
|
||||||
drive (`<captain drive>/approvals/<slug>/`, readable by the whole crew), else to the private project-user
|
drive (`<captain drive>/approvals/<slug>/`, readable by the whole crew), else to the private project-user
|
||||||
Drive folder. `gstack-approvals path` prints the chosen mirror and `READ_ALSO:` roots, which reads still
|
Drive folder. `gstack-approvals path` prints the chosen mirror and `READ_ALSO:` roots, which reads still
|
||||||
|
|||||||
+2
-2
@@ -98,8 +98,8 @@ keeps `.gstack/` out of git (a shared repo where gstack is one contributor's
|
|||||||
tool) can hold the same file at `<state root>/projects/<slug>/pregate.json`. The
|
tool) can hold the same file at `<state root>/projects/<slug>/pregate.json`. The
|
||||||
state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
|
state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
|
||||||
else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `<slug>` is what
|
else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `<slug>` is what
|
||||||
`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository
|
`~/.claude/skills/gstack/bin/gstack-slug --get REPO_SLUG` prints in the repository
|
||||||
(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none
|
(`GSTACK_PROJECT_SLUG` overrides it; a `.gstack-project` marker does not). It is read only when the base carries none
|
||||||
(under `--policy-from head` it is the trusted policy the head file is compared with), it lives outside the repository so a PR still
|
(under `--policy-from head` it is the trusted policy the head file is compared with), it lives outside the repository so a PR still
|
||||||
cannot reach it, and the record names it (`policy user <path>`) and fingerprints
|
cannot reach it, and the record names it (`policy user <path>`) and fingerprints
|
||||||
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
|
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
|
||||||
|
|||||||
@@ -137,6 +137,78 @@ yourself, for example in the project's `.claude/settings.local.json`
|
|||||||
remote, or for a vendored sub-repo that should be its own project. The pin is
|
remote, or for a vendored sub-repo that should be its own project. The pin is
|
||||||
applied per run and never cached.
|
applied per run and never cached.
|
||||||
|
|
||||||
|
### Nested repos and umbrella folders
|
||||||
|
|
||||||
|
By default, a repository inside another repository files under the **outer**
|
||||||
|
repository's slug, even when the inner one has its own remote. To make a folder
|
||||||
|
a project of its own, create an empty marker file in it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
touch path/to/folder/.gstack-project
|
||||||
|
```
|
||||||
|
|
||||||
|
- **A nested repo** (`~/projects/app-one/` with its own remote inside a
|
||||||
|
workspace repo): put the marker at the nested repo's top folder. It and every
|
||||||
|
folder under it file under its own remote's slug. The outer repo is unchanged.
|
||||||
|
- **An umbrella folder** (a plain folder over `backend/` and `frontend/`
|
||||||
|
repos): put the marker in the umbrella. The umbrella and both repos share
|
||||||
|
one memory under the umbrella folder's name, so a plan made in the umbrella
|
||||||
|
carries into the repo where you build it. A repo under the umbrella that should
|
||||||
|
keep its own memory gets its own marker; the innermost marker wins.
|
||||||
|
|
||||||
|
The marker's contents are ignored. It counts only when it is a regular file
|
||||||
|
you own, in a folder whose name has a letter or digit, and either at a
|
||||||
|
repository's top folder or in a folder that isn't inside any repository. When
|
||||||
|
you work under your home folder, a marker in your home folder itself or above
|
||||||
|
it is ignored; outside your home folder (a CI or cloud checkout under
|
||||||
|
`/workspace`, say) every folder up to `/` is checked, so keep stray markers out
|
||||||
|
of shared parents. A marker in a subfolder of a repository, a link or a
|
||||||
|
directory is ignored, so a repository you clone can't choose another project's
|
||||||
|
memory. An umbrella without a remote of its own files under its folder name, so
|
||||||
|
two umbrellas with the same name (`~/clientA/work` and `~/clientB/work`) share
|
||||||
|
one memory: rename one, or pin each with `GSTACK_PROJECT_SLUG`. Commit
|
||||||
|
it in a nested repo you share, if you like; it changes nothing for someone who
|
||||||
|
clones that repo on its own.
|
||||||
|
|
||||||
|
Checkpoints, learnings, decisions, the timeline and design docs follow the
|
||||||
|
marker. Records that belong to one repository never do: pregate policy and run
|
||||||
|
scratch, approvals, `/verify` results, the per-branch review log, the `/ship`
|
||||||
|
spend-cap pointer, the cached solo or collaborative repo mode and the Greptile
|
||||||
|
early-push answer stay
|
||||||
|
under the slug the repository has without a marker (`gstack-slug --get
|
||||||
|
REPO_SLUG`), so a branch that commits a marker can't change which policy
|
||||||
|
applies.
|
||||||
|
|
||||||
|
| To | Use | Lasts |
|
||||||
|
|---|---|---|
|
||||||
|
| Make a folder its own project | `.gstack-project` | until you delete it |
|
||||||
|
| Pick an exact bucket name | `GSTACK_PROJECT_SLUG` in the project's `.claude/settings.local.json` | per Claude Code project |
|
||||||
|
| Pick a name for one shell | `export GSTACK_PROJECT_SLUG=<name>` | that shell |
|
||||||
|
|
||||||
|
`GSTACK_PROJECT_SLUG` wins over a marker. Check which applies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-slug --identity
|
||||||
|
```
|
||||||
|
|
||||||
|
`PROJECT_MARKER=` names the marker in use; each `MARKER_IGNORED=` line names
|
||||||
|
a marker that didn't count and why. Deleting the marker restores the slug the
|
||||||
|
folder had before.
|
||||||
|
|
||||||
|
**Bringing earlier checkpoints across.** Checkpoints saved before the marker
|
||||||
|
stay in the old bucket, and the first run after you add the marker names it.
|
||||||
|
Preview the ones saved inside the marked folder, then copy them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-slug --adopt-legacy --from <old slug> --under-root
|
||||||
|
~/.claude/skills/gstack/bin/gstack-slug --adopt-legacy --from <old slug> --under-root --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Only checkpoints stamped with a folder at or below the marked one are copied,
|
||||||
|
and they are restamped so `/context-restore` finds them. Checkpoints from
|
||||||
|
before identity stamps, and other projects' files, stay where they are. The old
|
||||||
|
bucket is never changed.
|
||||||
|
|
||||||
## Ephemeral machines
|
## Ephemeral machines
|
||||||
|
|
||||||
A Capy cloud machine, a CI runner or a throwaway VM loses its disk when the
|
A Capy cloud machine, a CI runner or a throwaway VM loses its disk when the
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function locate(o: { cwd: string; env: Env; branch?: string }): StoreLoca
|
|||||||
}
|
}
|
||||||
if (!branch) return { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no branch at ${o.cwd} (detached HEAD or not a git repository)` };
|
if (!branch) return { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no branch at ${o.cwd} (detached HEAD or not a git repository)` };
|
||||||
const stateRoot = resolveStateRoot(o.env);
|
const stateRoot = resolveStateRoot(o.env);
|
||||||
const slug = slugFromEnvironment(stateRoot, o.cwd) || 'unknown';
|
const slug = slugFromEnvironment(stateRoot, o.cwd, { scope: 'repository' }) || 'unknown';
|
||||||
const dir = path.join(stateRoot, 'projects', slug, 'approvals');
|
const dir = path.join(stateRoot, 'projects', slug, 'approvals');
|
||||||
const file = path.join(dir, `${branchKey(branch)}.jsonl`);
|
const file = path.join(dir, `${branchKey(branch)}.jsonl`);
|
||||||
if (!contained(stateRoot, file)) return { code: 'APPROVAL_RECORD_INVALID', message: `record path escapes the state root: ${file}` };
|
if (!contained(stateRoot, file)) return { code: 'APPROVAL_RECORD_INVALID', message: `record path escapes the state root: ${file}` };
|
||||||
|
|||||||
+187
-4
@@ -6,8 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "fs";
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "fs";
|
||||||
import { basename, dirname, isAbsolute, join, resolve } from "path";
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
|
||||||
import { resolveStateRoot } from "./state-root";
|
import { resolveStateRoot } from "./state-root";
|
||||||
import { legacyRemoteSlug, remoteSlug } from "./remote-identity";
|
import { legacyRemoteSlug, remoteSlug } from "./remote-identity";
|
||||||
|
|
||||||
@@ -123,6 +123,10 @@ export function outermostRemoteRepo(startDir: string): { root: string; url: stri
|
|||||||
* test/bin-context-windows-slug.test.ts against test/gstack-slug-cwd-walk-up.test.ts
|
* test/bin-context-windows-slug.test.ts against test/gstack-slug-cwd-walk-up.test.ts
|
||||||
* and test/gstack-slug-parity.test.ts):
|
* and test/gstack-slug-parity.test.ts):
|
||||||
* 0. $GSTACK_PROJECT_SLUG env override — wins over everything, never cached.
|
* 0. $GSTACK_PROJECT_SLUG env override — wins over everything, never cached.
|
||||||
|
* 0m. A counted .gstack-project marker (see projectMarkerRoot): the marked
|
||||||
|
* folder's own remote slug, else its basename, cached in its own entry so
|
||||||
|
* removing the marker leaves the repo's entry as it was. Scope
|
||||||
|
* "repository" ignores every marker.
|
||||||
* 1. Walk UP to the OUTERMOST project root (see outermostProjectRoot). Without
|
* 1. Walk UP to the OUTERMOST project root (see outermostProjectRoot). Without
|
||||||
* the walk, a nested/vendored repo derived its slug from the INNERMOST
|
* the walk, a nested/vendored repo derived its slug from the INNERMOST
|
||||||
* `git remote get-url origin`, splitting the store the bash side keeps whole.
|
* `git remote get-url origin`, splitting the store the bash side keeps whole.
|
||||||
@@ -213,6 +217,170 @@ export function slugCacheReadOrder(stateRoot: string, cwd: string): string[] {
|
|||||||
/** Cache entries written since #3003 carry this version prefix ("v2:<slug>"). */
|
/** Cache entries written since #3003 carry this version prefix ("v2:<slug>"). */
|
||||||
export const SLUG_CACHE_VERSION_PREFIX = "v2:";
|
export const SLUG_CACHE_VERSION_PREFIX = "v2:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project marker (#2649, #1923), twin of bin/gstack-slug's step 0m: the innermost
|
||||||
|
* counted `.gstack-project` on the cwd chain makes its folder the project root.
|
||||||
|
* A marker 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. Stat-only: never spawns git.
|
||||||
|
*/
|
||||||
|
export const PROJECT_MARKER_FILE = ".gstack-project";
|
||||||
|
|
||||||
|
function homeForms(): string[] {
|
||||||
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||||
|
if (!home) return [];
|
||||||
|
let phys = home;
|
||||||
|
try {
|
||||||
|
phys = realpathSync(home);
|
||||||
|
} catch {
|
||||||
|
// keep the logical form only
|
||||||
|
}
|
||||||
|
return [...new Set([home, phys])].filter((h) => h !== "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnder(child: string, dir: string): boolean {
|
||||||
|
return child.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHomeOrAbove(dir: string, homes: string[]): boolean {
|
||||||
|
return dirname(dir) === dir || homes.some((h) => h === dir || isUnder(h, dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the marker in `dir` does not count, or "" when it counts. */
|
||||||
|
export function markerIgnoredReason(dir: string, underHome: boolean, homes: string[] = homeForms()): string {
|
||||||
|
const marker = join(dir, PROJECT_MARKER_FILE);
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = lstatSync(marker);
|
||||||
|
} catch {
|
||||||
|
return "not a regular file";
|
||||||
|
}
|
||||||
|
if (st.isSymbolicLink()) return "a symbolic link";
|
||||||
|
if (st.isDirectory()) return `a directory, not a file: rmdir '${marker}' && touch '${marker}'`;
|
||||||
|
if (!st.isFile()) return "not a regular file";
|
||||||
|
if (process.platform !== "win32" && typeof process.geteuid === "function" && st.uid !== process.geteuid()) {
|
||||||
|
return "owned by another user";
|
||||||
|
}
|
||||||
|
if (["", ".", ".."].includes(sanitizeSlug(basename(dir)))) {
|
||||||
|
return "its folder name has no letter or digit gstack can use in a project name; rename the folder or set GSTACK_PROJECT_SLUG";
|
||||||
|
}
|
||||||
|
if (underHome && isHomeOrAbove(dir, homes)) return "at or above $HOME";
|
||||||
|
if (existsSync(join(dir, ".git"))) return "";
|
||||||
|
let d = dirname(dir);
|
||||||
|
for (let depth = 0; depth < 64; depth++) {
|
||||||
|
if (underHome && isHomeOrAbove(d, homes)) break;
|
||||||
|
if (existsSync(join(d, ".git"))) {
|
||||||
|
return `inside the git work tree at ${d}; a marker counts only at a repository's top folder`;
|
||||||
|
}
|
||||||
|
const parent = dirname(d);
|
||||||
|
if (parent === d) break;
|
||||||
|
d = parent;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The innermost counted marker's folder (physical) on the cwd chain, plus every marker ignored on the way. */
|
||||||
|
export function findProjectMarker(cwd: string): { root: string; ignored: string[] } {
|
||||||
|
const homes = homeForms();
|
||||||
|
const underHome = homes.some((h) => isUnder(cwd, h));
|
||||||
|
const ignored: string[] = [];
|
||||||
|
let dir = cwd;
|
||||||
|
for (let depth = 0; dir && depth < 64; depth++) {
|
||||||
|
const marker = join(dir, PROJECT_MARKER_FILE);
|
||||||
|
let present = false;
|
||||||
|
try {
|
||||||
|
lstatSync(marker);
|
||||||
|
present = true;
|
||||||
|
} catch {
|
||||||
|
// no marker here
|
||||||
|
}
|
||||||
|
if (present) {
|
||||||
|
const why = markerIgnoredReason(dir, underHome, homes);
|
||||||
|
if (!why) {
|
||||||
|
try {
|
||||||
|
return { root: realpathSync(dir), ignored };
|
||||||
|
} catch {
|
||||||
|
ignored.push(`${marker} (unreadable folder)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ignored.push(`${marker} (${why})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (underHome && isHomeOrAbove(dir, homes)) break;
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
return { root: "", ignored };
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerCacheFile(stateRoot: string, root: string): string {
|
||||||
|
return join(stateRoot, "slug-cache", "marker", toMsysPath(root).replace(/%/g, "%25").replace(/_/g, "%5f").replace(/\//g, "_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marked folder that sets this cwd's identity, or "". A linked worktree
|
||||||
|
* outside the marked folder follows its main checkout: on a miss the walk
|
||||||
|
* repeats from there, as bin/gstack-slug does.
|
||||||
|
*/
|
||||||
|
export function projectMarkerRoot(cwd: string): string {
|
||||||
|
let root = findProjectMarker(cwd).root;
|
||||||
|
if (!root) {
|
||||||
|
const common = gitCommonDir(cwd);
|
||||||
|
const main = common && basename(common) === ".git" ? dirname(common) : "";
|
||||||
|
root = main && main !== cwd && !isUnder(cwd, main) ? findProjectMarker(main).root : "";
|
||||||
|
}
|
||||||
|
if (root && isFile(join(root, ".git"))) {
|
||||||
|
const common = gitCommonDir(root);
|
||||||
|
if (common && basename(common) === ".git") return dirname(common);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCacheEntry(file: string, content: string): void {
|
||||||
|
try {
|
||||||
|
let current: string | undefined;
|
||||||
|
try {
|
||||||
|
current = readFileSync(file, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// no entry yet
|
||||||
|
}
|
||||||
|
if (current === content) return;
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
const tmp = `${file}.tmp.${process.pid}`;
|
||||||
|
writeFileSync(tmp, content, "utf-8");
|
||||||
|
renameSync(tmp, file);
|
||||||
|
} catch {
|
||||||
|
// best-effort cache; a miss only costs a re-derive on the next call
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The marked folder's slug: its own canonical remote, else its basename; cached in its own entry. */
|
||||||
|
function markerSlug(stateRoot: string, cwd: string, root: string): string {
|
||||||
|
const file = markerCacheFile(stateRoot, root);
|
||||||
|
let slug = "";
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(file, "utf-8");
|
||||||
|
if (raw.startsWith(SLUG_CACHE_VERSION_PREFIX)) slug = sanitizeSlug(raw.slice(SLUG_CACHE_VERSION_PREFIX.length).trim());
|
||||||
|
} catch {
|
||||||
|
// miss
|
||||||
|
}
|
||||||
|
if (slug === "." || slug === "..") slug = "";
|
||||||
|
if (!slug) {
|
||||||
|
if (existsSync(join(root, ".git"))) {
|
||||||
|
const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf-8" });
|
||||||
|
const url = r.status === 0 ? (r.stdout || "").trim() : "";
|
||||||
|
const candidate = url ? remoteSlug(url) : "";
|
||||||
|
if (candidate && candidate !== "." && candidate !== ".." && !candidate.includes("/")) slug = candidate;
|
||||||
|
}
|
||||||
|
if (!slug) slug = sanitizeSlug(basename(root));
|
||||||
|
if (!slug || slug === "." || slug === "..") return "";
|
||||||
|
writeCacheEntry(file, `${SLUG_CACHE_VERSION_PREFIX}${slug}`);
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The slug a versioned cache entry records for `cwd`, or undefined when there is
|
* The slug a versioned cache entry records for `cwd`, or undefined when there is
|
||||||
* no entry, the entry predates the versioned format, or its value is unusable
|
* no entry, the entry predates the versioned format, or its value is unusable
|
||||||
@@ -221,7 +389,8 @@ export const SLUG_CACHE_VERSION_PREFIX = "v2:";
|
|||||||
* preamble runs, having already written the entry.
|
* preamble runs, having already written the entry.
|
||||||
*/
|
*/
|
||||||
export function readVersionedSlugCache(stateRoot: string, cwd: string): string | undefined {
|
export function readVersionedSlugCache(stateRoot: string, cwd: string): string | undefined {
|
||||||
const file = slugCacheReadOrder(stateRoot, cwd).find((f) => existsSync(f));
|
const marker = projectMarkerRoot(cwd);
|
||||||
|
const file = marker ? markerCacheFile(stateRoot, marker) : slugCacheReadOrder(stateRoot, cwd).find((f) => existsSync(f));
|
||||||
if (!file) return undefined;
|
if (!file) return undefined;
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
@@ -234,7 +403,11 @@ export function readVersionedSlugCache(stateRoot: string, cwd: string): string |
|
|||||||
return slug && slug !== "." && slug !== ".." ? slug : undefined;
|
return slug && slug !== "." && slug !== ".." ? slug : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string {
|
export function slugFromEnvironment(
|
||||||
|
gstackHome?: string,
|
||||||
|
cwd: string = process.cwd(),
|
||||||
|
opts: { scope?: "memory" | "repository" } = {},
|
||||||
|
): string {
|
||||||
const home = gstackHome || resolveStateRoot();
|
const home = gstackHome || resolveStateRoot();
|
||||||
const cacheDir = join(home, "slug-cache");
|
const cacheDir = join(home, "slug-cache");
|
||||||
const readOrder = slugCacheReadOrder(home, cwd);
|
const readOrder = slugCacheReadOrder(home, cwd);
|
||||||
@@ -246,6 +419,16 @@ export function slugFromEnvironment(gstackHome?: string, cwd: string = process.c
|
|||||||
const envSlug = sanitizeSlug((process.env.GSTACK_PROJECT_SLUG || "").trim());
|
const envSlug = sanitizeSlug((process.env.GSTACK_PROJECT_SLUG || "").trim());
|
||||||
if (envSlug && envSlug !== "." && envSlug !== "..") return envSlug;
|
if (envSlug && envSlug !== "." && envSlug !== "..") return envSlug;
|
||||||
|
|
||||||
|
// 0m. project marker (#2649) — its own cache entry; the repo's entry below is never touched.
|
||||||
|
// Scope "repository" (pregate policy and scratch, approvals, /verify) ignores
|
||||||
|
// every marker, so a branch that commits one can't move those records
|
||||||
|
// (twin of gstack-slug --get REPO_SLUG).
|
||||||
|
const markerRoot = opts.scope === "repository" ? "" : projectMarkerRoot(cwd);
|
||||||
|
if (markerRoot) {
|
||||||
|
const slug = markerSlug(home, cwd, markerRoot);
|
||||||
|
if (slug) return slug;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. outermost project root along the cwd ancestor chain (may be "").
|
// 1. outermost project root along the cwd ancestor chain (may be "").
|
||||||
const projectRoot = outermostProjectRoot(cwd);
|
const projectRoot = outermostProjectRoot(cwd);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -175,7 +175,7 @@ export function recordTrust(root: string, set: string, checks: readonly Check[],
|
|||||||
|
|
||||||
export function ledgerPath(root: string, env: NodeJS.ProcessEnv = process.env): string {
|
export function ledgerPath(root: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||||
const state = resolveStateRoot(env);
|
const state = resolveStateRoot(env);
|
||||||
return path.join(state, 'projects', slugFromEnvironment(state, root), 'verify', sha256(realRoot(root)).slice(0, 12), 'last-run.json');
|
return path.join(state, 'projects', slugFromEnvironment(state, root, { scope: 'repository' }), 'verify', sha256(realRoot(root)).slice(0, 12), 'last-run.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readLedger(file: string): Ledger | null {
|
export function readLedger(file: string): Ledger | null {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function visibility(env: EarlyEnv): "public" | "private" | "unknown" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function consentFile(env: EarlyEnv): string | undefined {
|
function consentFile(env: EarlyEnv): string | undefined {
|
||||||
const slug = env.run(path.join(env.binDir, "gstack-slug"), ["--get", "SLUG"]);
|
const slug = env.run(path.join(env.binDir, "gstack-slug"), ["--get", "REPO_SLUG"]);
|
||||||
let id = slug.status === 0 ? slug.stdout.trim() : "";
|
let id = slug.status === 0 ? slug.stdout.trim() : "";
|
||||||
if (!id) {
|
if (!id) {
|
||||||
const origin = env.run("git", ["remote", "get-url", "origin"]);
|
const origin = env.run("git", ["remote", "get-url", "origin"]);
|
||||||
|
|||||||
@@ -177,13 +177,13 @@ export function loadedDotenvFiles(cwd: string, execArgv: readonly string[]): str
|
|||||||
|
|
||||||
/** `<state root>/projects/<slug>/pregate.json` for the repository at `root`, from the caller's environment. */
|
/** `<state root>/projects/<slug>/pregate.json` for the repository at `root`, from the caller's environment. */
|
||||||
export function defaultUserConfigPath(root: string): string {
|
export function defaultUserConfigPath(root: string): string {
|
||||||
return path.join(resolveStateRoot(), 'projects', slugFromEnvironment(undefined, root), 'pregate.json');
|
return path.join(resolveStateRoot(), 'projects', slugFromEnvironment(undefined, root, { scope: 'repository' }), 'pregate.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `<state root>/projects/<slug>/pregate/runs`: where every run's scratch directory lives (lib/pregate/run-scratch.ts), from the caller's environment. */
|
/** `<state root>/projects/<slug>/pregate/runs`: where every run's scratch directory lives (lib/pregate/run-scratch.ts), from the caller's environment. */
|
||||||
export function defaultScratchRoot(root: string, env: NodeJS.ProcessEnv = process.env): string {
|
export function defaultScratchRoot(root: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||||
const stateRoot = resolveStateRoot(env);
|
const stateRoot = resolveStateRoot(env);
|
||||||
return path.join(stateRoot, 'projects', slugFromEnvironment(stateRoot, root), 'pregate', 'runs');
|
return path.join(stateRoot, 'projects', slugFromEnvironment(stateRoot, root, { scope: 'repository' }), 'pregate', 'runs');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This run's scratch directory, created under the state root; an unwritable state root falls back to a private directory under os.tmpdir() (still one per run). */
|
/** This run's scratch directory, created under the state root; an unwritable state root falls back to a private directory under os.tmpdir() (still one per run). */
|
||||||
|
|||||||
+5
-5
@@ -509,12 +509,12 @@ export function ledgerFileFor(o: { run?: string; record?: string }, env: NodeJS.
|
|||||||
throw usageError('pass --run <id> or --record <path>; runs are never found by branch or directory');
|
throw usageError('pass --run <id> or --record <path>; runs are never found by branch or directory');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SLUG and BRANCH exactly as gstack-review-log sees them (bin/gstack-slug). */
|
/** REPO_SLUG and BRANCH exactly as gstack-review-log sees them (bin/gstack-slug); a .gstack-project marker never moves them. */
|
||||||
export function reviewPaths(cwd: string, env: NodeJS.ProcessEnv): { marker: string; reviewLog: string } {
|
export function reviewPaths(cwd: string, env: NodeJS.ProcessEnv): { marker: string; reviewLog: string } {
|
||||||
const r = runScriptSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd, env, encoding: 'utf8', timeout: 30_000 });
|
const get = (k: string) => runScriptSync(path.join(BIN_DIR, 'gstack-slug'), ['--get', k], { cwd, env, encoding: 'utf8', timeout: 30_000 });
|
||||||
const get = (k: string) => new RegExp(`^${k}=(.*)$`, 'm').exec(r.stdout ?? '')?.[1]?.trim();
|
const s = get('REPO_SLUG'), b = get('BRANCH');
|
||||||
const slug = get('SLUG'), branch = get('BRANCH');
|
const slug = s.stdout?.trim(), branch = b.stdout?.trim();
|
||||||
if (r.status !== 0 || !slug || !branch) throw usageError(`cannot resolve the project slug and branch in ${cwd} (bin/gstack-slug exit ${r.status})`);
|
if (s.status !== 0 || b.status !== 0 || !slug || !branch) throw usageError(`cannot resolve the repository slug and branch in ${cwd} (bin/gstack-slug exit ${s.status || b.status})`);
|
||||||
const dir = path.join(resolveStateRoot(env), 'projects', slug);
|
const dir = path.join(resolveStateRoot(env), 'projects', slug);
|
||||||
return { marker: path.join(dir, `${branch}-ship-budget.json`), reviewLog: path.join(dir, `${branch}-reviews.jsonl`) };
|
return { marker: path.join(dir, `${branch}-ship-budget.json`), reviewLog: path.join(dir, `${branch}-reviews.jsonl`) };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.92.13",
|
"version": "1.92.14",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ describe("public-repo consent (ENG-20)", () => {
|
|||||||
expect(value(r.out, "GREPTILE_EARLY")).toBe("ask");
|
expect(value(r.out, "GREPTILE_EARLY")).toBe("ask");
|
||||||
expect(f.run("consent", "yes").out).toContain("GREPTILE_CONSENT: granted");
|
expect(f.run("consent", "yes").out).toContain("GREPTILE_CONSENT: granted");
|
||||||
expect(JSON.parse(fs.readFileSync(path.join(f.state, "projects/acme-widget/ship-greptile-early.json"), "utf8")).consent).toBe("yes");
|
expect(JSON.parse(fs.readFileSync(path.join(f.state, "projects/acme-widget/ship-greptile-early.json"), "utf8")).consent).toBe("yes");
|
||||||
|
expect(f.calls.filter(c => c.cmd.endsWith("gstack-slug")).map(c => c.args)).toContainEqual(["--get", "REPO_SLUG"]);
|
||||||
|
expect(f.calls.filter(c => c.cmd.endsWith("gstack-slug") && c.args.join(" ") !== "--get REPO_SLUG")).toEqual([]);
|
||||||
r = f.run("detect");
|
r = f.run("detect");
|
||||||
expect(value(r.out, "GREPTILE_CONSENT")).toBe("granted");
|
expect(value(r.out, "GREPTILE_CONSENT")).toBe("granted");
|
||||||
expect(value(r.out, "GREPTILE_EARLY")).toBe("on");
|
expect(value(r.out, "GREPTILE_EARLY")).toBe("on");
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* .gstack-project marker (#2649, #1923): an opt-in file that makes its folder a
|
||||||
|
* project of its own. Pinned for bin/gstack-slug, its TS twin in
|
||||||
|
* lib/bin-context.ts, and browse/bin/remote-slug. The default (outermost
|
||||||
|
* remote-bearing repo wins) is pinned unchanged in test/gstack-slug-parity.test.ts.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { projectMarkerRoot, readVersionedSlugCache, slugFromEnvironment } from '../lib/bin-context';
|
||||||
|
import { defaultUserConfigPath } from '../lib/pregate/context';
|
||||||
|
import { reviewPaths } from '../lib/ship-budget';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
|
const SLUG = path.join(ROOT, 'bin', 'gstack-slug');
|
||||||
|
const REMOTE_SLUG = path.join(ROOT, 'browse', 'bin', 'remote-slug');
|
||||||
|
|
||||||
|
let home: string;
|
||||||
|
let state: string;
|
||||||
|
let saved: Record<string, string | undefined>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-')));
|
||||||
|
state = path.join(home, '.gstack');
|
||||||
|
saved = { HOME: process.env.HOME, GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_PROJECT_SLUG: process.env.GSTACK_PROJECT_SLUG };
|
||||||
|
process.env.HOME = home;
|
||||||
|
process.env.GSTACK_HOME = state;
|
||||||
|
delete process.env.GSTACK_PROJECT_SLUG;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
for (const [k, v] of Object.entries(saved)) {
|
||||||
|
if (v === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = v;
|
||||||
|
}
|
||||||
|
fs.rmSync(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function env(extra: Record<string, string> = {}): Record<string, string | undefined> {
|
||||||
|
const { GSTACK_PROJECT_SLUG: _p, GSTACK_STATE_ROOT: _r, GSTACK_STATE_DIR: _d, CLAUDE_PLUGIN_DATA: _c, ...ambient } = process.env;
|
||||||
|
return { ...ambient, HOME: home, GSTACK_HOME: state, ...extra };
|
||||||
|
}
|
||||||
|
function run(script: string, cwd: string, args: string[] = [], extra: Record<string, string> = {}) {
|
||||||
|
return spawnSync('bash', [script, ...args], { cwd, env: env(extra), encoding: 'utf8', timeout: 15_000 });
|
||||||
|
}
|
||||||
|
const slug = (cwd: string, extra: Record<string, string> = {}) => run(SLUG, cwd, ['--get', 'SLUG'], extra).stdout.trim();
|
||||||
|
function identity(cwd: string): string {
|
||||||
|
const r = run(SLUG, cwd, ['--identity']);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
return r.stdout;
|
||||||
|
}
|
||||||
|
function git(args: string[], cwd?: string): void {
|
||||||
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 15_000, env: env({ GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }) });
|
||||||
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||||
|
}
|
||||||
|
function repo(dir: string, origin?: string): string {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
git(['init', '-q', '-b', 'main', dir]);
|
||||||
|
if (origin) git(['-C', dir, 'remote', 'add', 'origin', origin]);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
const mark = (dir: string) => fs.writeFileSync(path.join(dir, '.gstack-project'), '');
|
||||||
|
const cacheFiles = () => {
|
||||||
|
const dir = path.join(state, 'slug-cache');
|
||||||
|
return Object.fromEntries(fs.readdirSync(dir).filter((f) => fs.statSync(path.join(dir, f)).isFile()).map((f) => [f, fs.readFileSync(path.join(dir, f), 'utf8')]));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ws (me/workspace) holding the independent repo ws/proj (org/proj), as in #2649. */
|
||||||
|
function workspace() {
|
||||||
|
const ws = repo(path.join(home, 'ws'), 'git@github.com:me/workspace.git');
|
||||||
|
const proj = repo(path.join(ws, 'proj'), 'git@github.com:org/proj.git');
|
||||||
|
fs.mkdirSync(path.join(proj, 'app'));
|
||||||
|
return { ws, proj, app: path.join(proj, 'app') };
|
||||||
|
}
|
||||||
|
/** A plain (non-git) umbrella over two repos, as in #1923. */
|
||||||
|
function umbrella() {
|
||||||
|
const top = path.join(home, 'MyProduct');
|
||||||
|
const backend = repo(path.join(top, 'backend'), 'git@github.com:org/backend.git');
|
||||||
|
const frontend = repo(path.join(top, 'frontend'), 'git@github.com:org/frontend.git');
|
||||||
|
return { top, backend, frontend };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('gstack-slug: .gstack-project marker', () => {
|
||||||
|
test('a marker at a nested repo\'s top folder gives it (and its subfolders) its own slug; the outer repo is unchanged', () => {
|
||||||
|
const { ws, proj, app } = workspace();
|
||||||
|
const first = run(SLUG, proj, ['--get', 'SLUG']);
|
||||||
|
expect(first.stdout.trim()).toBe('me-workspace');
|
||||||
|
expect(first.stderr).toContain(`touch '${proj}/.gstack-project'`);
|
||||||
|
expect(run(SLUG, proj, ['--get', 'SLUG']).stderr).toBe('');
|
||||||
|
fs.mkdirSync(path.join(state, 'projects', 'me-workspace', 'checkpoints'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(state, 'projects', 'me-workspace', 'checkpoints', '20261001-120000-x.md'), '## Working on: x\n');
|
||||||
|
mark(proj);
|
||||||
|
const moved = run(SLUG, proj, ['--get', 'SLUG']);
|
||||||
|
expect(moved.stdout.trim()).toBe('org-proj');
|
||||||
|
expect(moved.stderr).toContain('--adopt-legacy --from me-workspace --under-root');
|
||||||
|
expect(run(SLUG, proj, ['--get', 'SLUG']).stderr).toBe('');
|
||||||
|
expect(run(SLUG, app, ['--get', 'PROJECT_MARKER']).stdout.trim()).toBe(path.join(proj, '.gstack-project'));
|
||||||
|
expect(run(SLUG, app, ['--get', 'REPO_SLUG']).stdout.trim()).toBe('me-workspace');
|
||||||
|
expect(slug(app)).toBe('org-proj');
|
||||||
|
expect(slug(ws)).toBe('me-workspace');
|
||||||
|
expect(identity(app)).toContain(`PROJECT_MARKER=${path.join(proj, '.gstack-project')}`);
|
||||||
|
expect(identity(app)).toContain(`PROJECT_ROOT=${proj}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the marker beats a sticky cache entry, and removing it restores every repo entry byte for byte', () => {
|
||||||
|
const { proj } = workspace();
|
||||||
|
expect(slug(proj)).toBe('me-workspace');
|
||||||
|
for (const p of [path.join(proj, '.git'), proj]) fs.writeFileSync(path.join(state, 'slug-cache', p.replace(/\//g, '_')), 'v2:sticky-name');
|
||||||
|
expect(slug(proj)).toBe('sticky-name');
|
||||||
|
const before = cacheFiles();
|
||||||
|
mark(proj);
|
||||||
|
expect(slug(proj)).toBe('org-proj');
|
||||||
|
expect(cacheFiles()).toEqual(before);
|
||||||
|
fs.rmSync(path.join(proj, '.gstack-project'));
|
||||||
|
expect(slug(proj)).toBe('sticky-name');
|
||||||
|
expect(cacheFiles()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an umbrella marker folds child repos into one project; a child\'s own marker wins', () => {
|
||||||
|
const { top, backend, frontend } = umbrella();
|
||||||
|
expect(slug(backend)).toBe('org-backend');
|
||||||
|
mark(top);
|
||||||
|
expect(slug(top)).toBe('MyProduct');
|
||||||
|
expect(slug(backend)).toBe('MyProduct');
|
||||||
|
expect(slug(frontend)).toBe('MyProduct');
|
||||||
|
mark(backend);
|
||||||
|
expect(slug(backend)).toBe('org-backend');
|
||||||
|
expect(slug(frontend)).toBe('MyProduct');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GSTACK_PROJECT_SLUG still beats a marker', () => {
|
||||||
|
const { proj } = workspace();
|
||||||
|
mark(proj);
|
||||||
|
expect(slug(proj, { GSTACK_PROJECT_SLUG: 'pinned' })).toBe('pinned');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a marker in a subfolder of a repo is ignored, with the reason in --identity', () => {
|
||||||
|
const { ws } = workspace();
|
||||||
|
const sub = path.join(ws, 'docs');
|
||||||
|
fs.mkdirSync(sub);
|
||||||
|
mark(sub);
|
||||||
|
expect(slug(sub)).toBe('me-workspace');
|
||||||
|
expect(identity(sub)).toMatch(/MARKER_IGNORED=.*inside\\ the\\ git\\ work\\ tree/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a linked, directory or dangling marker is ignored', () => {
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
fs.symlinkSync(path.join(home, 'nowhere'), path.join(top, '.gstack-project'));
|
||||||
|
expect(slug(backend)).toBe('org-backend');
|
||||||
|
fs.rmSync(path.join(top, '.gstack-project'));
|
||||||
|
fs.mkdirSync(path.join(top, '.gstack-project'));
|
||||||
|
expect(slug(backend)).toBe('org-backend');
|
||||||
|
expect(identity(backend)).toMatch(/MARKER_IGNORED=.*a\\ directory/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a marker at $HOME is ignored', () => {
|
||||||
|
const { backend } = umbrella();
|
||||||
|
mark(home);
|
||||||
|
expect(slug(backend)).toBe('org-backend');
|
||||||
|
expect(identity(backend)).toMatch(/MARKER_IGNORED=.*at\\ or\\ above/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hostile origin at the marked folder falls back to its basename', () => {
|
||||||
|
const proj = repo(path.join(home, 'outer', 'evil'), '..');
|
||||||
|
mark(proj);
|
||||||
|
expect(slug(proj)).toBe('evil');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a linked worktree outside the umbrella follows it while the marker exists', () => {
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||||
|
mark(top);
|
||||||
|
expect(slug(backend)).toBe('MyProduct');
|
||||||
|
const wt = path.join(home, 'elsewhere', 'wt');
|
||||||
|
git(['-C', backend, 'worktree', 'add', '-q', wt]);
|
||||||
|
expect(slug(wt)).toBe('MyProduct');
|
||||||
|
fs.rmSync(path.join(top, '.gstack-project'));
|
||||||
|
expect(slug(wt)).toBe('org-backend');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a linked worktree outside the umbrella follows it before the main checkout has ever resolved', () => {
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||||
|
mark(top);
|
||||||
|
const wt = path.join(home, 'elsewhere', 'wt');
|
||||||
|
git(['-C', backend, 'worktree', 'add', '-q', wt]);
|
||||||
|
expect(projectMarkerRoot(wt)).toBe(top);
|
||||||
|
expect(slug(wt)).toBe('MyProduct');
|
||||||
|
expect(slugFromEnvironment(state, wt)).toBe('MyProduct');
|
||||||
|
expect(run(REMOTE_SLUG, wt).stdout.trim()).toBe('MyProduct');
|
||||||
|
expect(slug(backend)).toBe('MyProduct');
|
||||||
|
expect(slug(wt)).toBe('MyProduct');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a worktree outside $HOME ignores a $HOME marker above its main checkout', () => {
|
||||||
|
const backend = repo(path.join(home, 'code', 'backend'), 'git@github.com:org/backend.git');
|
||||||
|
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||||
|
mark(home);
|
||||||
|
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-out-')));
|
||||||
|
try {
|
||||||
|
const wt = path.join(outside, 'wt');
|
||||||
|
git(['-C', backend, 'worktree', 'add', '-q', wt]);
|
||||||
|
expect(slug(wt)).toBe('org-backend');
|
||||||
|
expect(projectMarkerRoot(wt)).toBe('');
|
||||||
|
expect(slugFromEnvironment(state, wt)).toBe('org-backend');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(outside, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a worktree outside the umbrella follows it even when $HOME is itself a git repository', () => {
|
||||||
|
git(['init', '-q', home]);
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||||
|
mark(top);
|
||||||
|
expect(slug(backend)).toBe('MyProduct');
|
||||||
|
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-out-')));
|
||||||
|
try {
|
||||||
|
const wt = path.join(outside, 'wt');
|
||||||
|
git(['-C', backend, 'worktree', 'add', '-q', wt]);
|
||||||
|
expect(slug(wt)).toBe('MyProduct');
|
||||||
|
expect(slugFromEnvironment(state, wt)).toBe('MyProduct');
|
||||||
|
expect(run(REMOTE_SLUG, wt).stdout.trim()).toBe('MyProduct');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(outside, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote-slug agrees with gstack-slug under a counted marker and keeps its own answer otherwise', () => {
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
mark(top);
|
||||||
|
expect(run(REMOTE_SLUG, backend).stdout.trim()).toBe('MyProduct');
|
||||||
|
const { ws, proj } = workspace();
|
||||||
|
mark(path.join(ws));
|
||||||
|
fs.rmSync(path.join(ws, '.gstack-project'));
|
||||||
|
expect(run(REMOTE_SLUG, proj).stdout.trim()).toBe('org-proj');
|
||||||
|
mark(proj);
|
||||||
|
expect(run(REMOTE_SLUG, proj).stdout.trim()).toBe(slug(proj));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--adopt-legacy --under-root previews, then copies and restamps only checkpoints saved inside the marked folder', () => {
|
||||||
|
const { ws, proj, app } = workspace();
|
||||||
|
const old = path.join(state, 'projects', 'me-workspace', 'checkpoints');
|
||||||
|
fs.mkdirSync(old, { recursive: true });
|
||||||
|
const ck = (name: string, wt: string) => fs.writeFileSync(path.join(old, name), `---\nremote: github.com/me/workspace\nproject_root: ${ws}\nworktree: ${wt}\nhead: abc123\n---\n## Working on: x\n`);
|
||||||
|
ck('20261001-120000-inner.md', proj);
|
||||||
|
ck('20261002-120000-sibling.md', path.join(ws, 'other'));
|
||||||
|
fs.writeFileSync(path.join(old, '20260901-120000-old.md'), '## Working on: unstamped\n');
|
||||||
|
mark(proj);
|
||||||
|
const preview = run(SLUG, app, ['--adopt-legacy', '--from', 'me-workspace', '--under-root']);
|
||||||
|
expect(preview.status).toBe(0);
|
||||||
|
expect(preview.stdout).toContain('WOULD_COPY');
|
||||||
|
expect(preview.stdout).toContain('1 checkpoint(s) predate identity stamps');
|
||||||
|
expect(fs.existsSync(path.join(state, 'projects', 'org-proj', 'checkpoints'))).toBe(false);
|
||||||
|
const apply = run(SLUG, app, ['--adopt-legacy', '--from', 'me-workspace', '--under-root', '--apply']);
|
||||||
|
expect(apply.status).toBe(0);
|
||||||
|
const copied = fs.readdirSync(path.join(state, 'projects', 'org-proj', 'checkpoints'));
|
||||||
|
expect(copied).toEqual(['20261001-120000-inner.md']);
|
||||||
|
const text = fs.readFileSync(path.join(state, 'projects', 'org-proj', 'checkpoints', copied[0]), 'utf8');
|
||||||
|
expect(text).toContain('head: abc123');
|
||||||
|
expect(text).toContain(`project_root: ${proj}`);
|
||||||
|
const cls = spawnSync('bash', [SLUG, '--classify-checkpoints'], { cwd: app, env: env(), input: path.join(state, 'projects', 'org-proj', 'checkpoints', copied[0]) + '\n', encoding: 'utf8', timeout: 15_000 });
|
||||||
|
expect(cls.stdout.split('\t')[0]).toBe('match');
|
||||||
|
expect(fs.readdirSync(old).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--adopt-legacy --under-root never replaces what already sits at the destination name', () => {
|
||||||
|
const { ws, proj, app } = workspace();
|
||||||
|
const old = path.join(state, 'projects', 'me-workspace', 'checkpoints');
|
||||||
|
fs.mkdirSync(old, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(old, '20261001-120000-inner.md'), `---\nproject_root: ${ws}\nworktree: ${proj}\n---\n## Working on: x\n`);
|
||||||
|
mark(proj);
|
||||||
|
const dest = path.join(state, 'projects', 'org-proj', 'checkpoints');
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
fs.symlinkSync(path.join(home, 'saved-elsewhere.md'), path.join(dest, '20261001-120000-inner.md'));
|
||||||
|
const apply = run(SLUG, app, ['--adopt-legacy', '--from', 'me-workspace', '--under-root', '--apply']);
|
||||||
|
expect(apply.stdout).not.toContain('COPIED');
|
||||||
|
expect(fs.lstatSync(path.join(dest, '20261001-120000-inner.md')).isSymbolicLink()).toBe(true);
|
||||||
|
expect(fs.readdirSync(dest)).toEqual(['20261001-120000-inner.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a marked folder whose name has no usable letters or digits is ignored, the same in bash and TS', () => {
|
||||||
|
const top = path.join(home, '项目');
|
||||||
|
const backend = repo(path.join(top, 'backend'), 'git@github.com:org/backend.git');
|
||||||
|
fs.mkdirSync(path.join(backend, 'src'));
|
||||||
|
mark(top);
|
||||||
|
expect(slug(path.join(backend, 'src'))).toBe('org-backend');
|
||||||
|
expect(slugFromEnvironment(state, path.join(backend, 'src'))).toBe('org-backend');
|
||||||
|
expect(projectMarkerRoot(top)).toBe('');
|
||||||
|
expect(identity(top)).toMatch(/MARKER_IGNORED=.*folder\\ name/);
|
||||||
|
expect(run(REMOTE_SLUG, path.join(backend, 'src')).stdout.trim()).toBe('org-backend');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote-slug follows a marker under a non-ASCII parent in the C locale', () => {
|
||||||
|
const top = path.join(home, '文档', 'proj');
|
||||||
|
const backend = repo(path.join(top, 'backend'), 'git@github.com:org/backend.git');
|
||||||
|
mark(top);
|
||||||
|
expect(slug(backend, { LC_ALL: 'C' })).toBe('proj');
|
||||||
|
expect(run(REMOTE_SLUG, backend, [], { LC_ALL: 'C' }).stdout.trim()).toBe('proj');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo mode stays with each repository under an umbrella', () => {
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
for (let i = 0; i < 5; i++) git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', `c${i}`]);
|
||||||
|
mark(top);
|
||||||
|
expect(slug(backend)).toBe('MyProduct');
|
||||||
|
const r = run(path.join(ROOT, 'bin', 'gstack-repo-mode'), backend);
|
||||||
|
expect(r.stdout).toContain('REPO_MODE=solo');
|
||||||
|
expect(fs.existsSync(path.join(state, 'projects', 'MyProduct', 'repo-mode.json'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(state, 'projects', 'org-backend', 'repo-mode.json'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the ship-budget pointer and the review log stay with the repository when a marker appears mid-run', () => {
|
||||||
|
const { proj } = workspace();
|
||||||
|
git(['-C', proj, 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||||
|
const before = reviewPaths(proj, env());
|
||||||
|
expect(before.marker).toContain(path.join('projects', 'me-workspace') + path.sep);
|
||||||
|
mark(proj);
|
||||||
|
expect(slug(proj)).toBe('org-proj');
|
||||||
|
expect(reviewPaths(proj, env())).toEqual(before);
|
||||||
|
const branch = run(SLUG, proj, ['--get', 'BRANCH']).stdout.trim();
|
||||||
|
fs.mkdirSync(path.join(state, 'projects', 'me-workspace'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(state, 'projects', 'me-workspace', `${branch}-reviews.jsonl`), '{"skill":"review","status":"clean"}\n');
|
||||||
|
expect(run(path.join(ROOT, 'bin', 'gstack-review-read'), proj).stdout).toContain('"skill":"review"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sibling repos under an umbrella keep separate ship-budget pointers', () => {
|
||||||
|
const { top, backend, frontend } = umbrella();
|
||||||
|
mark(top);
|
||||||
|
expect(reviewPaths(backend, env()).marker).not.toBe(reviewPaths(frontend, env()).marker);
|
||||||
|
expect(reviewPaths(backend, env()).reviewLog).toContain(path.join('projects', 'org-backend') + path.sep);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovered checkpoints keep the repository they were saved in', () => {
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
const old = path.join(state, 'projects', 'org-backend', 'checkpoints');
|
||||||
|
fs.mkdirSync(old, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(old, '20261001-120000-x.md'), `---\nremote: github.com/org/backend\nproject_root: ${backend}\nhead: abc123\n---\n## Working on: x\n`);
|
||||||
|
mark(top);
|
||||||
|
const apply = run(SLUG, backend, ['--adopt-legacy', '--from', 'org-backend', '--under-root', '--apply']);
|
||||||
|
expect(apply.stdout).toContain('COPIED');
|
||||||
|
const text = fs.readFileSync(path.join(state, 'projects', 'MyProduct', 'checkpoints', '20261001-120000-x.md'), 'utf8');
|
||||||
|
expect(text).toContain(`worktree: ${backend}`);
|
||||||
|
expect(text).toContain(`project_root: ${top}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a committed marker in a repo with no remote gives its linked worktrees the same slug', () => {
|
||||||
|
const solo = repo(path.join(home, 'solo'));
|
||||||
|
mark(solo);
|
||||||
|
git(['-C', solo, 'add', '.gstack-project']);
|
||||||
|
git(['-C', solo, 'commit', '-q', '-m', 'marker']);
|
||||||
|
const wt = path.join(home, 'elsewhere', 'feature-wt');
|
||||||
|
git(['-C', solo, 'worktree', 'add', '-q', wt]);
|
||||||
|
expect(slug(solo)).toBe('solo');
|
||||||
|
expect(slug(wt)).toBe('solo');
|
||||||
|
expect(slugFromEnvironment(state, wt)).toBe('solo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marked folders whose paths differ only by / and _ keep separate slugs', () => {
|
||||||
|
const a = path.join(home, 'team_app');
|
||||||
|
const b = path.join(home, 'team', 'app');
|
||||||
|
for (const d of [a, b]) { fs.mkdirSync(d, { recursive: true }); mark(d); }
|
||||||
|
expect(slug(a)).toBe('team_app');
|
||||||
|
expect(slug(b)).toBe('app');
|
||||||
|
expect(slugFromEnvironment(state, b)).toBe('app');
|
||||||
|
expect(readVersionedSlugCache(state, b)).toBe('app');
|
||||||
|
expect(readVersionedSlugCache(state, a)).toBe('team_app');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--adopt-legacy --under-root refuses where no marker applies', () => {
|
||||||
|
const { proj } = workspace();
|
||||||
|
const r = run(SLUG, proj, ['--adopt-legacy', '--from', 'me-workspace', '--under-root']);
|
||||||
|
expect(r.status).toBe(2);
|
||||||
|
expect(r.stderr).toContain('no .gstack-project applies here');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lib/bin-context: marker twin', () => {
|
||||||
|
test('slugFromEnvironment and the hook reader follow the marker, and both forget it on removal', () => {
|
||||||
|
const { proj, app } = workspace();
|
||||||
|
expect(slugFromEnvironment(state, app)).toBe('me-workspace');
|
||||||
|
expect(readVersionedSlugCache(state, app)).toBe('me-workspace');
|
||||||
|
mark(proj);
|
||||||
|
expect(readVersionedSlugCache(state, app)).toBeUndefined();
|
||||||
|
expect(slugFromEnvironment(state, app)).toBe('org-proj');
|
||||||
|
expect(readVersionedSlugCache(state, app)).toBe('org-proj');
|
||||||
|
fs.rmSync(path.join(proj, '.gstack-project'));
|
||||||
|
expect(readVersionedSlugCache(state, app)).toBe('me-workspace');
|
||||||
|
expect(slugFromEnvironment(state, app)).toBe('me-workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bash and TS read each other\'s marker entries', () => {
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
mark(top);
|
||||||
|
expect(slugFromEnvironment(state, backend)).toBe('MyProduct');
|
||||||
|
fs.writeFileSync(path.join(state, 'slug-cache', 'marker', top.replace(/\//g, '_')), 'v2:from-ts');
|
||||||
|
expect(slug(backend)).toBe('from-ts');
|
||||||
|
fs.writeFileSync(path.join(state, 'slug-cache', 'marker', top.replace(/\//g, '_')), 'v2:from-bash');
|
||||||
|
expect(slugFromEnvironment(state, backend)).toBe('from-bash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repository scope ignores every marker, so a committed marker cannot move pregate policy', () => {
|
||||||
|
const u = umbrella();
|
||||||
|
mark(u.top);
|
||||||
|
expect(slugFromEnvironment(state, u.backend)).toBe('MyProduct');
|
||||||
|
expect(slugFromEnvironment(state, u.backend, { scope: 'repository' })).toBe('org-backend');
|
||||||
|
const { proj, app } = workspace();
|
||||||
|
const before = defaultUserConfigPath(proj);
|
||||||
|
expect(before).toBe(path.join(state, 'projects', 'me-workspace', 'pregate.json'));
|
||||||
|
mark(proj);
|
||||||
|
git(['-C', proj, 'add', '.gstack-project']);
|
||||||
|
git(['-C', proj, 'commit', '-q', '-m', 'marker']);
|
||||||
|
expect(slugFromEnvironment(state, app)).toBe('org-proj');
|
||||||
|
expect(slugFromEnvironment(state, app, { scope: 'repository' })).toBe('me-workspace');
|
||||||
|
expect(defaultUserConfigPath(proj)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a linked worktree follows the umbrella natively and stops when the marker goes', () => {
|
||||||
|
const { top, backend } = umbrella();
|
||||||
|
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||||
|
mark(top);
|
||||||
|
expect(slugFromEnvironment(state, backend)).toBe('MyProduct');
|
||||||
|
const wt = path.join(home, 'elsewhere', 'wt');
|
||||||
|
git(['-C', backend, 'worktree', 'add', '-q', wt]);
|
||||||
|
expect(projectMarkerRoot(wt)).toBe(top);
|
||||||
|
expect(slugFromEnvironment(state, wt)).toBe('MyProduct');
|
||||||
|
expect(readVersionedSlugCache(state, wt)).toBe('MyProduct');
|
||||||
|
fs.rmSync(path.join(top, '.gstack-project'));
|
||||||
|
expect(projectMarkerRoot(wt)).toBe('');
|
||||||
|
expect(slugFromEnvironment(state, wt)).toBe('org-backend');
|
||||||
|
expect(slug(wt)).toBe('org-backend');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subfolder and $HOME markers are ignored natively too', () => {
|
||||||
|
const { ws } = workspace();
|
||||||
|
const sub = path.join(ws, 'docs');
|
||||||
|
fs.mkdirSync(sub);
|
||||||
|
mark(sub);
|
||||||
|
mark(home);
|
||||||
|
expect(slugFromEnvironment(state, sub)).toBe('me-workspace');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user