mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
* fix(upgrade): refuse an install whose enclosing project tracks its files (#1022) A project that commits gstack's files as plain blobs puts the old ones back on every checkout of its own, so VERSION froze behind HEAD while gstack-upgrade reported the install current. bin/gstack-ff-update, the one move the auto-updater and /gstack-upgrade share, now refuses with exit 6 (nothing changed) when the enclosing repository tracks non-gitlink paths under the install, or when VERSION differs from HEAD (eol-normalized). gstack-upgrade runs the same checks through --preflight before any fetch; the auto-updater holds the update with a banner and a troubleshooting anchor. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Leave an unmerged VERSION to the rescue, keep the enclosing project's fsmonitor hook from running during the preflight, and word the version-reverted refusal for a hand edit as well as a project checkout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(troubleshooting): name the hand-edited VERSION case Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review pass fixes for the #1022 preflight The printed repair untracks the install with --literal-pathspecs and escapes glob characters in the ignore rule; the message is no longer capped short. version-reverted compares VERSION's first line with HEAD's, yields to the rescue when any file is unmerged, and runs inside the install lock on a full update. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the literal-repair decoy matches the install's glob Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: fragment for the #1022 preflight Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: finish an interrupted move instead of holding it as version-reverted The in-lock VERSION check accepts a VERSION already at the target's. Adds a session-update case for the version-reverted hold, and backdates mtimes in the new ff-update fixtures so the barrier's recapture is not racy. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: name the preflight's best-effort exit (gate-tail lint) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: say already-reverted installs need the one-time repair 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
7f815411f3
commit
b95de2254e
12 files changed
+344
-15
No files matched your search
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.5.0] - 2026-10-31
|
||||
|
||||
**Updates no longer stall in silence when the project around your gstack install has committed gstack's files (#1022).** If gstack lives inside another git project (for example `<project>/.claude/skills/gstack`) and that project committed gstack's files as ordinary files, a stash, reset or branch switch in the project put the old files back. VERSION stayed frozen at an old release while updates reported success. Once this release is on disk, every update path stops before it changes anything and prints the exact fix. An install the project has already reverted is still running the project's old copies of these scripts, so it needs the one-time repair in `docs/troubleshooting.md#auto-update-tracked-by-project` first.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-ff-update` checks before it moves.** It refuses with exit 6 and `tracked-by-project:` when the enclosing project tracks the install's files (a git submodule is fine), and with `version-reverted:` when VERSION's first line disagrees with HEAD's. The message spells out the repair. Untrack the install (`git rm --cached`, literal paths only), ignore it, commit on every branch, then `git stash push` in the install, which keeps every edit. `--preflight <install>` prints the same check on demand. An unmerged file still goes to the existing rescue, and a full update checks VERSION inside the install lock.
|
||||
- **`bin/gstack-upgrade`** runs that check before any fetch and refuses with `UPGRADE_REFUSED` (exit 3).
|
||||
- **The session auto-updater** holds the update on exit 6 and shows a banner that points to `--preflight` and the new troubleshooting section, `docs/troubleshooting.md#auto-update-tracked-by-project`.
|
||||
|
||||
## [1.92.4.0] - 2026-10-31
|
||||
|
||||
**A merge train now carries up to five READY PRs instead of three.** That means fewer full gates for the same number of PRs. A short train still leaves one hour after its oldest READY.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.4.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.5.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+47
-3
@@ -36,7 +36,14 @@
|
||||
# exit 3 autostash-unrecoverable: (edits kept where they are)
|
||||
# exit 4 not-fast-forward:
|
||||
# exit 5 index-unreadable:
|
||||
# exit 6 tracked-by-project: / version-reverted: (#1022, the preflight)
|
||||
# exit 75 ff-busy: (another update holds this install's lock)
|
||||
#
|
||||
# gstack-ff-update --preflight <dir> runs only the exit-6 checks (exit 0 or 6);
|
||||
# bin/gstack-upgrade calls it before any fetch. A project that tracks the
|
||||
# install's files as plain blobs puts old ones back on every checkout of its
|
||||
# own, so VERSION freezes behind HEAD while updates seem to succeed; a
|
||||
# submodule gitlink is the supported shape and passes.
|
||||
# A rescue also prints the inspect, export and delete commands on stderr.
|
||||
|
||||
set +e
|
||||
@@ -49,15 +56,51 @@ ff_flatten() {
|
||||
printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}"
|
||||
}
|
||||
ff_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" 300)"; exit "$1"; }
|
||||
refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" "${4:-300}")"; exit "$1"; }
|
||||
|
||||
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision>"
|
||||
DIR=$1 REV=$2
|
||||
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision> | --preflight <install dir>"
|
||||
PREFLIGHT=0
|
||||
if [ "$1" = --preflight ]; then PREFLIGHT=1 DIR=$2 REV=HEAD; else DIR=$1 REV=$2; fi
|
||||
cd -- "$DIR" 2>/dev/null || refuse 2 not-a-checkout "cannot enter $DIR"
|
||||
DIR=$(pwd -P)
|
||||
g() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; }
|
||||
[ "$(g rev-parse --is-inside-work-tree 2>/dev/null)" = true ] && [ -z "$(g rev-parse --show-prefix 2>/dev/null)" ] \
|
||||
|| refuse 2 not-a-checkout "$DIR is not the top of a git checkout"
|
||||
|
||||
# ── Preflight (#1022) ──
|
||||
# The repository enclosing the install's parent, probed with the caller's git
|
||||
# environment cleared; any git error there means not tracked.
|
||||
_PF=$(
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY
|
||||
cd -- "$DIR/.." 2>/dev/null || exit 0 # best-effort: no parent directory means no project tracks the install
|
||||
pg() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; }
|
||||
_top=$(pg rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
_rel="$(pg rev-parse --show-prefix 2>/dev/null)${DIR##*/}" || exit 0
|
||||
_n=0
|
||||
while IFS= read -r _l; do
|
||||
case "$_l" in (''|'160000 '*) ;; (*) _n=$((_n + 1)) ;; esac
|
||||
done <<EOF_PF
|
||||
$(pg -C "$_top" --literal-pathspecs ls-files -s -- "$_rel" 2>/dev/null)
|
||||
EOF_PF
|
||||
[ "$_n" -gt 0 ] && printf '%s\n%s\n%s\n' "$_top" "$_rel" "$_n"
|
||||
)
|
||||
if [ -n "$_PF" ]; then
|
||||
_top=$(printf '%s\n' "$_PF" | sed -n 1p) _rel=$(printf '%s\n' "$_PF" | sed -n 2p) _n=$(printf '%s\n' "$_PF" | sed -n 3p)
|
||||
_ign=$(printf '%s' "$_rel" | sed 's/[][*?\\]/\\&/g')
|
||||
refuse 6 tracked-by-project "$_top tracks $_n files of the gstack install at $_rel, so every checkout there puts old gstack files back (VERSION frozen, #1022); nothing was changed; fix: git -C $(ff_quote "$_top") --literal-pathspecs rm -r -q --cached -- $(ff_quote "$_rel"), add /$_ign/ to $(ff_quote "$_top/.gitignore") and commit both on every branch you check out (or vendor gstack as a git submodule), then git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit) and re-run" 4000
|
||||
fi
|
||||
# A VERSION whose first line differs from HEAD's. Any unmerged file is left to
|
||||
# the rescue below; the full update runs this inside the lock, where a VERSION
|
||||
# already at the target's (an interrupted move) is finished, not held.
|
||||
version_check() {
|
||||
g cat-file -e HEAD:VERSION 2>/dev/null && [ -z "$(g ls-files -u 2>/dev/null)" ] || return 0
|
||||
_have=$(head -1 VERSION 2>/dev/null | tr -d '\r') _want=$(g show HEAD:VERSION 2>/dev/null | head -1 | tr -d '\r')
|
||||
[ "$_have" = "$_want" ] && return 0
|
||||
[ -n "$1" ] && [ "$_have" = "$(g show "$1:VERSION" 2>/dev/null | head -1 | tr -d '\r')" ] && return 0
|
||||
refuse 6 version-reverted "VERSION says ${_have:-nothing} but the checkout is at $_want: files changed outside git (an enclosing project's checkout puts old ones back, #1022, or a hand edit); nothing was changed; fix: git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit), then re-run" 4000
|
||||
}
|
||||
[ "$PREFLIGHT" -eq 1 ] && { version_check; exit 0; }
|
||||
|
||||
TARGET=$(g rev-parse -q --verify "$REV^{commit}" 2>/dev/null) || refuse 2 target-unresolvable "$REV does not name a commit in $DIR"
|
||||
COMMON=$(g rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
|
||||
|| COMMON=$(cd -- "$(g rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) \
|
||||
@@ -128,6 +171,7 @@ case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac
|
||||
HB_PID=$!
|
||||
|
||||
# ── Inside the lock ──
|
||||
version_check "$TARGET"
|
||||
HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit"
|
||||
TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed"
|
||||
g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed"
|
||||
|
||||
@@ -246,6 +246,8 @@ case "$_P_PULL" in
|
||||
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now ($_WHERE): $_FF_FIX (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;;
|
||||
unmerged-files*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); an earlier merge, rebase or stash pop left conflicts in the install, and an update would bury them. Fix now ($_WHERE): $_FF_FIX (saves every edit, the unmerged files included, in a rescue ref before it updates). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-unmerged-files" ;;
|
||||
tracked-by-project*|version-reverted*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. gstack retries at the next check. The full fix: bash $_Q/bin/gstack-ff-update --preflight $_Q. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-tracked-by-project" ;;
|
||||
autostash-unrecoverable*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); your local edits are where they were and nothing was installed. Look first: git -C $_Q status --short. Fix now ($_WHERE): $_FF_FIX (once the cause above is gone). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-autostash-unrecoverable" ;;
|
||||
*)
|
||||
@@ -500,7 +502,7 @@ CALLER_PID=$(gstack_harness_pid "$PPID")
|
||||
log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1 kept_stash=${4##*/} kept_ref=$4 $5"
|
||||
rescue_note "$4" "${5#unmerged=}" ;;
|
||||
0:*) ;;
|
||||
3:*) HELD=$FF_OUT PULL_EXIT=0 ;;
|
||||
3:*|6:*) HELD=$FF_OUT PULL_EXIT=0 ;;
|
||||
75:*) log_entry "SKIP ff_busy $(ff_flatten "$FF_OUT" 200)"; rm -f "$PULL_ERR_FILE" 2>/dev/null; exit 0 ;;
|
||||
*) printf '%s\n' "${FF_OUT:-bin/gstack-ff-update exited $PULL_EXIT}" > "${PULL_ERR_FILE:-/dev/null}" ;;
|
||||
esac
|
||||
|
||||
+6
-2
@@ -21,7 +21,9 @@
|
||||
# is not a gstack checkout, HEAD is detached or not on the remote default
|
||||
# branch (a pin), any tracked byte is dirty, a local commit diverged, the
|
||||
# incoming release's Bun floor holds, or another updater holds the setup
|
||||
# lock; never stashes, never resets. An unreachable remote is
|
||||
# lock; never stashes, never resets. Before any fetch it also refuses when
|
||||
# bin/gstack-ff-update --preflight does: an enclosing project tracks the
|
||||
# install's files, or VERSION was put back behind HEAD (#1022). An unreachable remote is
|
||||
# UPGRADE_FETCH_FAILED (exit 3). While another live session holds a workflow
|
||||
# lease on this installation (bin/gstack-lease live) it refuses with
|
||||
# UPGRADE_BUSY (exit 3); GSTACK_UPGRADE_FORCE=1 overrides. Prints
|
||||
@@ -50,7 +52,7 @@ case "${1:-}" in
|
||||
--check) CHECK=1 ;;
|
||||
--ff-only-no-reinstall) REINSTALL=0 ;;
|
||||
'') ;;
|
||||
-h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help) sed -n '2,41p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -71,6 +73,8 @@ bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_UPGRAD
|
||||
TOPLEVEL=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null)
|
||||
[ -f "$ROOT/VERSION" ] && [ -f "$ROOT/setup" ] && [ -n "$TOPLEVEL" ] && gstack_path_same "$TOPLEVEL" "$ROOT" \
|
||||
|| refuse "$ROOT is not a gstack checkout" "run gstack-upgrade from a git checkout of https://github.com/garrytan/gstack"
|
||||
PF=$(bash "$ROOT/bin/gstack-ff-update" --preflight "$ROOT" 2>/dev/null)
|
||||
[ $? -eq 6 ] && { echo "gstack-upgrade: $PF (UPGRADE_REFUSED)" >&2; exit 3; }
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
HOST=$(git -C "$ROOT" remote get-url origin 2>/dev/null); HOST="${HOST#*://}"; HOST="${HOST#*@}"; HOST="${HOST%%[/:]*}"
|
||||
R="user ran gstack-upgrade"
|
||||
|
||||
@@ -1109,6 +1109,28 @@ conflict yourself instead, finish or abort your merge (`git -C '<dir>' merge
|
||||
--abort`, `git -C '<dir>' checkout -- <file>`); gstack retries at the next
|
||||
check.
|
||||
|
||||
<a id="auto-update-tracked-by-project"></a>
|
||||
### `gstack auto-update: update held (tracked-by-project: ...)` / `(version-reverted: ...)` / `gstack-upgrade: tracked-by-project: ... (UPGRADE_REFUSED)` / `gstack-upgrade: version-reverted: ... (UPGRADE_REFUSED)`
|
||||
|
||||
**Meaning.** Your gstack install sits inside another git project, and that
|
||||
project has committed gstack's files as ordinary files. Every time you switch
|
||||
branches or check out files in that project, git puts its old copies of
|
||||
gstack's files back, so VERSION and other files fall behind the install's own
|
||||
HEAD while updates seem to succeed (#1022). `version-reverted` is the same
|
||||
symptom seen from the install: its VERSION disagrees with HEAD. Neither
|
||||
`gstack-upgrade`, `/gstack-upgrade` nor the auto-updater moves the install
|
||||
until it is fixed. A project that holds gstack as a git submodule is fine.
|
||||
`version-reverted` with no enclosing project means VERSION was edited by
|
||||
hand; only the `stash push` step below applies.
|
||||
|
||||
**Fix.** Print the exact commands with
|
||||
`bash <install>/bin/gstack-ff-update --preflight <install>`. They do three
|
||||
things: untrack the install in the project (`git rm -r --cached`), add it to
|
||||
the project's `.gitignore`, and commit both on every branch you check out
|
||||
(or vendor gstack as a submodule instead). Then
|
||||
`git -C <install> stash push` puts the install's own files back and keeps
|
||||
any edit you made in the stash, and the next update proceeds.
|
||||
|
||||
<a id="auto-update-autostash-unrecoverable"></a>
|
||||
### `gstack auto-update: update held (autostash-unrecoverable: <reason>); your local edits are where they were and nothing was installed. Look first: git -C '<dir>' status --short. Fix now (...)` / `autostash-unrecoverable: <reason>`
|
||||
|
||||
@@ -1551,6 +1573,8 @@ directory is not a gstack checkout, HEAD is detached or on another branch (a
|
||||
pin), a tracked file is modified, a local commit is not on the remote branch,
|
||||
the incoming release's Bun floor is above the Bun on PATH, or another updater
|
||||
holds `<state root>/.setup-lock`. It never stashes, resets or discards.
|
||||
A `tracked-by-project` or `version-reverted` cause has its own section:
|
||||
[an enclosing project tracks the install](#auto-update-tracked-by-project).
|
||||
|
||||
**Fix.** Resolve the named cause with the printed command (commit or move the
|
||||
listed changes, check out the default branch, move local commits to another
|
||||
|
||||
+1
-1
@@ -589,7 +589,7 @@ export const RESULT_CODES = {
|
||||
// Field wave lane A (items 1-4): gstack-upgrade, the Capy skill bootstrapper, install admission.
|
||||
UPGRADE_REFUSED: {
|
||||
anchor: 'upgrade-refused',
|
||||
summary: 'bin/gstack-upgrade changed nothing: the checkout is dirty, diverged, pinned (detached or off the remote default branch), not a gstack checkout, or the incoming release holds',
|
||||
summary: 'bin/gstack-upgrade changed nothing: the checkout is dirty, diverged, pinned (detached or off the remote default branch), not a gstack checkout, tracked by an enclosing project (#1022), or the incoming release holds',
|
||||
fix: 'resolve the named cause (commit or move the listed changes, check out the default branch), then re-run bin/gstack-upgrade; it never stashes or resets',
|
||||
},
|
||||
UPGRADE_BUSY: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.4",
|
||||
"version": "1.92.5",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -74,6 +74,8 @@ function ff(fx: Fx, rev = 'origin/main', extra: Record<string, string> = {}, bin
|
||||
}
|
||||
const read = (fx: Fx, f: string) => fs.readFileSync(path.join(fx.install, f), 'utf8');
|
||||
const write = (fx: Fx, f: string, body: string) => fs.writeFileSync(path.join(fx.install, f), body);
|
||||
/** Moves mtimes 10 s back so no entry is racily clean against the index (a same-second edit can trip the barrier's recapture). */
|
||||
const backdate = (fx: Fx, ...files: string[]) => { const s = Date.now() / 1000 - 10; for (const f of files) fs.utimesSync(path.join(fx.install, f), s, s); };
|
||||
const rescueRef = (out: string) => out.match(/^rescued [0-9a-f]+ [0-9a-f]+ (refs\/gstack\/rescue\/[0-9a-f]{40}) unmerged=([01])$/m);
|
||||
function makeUnmerged(fx: Fx, file: string) {
|
||||
const blob = (body: string) => execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: fx.install, input: body, encoding: 'utf8', timeout: 30_000 }).trim();
|
||||
@@ -264,6 +266,124 @@ describe.skipIf(process.platform === 'win32')('gstack-ff-update: moving the tree
|
||||
expect(git(fx.install, 'log', '-1', '--format=%an <%ae>|%cn <%ce>', ref)).toBe('gstack rescue <gstack@localhost>|gstack rescue <gstack@localhost>');
|
||||
});
|
||||
|
||||
// Value: protects=the one move every upgrade path shares refuses (exit 6, nothing changed) when an enclosing project tracks the install or
|
||||
// VERSION was put back behind HEAD, even with nothing to move (#1022); fails_when=the preflight is skipped, runs after "already", or counts a gitlink;
|
||||
// why_new=no case had an enclosing project; seam=none
|
||||
test('preflight: a tracking project or a reverted VERSION refuses with exit 6 and changes nothing; a gitlink and --preflight on a clean install pass', () => {
|
||||
const fx = makeFixture();
|
||||
const old = git(fx.install, 'rev-parse', 'HEAD');
|
||||
expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0);
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
git(fx.base, 'update-index', '--add', '--cacheinfo', `160000,${old},install`);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0);
|
||||
git(fx.base, 'update-index', '--force-remove', 'install');
|
||||
git(fx.base, 'update-index', '--add', '--', 'install/VERSION', 'install/notes.txt');
|
||||
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
|
||||
const tracked = ff(fx);
|
||||
expect(tracked.status, tracked.stdout + tracked.stderr).toBe(6);
|
||||
expect(tracked.stdout).toStartWith(`tracked-by-project: ${fx.base} tracks 2 files of the gstack install at install,`);
|
||||
expect(tracked.stdout.trim().split('\n')).toHaveLength(1);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old);
|
||||
fs.rmSync(path.join(fx.base, '.git'), { recursive: true, force: true });
|
||||
expect(ff(fx).status).toBe(0);
|
||||
write(fx, 'VERSION', '1.0.0\n');
|
||||
const reverted = ff(fx);
|
||||
expect(reverted.status).toBe(6);
|
||||
expect(reverted.stdout).toStartWith('version-reverted: VERSION says 1.0.0 but the checkout is at 1.1.0');
|
||||
expect(read(fx, 'VERSION')).toBe('1.0.0\n');
|
||||
write(fx, 'VERSION', '9.9.9\n');
|
||||
expect(ff(fx).stdout).toStartWith('version-reverted: VERSION says 9.9.9 but the checkout is at 1.1.0: files changed outside git');
|
||||
});
|
||||
|
||||
// Value: protects=an unmerged VERSION still takes the existing rescue, which the unmerged-files banner's command relies on;
|
||||
// fails_when=the version-reverted preflight fires on conflict markers; why_new=the preflight is new; seam=none
|
||||
test('preflight: an unmerged VERSION is rescued, not refused as version-reverted', () => {
|
||||
const fx = makeFixture();
|
||||
makeUnmerged(fx, 'VERSION');
|
||||
const target = release(fx, { 'notes.txt': 'x\n' });
|
||||
const r = ff(fx, target);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/^rescued .* unmerged=1/);
|
||||
});
|
||||
|
||||
// Value: protects=the preflight runs no code from the enclosing project (its core.fsmonitor hook); fails_when=an enclosing-repo git call
|
||||
// drops the fsmonitor override; why_new=the enclosing-project probe is new; seam=none
|
||||
test('preflight: an enclosing project\'s core.fsmonitor hook never runs', () => {
|
||||
const fx = makeFixture();
|
||||
const sentinel = path.join(fx.base, 'fsmonitor-ran');
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
fs.writeFileSync(path.join(fx.base, 'hook.sh'), `#!/bin/sh\ntouch '${sentinel}'\n`, { mode: 0o755 });
|
||||
git(fx.base, 'config', 'core.fsmonitor', path.join(fx.base, 'hook.sh'));
|
||||
const r = spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.existsSync(sentinel)).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=the printed repair untracks only the install, even when its name holds glob characters, and prints whole at any path length;
|
||||
// fails_when=the rm drops --literal-pathspecs, the ignore rule is unescaped, or the message is capped short; why_new=review pass 1; seam=none
|
||||
test('preflight: the tracked-by-project repair is literal and printed whole', () => {
|
||||
const fx = makeFixture(`gstack*${'x'.repeat(150)}`);
|
||||
const name = path.basename(fx.install);
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
const decoy = `gstack-data${'x'.repeat(150)}`;
|
||||
fs.writeFileSync(path.join(fx.base, decoy), 'keep\n');
|
||||
git(fx.base, 'add', '--', decoy);
|
||||
git(fx.base, 'update-index', '--add', '--', `${name}/VERSION`);
|
||||
const r = spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(6);
|
||||
expect(r.stdout.trimEnd()).toEndWith('and re-run');
|
||||
expect(r.stdout).toContain(`add /gstack\\*${'x'.repeat(150)}/ to`);
|
||||
const rm = r.stdout.match(/fix: (git -C .*? rm -r -q --cached -- '[^']*')/)?.[1];
|
||||
expect(rm).toContain('--literal-pathspecs');
|
||||
expect(spawnSync('bash', ['-c', rm!], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
|
||||
expect(git(fx.base, 'ls-files').split('\n')).toEqual([decoy]);
|
||||
});
|
||||
|
||||
// Value: protects=a dirty VERSION beside an unmerged file still takes the rescue (stash cannot run there); a VERSION that differs only in mode is no
|
||||
// revert; a held lock wins over the VERSION check; fails_when=the check looks only at VERSION's own stage, compares bytes, or runs before the lock;
|
||||
// why_new=review pass 1; seam=none
|
||||
test('preflight: version-reverted yields to the rescue, to a same-text VERSION, and to a held lock', () => {
|
||||
const fx = makeFixture();
|
||||
makeUnmerged(fx, 'notes.txt');
|
||||
write(fx, 'VERSION', '0.9.0\n');
|
||||
backdate(fx, 'VERSION', 'notes.txt');
|
||||
const mixed = ff(fx, release(fx, { 'other.txt': 'x\n' }));
|
||||
expect(mixed.status, mixed.stdout + mixed.stderr).toBe(0);
|
||||
expect(mixed.stdout).toMatch(/^rescued .* unmerged=1/);
|
||||
|
||||
const fy = makeFixture();
|
||||
fs.chmodSync(path.join(fy.install, 'VERSION'), 0o755);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fy.install], { encoding: 'utf8', env: isolatedEnv(fy.base), timeout: 60_000 }).status).toBe(0);
|
||||
|
||||
const fz = makeFixture();
|
||||
release(fz, { 'other.txt': 'x\n' });
|
||||
write(fz, 'VERSION', '0.9.0\n');
|
||||
const holder = spawn('sleep', ['30']);
|
||||
try {
|
||||
const lock = path.join(fz.install, '.git', 'gstack-ff.lock');
|
||||
fs.mkdirSync(lock);
|
||||
fs.writeFileSync(path.join(lock, 'pid'), `${holder.pid}\n`);
|
||||
expect(ff(fz, 'origin/main', { GSTACK_FF_LOCK_WAIT: '0' }).status).toBe(75);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fz.install], { encoding: 'utf8', env: isolatedEnv(fz.base), timeout: 60_000 }).status).toBe(6);
|
||||
} finally {
|
||||
holder.kill();
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a move interrupted after the tree was written but before HEAD moved still finishes on the next run, not held as
|
||||
// version-reverted; fails_when=the in-lock check ignores the target's VERSION; why_new=review pass 2; seam=none
|
||||
test('an interrupted move (tree at the target, HEAD old) finishes instead of holding', () => {
|
||||
const fx = makeFixture();
|
||||
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
|
||||
execFileSync('bash', ['-c', `git archive ${target} | tar -x`], { cwd: fx.install, timeout: 30_000 });
|
||||
expect(read(fx, 'VERSION')).toBe('1.1.0\n');
|
||||
backdate(fx, 'VERSION', 'notes.txt');
|
||||
const r = ff(fx, target);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0);
|
||||
});
|
||||
|
||||
test('refusals: not a fast-forward, an unresolvable target, and a missing checkout', () => {
|
||||
const fx = makeFixture();
|
||||
write(fx, 'VERSION', 'local\n');
|
||||
|
||||
@@ -19,7 +19,7 @@ const REPO = path.resolve(import.meta.dir, '..');
|
||||
const DEPS = [
|
||||
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
|
||||
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
|
||||
'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh',
|
||||
'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-ff-update',
|
||||
];
|
||||
const hosts: CapyHost[] = [];
|
||||
const children: ChildProcess[] = [];
|
||||
@@ -40,7 +40,7 @@ const write = (file: string, text: string, mode = 0o644) => {
|
||||
fs.writeFileSync(file, text, { mode });
|
||||
};
|
||||
|
||||
function fixture() {
|
||||
function fixture(opts: { parent?: 'files' | 'gitlink' } = {}) {
|
||||
const host = capyHost({ systemBun: '1.4.2' });
|
||||
hosts.push(host);
|
||||
const git = (args: string[], cwd: string) => {
|
||||
@@ -68,8 +68,18 @@ function fixture() {
|
||||
const a = git(['rev-parse', 'HEAD'], seed);
|
||||
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
|
||||
git(['push', '-q', bare, 'main'], seed);
|
||||
const root = path.join(host.base, 'gstack');
|
||||
const project = path.join(host.base, 'project');
|
||||
const root = opts.parent ? path.join(project, '.claude/skills/gstack') : path.join(host.base, 'gstack');
|
||||
fs.mkdirSync(path.dirname(root), { recursive: true });
|
||||
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
||||
if (opts.parent) {
|
||||
// #1022: a project that committed the install's files as plain blobs (or,
|
||||
// the supported shape, as a submodule gitlink) beside the nested .git.
|
||||
git(['init', '-q', '-b', 'main'], project);
|
||||
if (opts.parent === 'files') git(['update-index', '--add', '--', ...git(['ls-files'], root).split('\n').map(f => `.claude/skills/gstack/${f}`)], project);
|
||||
else git(['update-index', '--add', '--cacheinfo', `160000,${a},.claude/skills/gstack`], project);
|
||||
git(['commit', '-qm', 'vendor gstack'], project);
|
||||
}
|
||||
write(path.join(seed, 'VERSION'), '1.0.1\n');
|
||||
git(['commit', '-qam', 'B'], seed);
|
||||
const b = git(['rev-parse', 'HEAD'], seed);
|
||||
@@ -78,7 +88,7 @@ function fixture() {
|
||||
const run = (args: string[] = [], extra: Record<string, string> = {}) =>
|
||||
spawnSync('bash', [path.join(root, 'bin/gstack-upgrade'), ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 60_000 });
|
||||
return {
|
||||
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
|
||||
host, root, bare, a, b, run, project, git: (args: string[]) => git(args, root), parentGit: (args: string[]) => git(args, project),
|
||||
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
@@ -396,6 +406,70 @@ describe('gstack-upgrade', () => {
|
||||
expect(f.calls()).toEqual(['setup --refresh-registered', `doctor --check --require runtime,pins --project ${fs.realpathSync(project)}`]);
|
||||
});
|
||||
|
||||
// Value: protects=an install whose enclosing project tracks its files is refused before it moves, so a project checkout cannot revert VERSION behind an upgraded HEAD (#1022);
|
||||
// fails_when=the enclosing-project check is dropped or matches a submodule gitlink; why_new=no case had an enclosing repository; seam=none
|
||||
test('a project that tracks the install\'s files refuses --check and default with UPGRADE_REFUSED naming the untrack fix (#1022)', () => {
|
||||
const f = fixture({ parent: 'files' });
|
||||
const reflog = f.git(['reflog']);
|
||||
for (const args of [['--check'], []]) {
|
||||
const r = f.run(args);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(3);
|
||||
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
|
||||
expect(r.stderr).toContain(`${f.project} tracks`);
|
||||
expect(r.stderr).toContain(`git -C '${f.project}' --literal-pathspecs rm -r -q --cached -- '.claude/skills/gstack'`);
|
||||
expect(r.stderr.trim().split('\n').filter(l => l.startsWith('gstack-upgrade: '))).toHaveLength(1);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
||||
expect(f.git(['reflog'])).toBe(reflog);
|
||||
}
|
||||
expect(f.calls()).toEqual([]);
|
||||
f.parentGit(['rm', '-r', '-q', '--cached', '--', '.claude/skills/gstack']);
|
||||
f.git(['stash', 'push', '-m', 'files reverted by the project']);
|
||||
const fixed = f.run();
|
||||
expect(fixed.status, fixed.stdout + fixed.stderr).toBe(0);
|
||||
expect(fixed.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1');
|
||||
});
|
||||
|
||||
// Value: protects=the supported vendoring shape (a submodule gitlink) still upgrades; fails_when=the #1022 check counts a gitlink as tracked files;
|
||||
// why_new=negative control for the case above; seam=none
|
||||
test('a project that holds the install as a submodule gitlink upgrades normally', () => {
|
||||
const f = fixture({ parent: 'gitlink' });
|
||||
const r = f.run();
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
|
||||
// Value: protects=a working-tree VERSION that disagrees with HEAD is never reported current (the frozen VERSION of #1022);
|
||||
// fails_when=the current path trusts the VERSION file without comparing it to HEAD; why_new=every current case had a clean tree; seam=none
|
||||
test('a VERSION reverted behind HEAD is not current: --check and default refuse naming both versions (#1022)', () => {
|
||||
const f = fixture();
|
||||
expect(f.run(['--ff-only-no-reinstall']).status).toBe(0);
|
||||
fs.writeFileSync(path.join(f.root, 'VERSION'), '1.0.0\n');
|
||||
for (const args of [['--check'], []]) {
|
||||
const r = f.run(args);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(3);
|
||||
expect(r.stdout).not.toContain('gstack-upgrade: current');
|
||||
expect(r.stderr).toContain('VERSION says 1.0.0 but the checkout is at 1.0.1');
|
||||
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
|
||||
}
|
||||
expect(f.calls()).toEqual([]);
|
||||
f.git(['stash', 'push', '-q', '-m', 'files reverted by the project']);
|
||||
const fixed = f.run(['--check']);
|
||||
expect(fixed.status, fixed.stdout + fixed.stderr).toBe(0);
|
||||
expect(fixed.stdout).toContain('gstack-upgrade: current 1.0.1');
|
||||
});
|
||||
|
||||
// Value: protects=a Windows checkout with core.autocrlf (VERSION written CRLF) is not mistaken for a reverted VERSION;
|
||||
// fails_when=the VERSION check compares raw bytes instead of git's eol-normalized diff; why_new=the check is new; seam=none
|
||||
test('a CRLF VERSION under core.autocrlf=true is current, not reverted', () => {
|
||||
const f = fixture();
|
||||
expect(f.run(['--ff-only-no-reinstall']).status).toBe(0);
|
||||
f.git(['config', 'core.autocrlf', 'true']);
|
||||
fs.writeFileSync(path.join(f.root, 'VERSION'), '1.0.1\r\n');
|
||||
const r = f.run(['--check']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('gstack-upgrade: current 1.0.1');
|
||||
});
|
||||
|
||||
test('every bin a trailer or fix line names exists in the repo', () => {
|
||||
const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md',
|
||||
'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'];
|
||||
|
||||
@@ -166,10 +166,62 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update: held-updat
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
|
||||
});
|
||||
|
||||
// Value: protects=the auto-updater holds, instead of rescuing and re-updating forever, an install whose enclosing project tracks its files (#1022);
|
||||
// fails_when=gstack-ff-update's exit 6 is not a hold, or the banner's command does not print the full fix; why_new=no case had an enclosing project; seam=none
|
||||
test('tracked-by-project: the update holds with HEAD unchanged; the banner names the preflight, which prints the full fix', async () => {
|
||||
const fx = makeFixture();
|
||||
const old = git(fx.install, 'rev-parse', 'HEAD');
|
||||
release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
git(fx.base, 'update-index', '--add', '--', ...git(fx.install, 'ls-files').split('\n').map((f) => `it's an install/${f}`));
|
||||
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
|
||||
await waitFor(fx, /Z HELD tracked-by-project: /);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old);
|
||||
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
|
||||
const lines = banner(fx).trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toStartWith(`gstack auto-update: update held (tracked-by-project: ${fx.base} tracks`);
|
||||
expect(lines[0]).toContain('troubleshooting.md#auto-update-tracked-by-project');
|
||||
const cmd = lines[0].match(/The full fix: (bash .*?)\. https:/)![1];
|
||||
const r = run(fx, cmd);
|
||||
expect(r.status).toBe(6);
|
||||
expect(r.stdout).toContain(`git -C ${quoted(fx.base)} --literal-pathspecs rm -r -q --cached -- ${quoted("it's an install")}`);
|
||||
});
|
||||
|
||||
// Value: protects=a project holding the install as a submodule gitlink keeps auto-updating; fails_when=the #1022 check counts a gitlink;
|
||||
// why_new=negative control for the case above; seam=none
|
||||
test('a gitlink in the enclosing project is not a hold: the update lands', async () => {
|
||||
const fx = makeFixture();
|
||||
const target = release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
git(fx.base, 'update-index', '--add', '--cacheinfo', `160000,${git(fx.install, 'rev-parse', 'HEAD')},it's an install`);
|
||||
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
|
||||
await waitFor(fx, /Z UPDATED /);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
|
||||
});
|
||||
|
||||
// Value: protects=a VERSION behind HEAD with no enclosing project holds (not rescued) and its preflight prints the stash fix;
|
||||
// fails_when=session-update's exit-6 arm drops version-reverted; why_new=review pass 2; seam=none
|
||||
test('version-reverted: the update holds with HEAD unchanged; the preflight prints the stash fix', async () => {
|
||||
const fx = makeFixture();
|
||||
const old = git(fx.install, 'rev-parse', 'HEAD');
|
||||
release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
|
||||
fs.writeFileSync(path.join(fx.install, 'VERSION'), '0.0.1\n');
|
||||
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
|
||||
await waitFor(fx, /Z HELD version-reverted: /);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old);
|
||||
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
|
||||
const line = banner(fx).trim();
|
||||
expect(line).toStartWith('gstack auto-update: update held (version-reverted: VERSION says 0.0.1 but the checkout is at 1.0.0');
|
||||
const r = run(fx, line.match(/The full fix: (bash .*?)\. https:/)![1]);
|
||||
expect(r.status).toBe(6);
|
||||
expect(r.stdout).toContain(`git -C ${quoted(fx.install)} stash push`);
|
||||
});
|
||||
|
||||
test('every anchor the lines link to exists', () => {
|
||||
const src = fs.readFileSync(SCRIPT, 'utf8');
|
||||
const anchors = [...src.matchAll(/troubleshooting\.md#([a-z0-9-]+)/g)].map((m) => m[1]!);
|
||||
for (const a of ['auto-update-unmerged-files', 'auto-update-autostash-unrecoverable', 'auto-update-rescued-edits']) expect(anchors).toContain(a);
|
||||
for (const a of ['auto-update-unmerged-files', 'auto-update-autostash-unrecoverable', 'auto-update-rescued-edits', 'auto-update-tracked-by-project']) expect(anchors).toContain(a);
|
||||
for (const a of anchors) expect(DOC, a).toContain(`<a id="${a}"></a>`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user