feat(update-check): an equal VERSION with a moved main is an upgrade, via the compare API (W12)

When VERSION matches but ls-remote's main SHA differs from this checkout's
HEAD, bin/gstack-update-check asks the GitHub compare API (status, ahead_by)
through the existing receipted curl and prints
UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>; a diverged or unreadable compare is
CHECK_FAILED (unknown), and behind/identical is up to date. Never fetches.

The cache gains an IDENTITY line (head, compared remote SHA, upstream): an
outcome replays only for the same HEAD and upstream, a same-remote refresh
reuses it without another compare, and a git checkout ignores a legacy
one-line cache (non-git installs keep the one-line format). The snooze is
keyed by the SHA. gstack-upgrade's question, snooze note and What's New step
handle the four-field line; gstack-skill-end reports it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-16 23:25:36 +00:00
1 parent a3b86b10b2
commit 421e66d2a4
6 files changed
+350 -29

No files matched your search

+2 -2
View File
@@ -41,9 +41,9 @@ _TEL=$("$_BIN/gstack-config" get telemetry 2>/dev/null || echo off)
_SESSION_KIND=$("$_BIN/gstack-session-kind" 2>/dev/null || echo "interactive")
if [ "$_SESSION_KIND" = "unattended" ]; then
_TEL="off"
_CACHED_UPD=$(head -1 "$_GH/last-update-check" 2>/dev/null | tr -cd 'A-Za-z0-9._ ' || true)
_CACHED_UPD=$(head -1 "$_GH/last-update-check" 2>/dev/null | tr -cd 'A-Za-z0-9._+ ' || true)
case "$_CACHED_UPD" in
UPGRADE_AVAILABLE*) echo "UPGRADE_AVAILABLE: ${_CACHED_UPD##* }; run /gstack-upgrade"
UPGRADE_AVAILABLE*) echo "UPGRADE_AVAILABLE: $(printf '%s\n' "$_CACHED_UPD" | awk '{ print $3 (NF >= 4 ? " (main at " $4 ")" : "") }'); run /gstack-upgrade"
echo "UPGRADE_COMMAND: $(cd "$_BIN/.." 2>/dev/null && pwd -P)/bin/gstack-upgrade" ;;
esac
echo "ARTIFACTS_SYNC: skipped (unattended session)"
+127 -22
View File
@@ -4,18 +4,34 @@
# Output (one line, or nothing):
# JUST_UPGRADED <old> <new> — marker found from recent upgrade
# UPGRADE_AVAILABLE <old> <new> — remote VERSION differs from local
# UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>
# — VERSION is equal but main moved <n>
# commits past this checkout's HEAD (the
# GitHub compare API says so); the snooze
# is keyed by <sha>
# CHECK_FAILED <reason> — the check crashed, or (--force only) the
# remote version could not be read
# (nothing) — up to date, snoozed, disabled, or check skipped
#
# A remote version that cannot be read is cached as CHECK_FAILED (short TTL),
# never as UP_TO_DATE (#2786). Skill preambles replay it silently; only
# --force (/gstack-upgrade) prints it with the failed URL.
# --force (/gstack-upgrade) prints it with the failed URL. A diverged or
# unreadable compare (equal VERSION, different main) is CHECK_FAILED too.
#
# Cache: line 1 is the outcome; line 2 is
# IDENTITY head=<local HEAD|none> remote=<compared main SHA|none> upstream=<url>
# A cached outcome is replayed only while this checkout's HEAD and upstream
# match line 2. A git checkout ignores a cache without line 2 (written before
# the equal-version compare existed); a non-git install has no HEAD, so its
# cache keeps the version-only meaning. Never fetches: ls-remote and one
# compare request are the only network reads.
#
# Env overrides (for testing):
# GSTACK_DIR — override auto-detected gstack root
# GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback)
# GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution
# GSTACK_REMOTE_RAW_BASE — override the SHA-pinned raw VERSION base URL
# GSTACK_COMPARE_API — override the GitHub compare API base URL
# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
set -euo pipefail
@@ -60,6 +76,8 @@ SNOOZE_FILE="$STATE_DIR/update-snoozed"
VERSION_FILE="$GSTACK_DIR/VERSION"
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"
REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}"
REMOTE_RAW_BASE="${GSTACK_REMOTE_RAW_BASE:-https://raw.githubusercontent.com/garrytan/gstack}"
COMPARE_API="${GSTACK_COMPARE_API:-https://api.github.com/repos/garrytan/gstack/compare}"
# ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ──
FORCE=0
@@ -74,6 +92,43 @@ _display_url() {
printf '%s' "$1" | sed -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)[^/@]*@#\1#'
}
# ─── Cache identity: this checkout's HEAD and the upstream it compares with ──
LOCAL_HEAD="none"
if [ -e "$GSTACK_DIR/.git" ]; then
_H="$(git -C "$GSTACK_DIR" rev-parse --verify --quiet HEAD 2>/dev/null || true)"
if echo "$_H" | grep -qE '^[0-9a-f]{40}$'; then LOCAL_HEAD="$_H"; fi
fi
if [ -n "${GSTACK_REMOTE_URL:-}" ]; then UPSTREAM="$(_display_url "$REMOTE_URL")"; else UPSTREAM="$(_display_url "$REMOTE_REPO")"; fi
# _identity_field <name> — a field of the cache's IDENTITY line (empty when absent).
_identity_field() {
printf '%s\n' "${CACHED_ID:-}" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -1
}
# _identity_ok — the cached outcome belongs to this HEAD and upstream.
_identity_ok() {
if [ -z "${CACHED_ID:-}" ]; then
[ "$LOCAL_HEAD" = "none" ]
return
fi
[ "$(_identity_field head)" = "$LOCAL_HEAD" ] && [ "$(_identity_field upstream)" = "$UPSTREAM" ]
}
# _write_cache <outcome line> — the outcome plus the identity it was computed for
# (a non-git install keeps the one-line, version-only cache).
_write_cache() {
if [ "$LOCAL_HEAD" = "none" ]; then
printf '%s\n' "$1" > "$CACHE_FILE"
return
fi
printf '%s\nIDENTITY head=%s remote=%s upstream=%s\n' "$1" "$LOCAL_HEAD" "${_REMOTE_SHA:-none}" "$UPSTREAM" > "$CACHE_FILE"
}
# _snooze_key <UPGRADE_AVAILABLE line> — the SHA for the equal-version form, else the new version.
_snooze_key() {
printf '%s\n' "$1" | awk '{ print (NF >= 4 ? $4 : $3) }'
}
# ─── Step 0: Check if updates are disabled ────────────────────
_UC=$("$GSTACK_DIR/bin/gstack-config" get update_check 2>/dev/null || true)
if [ "$_UC" = "false" ]; then
@@ -172,8 +227,11 @@ fi
# UP_TO_DATE: 60 min TTL (detect new releases quickly)
# UPGRADE_AVAILABLE: 720 min TTL (keep nagging)
# CHECK_FAILED: 10 min TTL (retry soon, without re-fetching on every skill start)
CACHED=""
CACHED_ID=""
if [ -f "$CACHE_FILE" ]; then
CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)"
CACHED="$(head -1 "$CACHE_FILE" 2>/dev/null || true)"
CACHED_ID="$(sed -n '2{/^IDENTITY /p;}' "$CACHE_FILE" 2>/dev/null || true)"
case "$CACHED" in
UP_TO_DATE*) CACHE_TTL=60 ;;
UPGRADE_AVAILABLE*) CACHE_TTL=720 ;;
@@ -181,6 +239,7 @@ if [ -f "$CACHE_FILE" ]; then
*) CACHE_TTL=0 ;; # corrupt → force re-fetch
esac
_identity_ok || CACHE_TTL=0
STALE=$(find "$CACHE_FILE" -mmin +$CACHE_TTL 2>/dev/null || true)
if [ -z "$STALE" ] && [ "$CACHE_TTL" -gt 0 ]; then
case "$CACHED" in
@@ -195,8 +254,7 @@ if [ -f "$CACHE_FILE" ]; then
UPGRADE_AVAILABLE*)
CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')"
if [ "$CACHED_OLD" = "$LOCAL" ]; then
CACHED_NEW="$(echo "$CACHED" | awk '{print $3}')"
if check_snooze "$CACHED_NEW"; then
if check_snooze "$(_snooze_key "$CACHED")"; then
exit 0 # snoozed — stay quiet
fi
echo "$CACHED"
@@ -256,8 +314,23 @@ if [ -z "${GSTACK_REMOTE_URL:-}" ]; then
git ls-remote "$1" refs/heads/main 2>/dev/null' _ "$REMOTE_REPO" || true)"
_REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')"
if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
_SHA_URL="https://raw.githubusercontent.com/garrytan/gstack/${_REMOTE_SHA}/VERSION"
# The same main SHA, HEAD and upstream as the cache: its outcome still holds.
if [ "$(_identity_field remote)" = "$_REMOTE_SHA" ] && _identity_ok; then
case "$CACHED" in
UP_TO_DATE*)
[ "$(echo "$CACHED" | awk '{print $2}')" = "$LOCAL" ] && { touch "$CACHE_FILE"; exit 0; } ;;
UPGRADE_AVAILABLE*)
if [ "$(echo "$CACHED" | awk '{print $2}')" = "$LOCAL" ]; then
touch "$CACHE_FILE"
check_snooze "$(_snooze_key "$CACHED")" || echo "$CACHED"
exit 0
fi ;;
esac
fi
_SHA_URL="$REMOTE_RAW_BASE/${_REMOTE_SHA}/VERSION"
REMOTE="$(_receipted_version_fetch "$_SHA_URL")"
else
_REMOTE_SHA=""
fi
fi
@@ -277,31 +350,63 @@ if ! echo "$REMOTE" | grep -qE '^[0-9]+\.[0-9.]+$'; then
if [ -n "${_SHA_URL:-}" ]; then
_FAILED_URL="$(_display_url "$_SHA_URL") (then $_FAILED_URL)"
fi
echo "CHECK_FAILED $LOCAL $_FAILED_URL" > "$CACHE_FILE"
_write_cache "CHECK_FAILED $LOCAL $_FAILED_URL"
if [ "$FORCE" = "1" ]; then
echo "CHECK_FAILED could not read the remote gstack version from $_FAILED_URL — update status UNKNOWN, not up-to-date"
fi
exit 0
fi
NEW="$REMOTE"
if [ "$LOCAL" = "$REMOTE" ]; then
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
exit 0
# Equal VERSION: main may still have moved past this checkout without a
# release. Ask GitHub how the two commits relate (status, ahead_by); page 2
# of one commit per page carries the counts without the file list.
if [ -z "${_REMOTE_SHA:-}" ] || [ "$LOCAL_HEAD" = "none" ] || [ "$_REMOTE_SHA" = "$LOCAL_HEAD" ]; then
_write_cache "UP_TO_DATE $LOCAL"
exit 0
fi
_CMP_URL="$COMPARE_API/${LOCAL_HEAD}...${_REMOTE_SHA}?per_page=1&page=2"
_CMP="$(_receipted_version_fetch "$_CMP_URL" | tr -s '\n\r\t ' ' ')"
_CMP_STATUS="$(printf '%s' "$_CMP" | sed -nE 's/.*"status" ?: ?"([a-z]+)" ?, ?"ahead_by" ?: ?([0-9]+).*/\1 \2/p')"
_AHEAD="${_CMP_STATUS#* }"
case "${_CMP_STATUS% *}" in
identical|behind)
_write_cache "UP_TO_DATE $LOCAL"
exit 0 ;;
ahead)
if [ "${_AHEAD:-0}" -gt 0 ] 2>/dev/null; then
NEW="$LOCAL+$_AHEAD $(printf '%s' "$_REMOTE_SHA" | cut -c1-7)"
fi ;;
esac
if [ "$NEW" = "$REMOTE" ]; then
_WHY="${_CMP_STATUS% *}"
_CMP_SHOWN="$(_display_url "$COMPARE_API")/${LOCAL_HEAD:0:12}...${_REMOTE_SHA:0:12}"
_write_cache "CHECK_FAILED $LOCAL $_CMP_SHOWN ${_WHY:-unreadable}"
if [ "$FORCE" = "1" ]; then
if [ "$_WHY" = "diverged" ]; then
echo "CHECK_FAILED this checkout (${LOCAL_HEAD:0:12}) has diverged from main (${_REMOTE_SHA:0:12}) at the same VERSION $LOCAL — update status UNKNOWN, not up-to-date"
else
echo "CHECK_FAILED could not compare this checkout (${LOCAL_HEAD:0:12}) with main (${_REMOTE_SHA:0:12}) at $_CMP_SHOWN — update status UNKNOWN, not up-to-date"
fi
fi
exit 0
fi
else
# Semver-order guard: only flag an upgrade when REMOTE sorts higher than
# LOCAL. Protects against transient stale-CDN regressions (REMOTE < LOCAL)
# and dev installs running ahead of main, both of which would otherwise
# emit a backwards UPGRADE_AVAILABLE line.
_HIGHER="$(printf '%s\n%s\n' "$LOCAL" "$REMOTE" | sort -V | tail -1)"
if [ "$_HIGHER" != "$REMOTE" ]; then
_write_cache "UP_TO_DATE $LOCAL"
exit 0
fi
fi
# Semver-order guard: only flag an upgrade when REMOTE sorts higher than
# LOCAL. Protects against transient stale-CDN regressions (REMOTE < LOCAL)
# and dev installs running ahead of main, both of which would otherwise
# emit a backwards UPGRADE_AVAILABLE line.
_HIGHER="$(printf '%s\n%s\n' "$LOCAL" "$REMOTE" | sort -V | tail -1)"
if [ "$_HIGHER" != "$REMOTE" ]; then
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
exit 0
fi
# REMOTE is strictly newer — upgrade available
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE"
if check_snooze "$REMOTE"; then
# REMOTE is strictly newer, or main moved past HEAD at the same VERSION
_write_cache "UPGRADE_AVAILABLE $LOCAL $NEW"
if check_snooze "$(_snooze_key "UPGRADE_AVAILABLE $LOCAL $NEW")"; then
exit 0 # snoozed — stay quiet
fi
@@ -312,4 +417,4 @@ if [ -x "$TEL_CMD" ]; then
--outcome success --session-id "update-$$-$(date +%s)" 2>/dev/null &
fi
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE"
echo "UPGRADE_AVAILABLE $LOCAL $NEW"
+2 -1
View File
@@ -30,7 +30,8 @@ it, so `headless` keeps its BLOCK semantics.
sync skipped (unattended session)` on stderr), and `gstack-skill-end` drains
nothing. Inherited enabled settings and pending queue work are left for the
next human session. The upgrade offer becomes one line at skill end, read from
the local cache: `UPGRADE_AVAILABLE: <version>; run /gstack-upgrade`.
the local cache: `UPGRADE_AVAILABLE: <version>; run /gstack-upgrade` (`<version>+<n> (main at <sha>)` when
main moved <n> commits past the checkout without a new version).
- **Questions decide themselves, gates do not.** A question with a
`(recommended)` option takes it and appends the choice to `decisions.jsonl`
with `kind: "auto"`. A question with no recommendation, any consent, any
+8 -2
View File
@@ -46,6 +46,10 @@ echo "AUTO_UPGRADE=$_AUTO"
**Otherwise**, use AskUserQuestion:
- Question: "gstack **v{new}** is available (you're on v{old}). Upgrade now?"
When the line has a fourth field, `UPGRADE_AVAILABLE <old> <old>+<n> <sha>`,
main moved `<n>` commits past this checkout without a new version; ask
instead: "gstack main has **{n} new commit(s)** since your checkout (still
v{old}; main is at {sha}). Upgrade now?"
- Options: ["Yes, upgrade now", "Always keep me up to date", "Not now", "Never ask again"]
**If "Yes, upgrade now":** Proceed to Step 2.
@@ -73,7 +77,7 @@ _NEW_LEVEL=$((_CUR_LEVEL + 1))
[ "$_NEW_LEVEL" -gt 3 ] && _NEW_LEVEL=3
echo "$_REMOTE_VER $_NEW_LEVEL $(date +%s)" > "$_SNOOZE_FILE"
```
Note: `{new}` is the remote version from the `UPGRADE_AVAILABLE` output — substitute it from the update check result.
Note: `{new}` is the remote version from the `UPGRADE_AVAILABLE` output — substitute it from the update check result. On the four-field line, substitute the fourth field (the main SHA) for `{new}` in this block: that snooze ends when main moves again.
Tell user the snooze duration: "Next reminder in 24h" (or 48h or 1 week, depending on level). Tip: "Set `auto_upgrade: true` in `~/.gstack/config.yaml` for automatic upgrades."
@@ -374,6 +378,8 @@ rm -f "$GSTACK_STATE_ROOT"/update-snoozed
Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant.
On the four-field (same-version) upgrade there is no new CHANGELOG entry: summarize the commit subjects the upgrade brought instead (`git -C "$INSTALL_DIR" log --oneline <PRE_UPGRADE_COMMIT>..HEAD`, with the PRE_UPGRADE_COMMIT the upgrade printed, at most 7 bullets) and title it "gstack v{old}, now at main {sha} ({n} unreleased commit(s))".
Format:
```
gstack v{new} — upgraded from v{old}!
@@ -403,7 +409,7 @@ When invoked directly as `/gstack-upgrade` (not from a preamble):
```
Use the output to determine if an upgrade is available.
2. If `UPGRADE_AVAILABLE <old> <new>`: follow Steps 2-6 above.
2. If `UPGRADE_AVAILABLE <old> <new>` (or `UPGRADE_AVAILABLE <old> <old>+<n> <sha>`: main moved without a new version): follow Steps 2-6 above.
If `CHECK_FAILED ...`: the remote version could not be read, so the update
status is unknown. Show the line (it names the URL that failed) and tell the
+8 -2
View File
@@ -43,6 +43,10 @@ echo "AUTO_UPGRADE=$_AUTO"
**Otherwise**, use AskUserQuestion:
- Question: "gstack **v{new}** is available (you're on v{old}). Upgrade now?"
When the line has a fourth field, `UPGRADE_AVAILABLE <old> <old>+<n> <sha>`,
main moved `<n>` commits past this checkout without a new version; ask
instead: "gstack main has **{n} new commit(s)** since your checkout (still
v{old}; main is at {sha}). Upgrade now?"
- Options: ["Yes, upgrade now", "Always keep me up to date", "Not now", "Never ask again"]
**If "Yes, upgrade now":** Proceed to Step 2.
@@ -70,7 +74,7 @@ _NEW_LEVEL=$((_CUR_LEVEL + 1))
[ "$_NEW_LEVEL" -gt 3 ] && _NEW_LEVEL=3
echo "$_REMOTE_VER $_NEW_LEVEL $(date +%s)" > "$_SNOOZE_FILE"
```
Note: `{new}` is the remote version from the `UPGRADE_AVAILABLE` output — substitute it from the update check result.
Note: `{new}` is the remote version from the `UPGRADE_AVAILABLE` output — substitute it from the update check result. On the four-field line, substitute the fourth field (the main SHA) for `{new}` in this block: that snooze ends when main moves again.
Tell user the snooze duration: "Next reminder in 24h" (or 48h or 1 week, depending on level). Tip: "Set `auto_upgrade: true` in `~/.gstack/config.yaml` for automatic upgrades."
@@ -371,6 +375,8 @@ rm -f "$GSTACK_STATE_ROOT"/update-snoozed
Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant.
On the four-field (same-version) upgrade there is no new CHANGELOG entry: summarize the commit subjects the upgrade brought instead (`git -C "$INSTALL_DIR" log --oneline <PRE_UPGRADE_COMMIT>..HEAD`, with the PRE_UPGRADE_COMMIT the upgrade printed, at most 7 bullets) and title it "gstack v{old}, now at main {sha} ({n} unreleased commit(s))".
Format:
```
gstack v{new} — upgraded from v{old}!
@@ -400,7 +406,7 @@ When invoked directly as `/gstack-upgrade` (not from a preamble):
```
Use the output to determine if an upgrade is available.
2. If `UPGRADE_AVAILABLE <old> <new>`: follow Steps 2-6 above.
2. If `UPGRADE_AVAILABLE <old> <new>` (or `UPGRADE_AVAILABLE <old> <old>+<n> <sha>`: main moved without a new version): follow Steps 2-6 above.
If `CHECK_FAILED ...`: the remote version could not be read, so the update
status is unknown. Show the line (it names the URL that failed) and tell the
+203
View File
@@ -0,0 +1,203 @@
/**
* W12 (field list 2; #2378's Claude Code half): bin/gstack-update-check at an
* equal VERSION whose main moved past this checkout. Real git fixtures: a bare
* "origin" whose main the script reads with ls-remote, a gstack checkout at an
* older commit, the SHA-pinned raw VERSION and the GitHub compare API served
* from file:// fixtures (GSTACK_REMOTE_RAW_BASE, GSTACK_COMPARE_API). A git
* shim on PATH logs every fetch, pull or merge: the check never fetches.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const SCRIPT = path.join(ROOT, 'bin', 'gstack-update-check');
interface Fx { root: string; gstack: string; origin: string; state: string; raw: string; compare: string; gitLog: string; env: Record<string, string> }
let fx: Fx;
function sh(cwd: string, cmd: string, env: Record<string, string> = fx.env): string {
const r = spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...env } });
if (r.status !== 0) throw new Error(cmd + ' failed: ' + r.stderr);
return r.stdout.trim();
}
/** A commit on the origin's main made from a scratch clone (the checkout never fetches it). */
function pushUpstream(message: string): string {
const clone = path.join(fx.root, 'upstream-' + Math.random().toString(36).slice(2));
sh(fx.root, 'git clone -q "' + fx.origin + '" "' + clone + '"');
sh(clone, 'echo "' + message + '" >> NOTES && git add -A && git commit -q -m "' + message + '" && git push -q origin HEAD:main');
const sha = sh(clone, 'git rev-parse HEAD');
serveVersion(sha, '1.0.0');
return sha;
}
function serveVersion(sha: string, version: string): void {
fs.mkdirSync(path.join(fx.raw, sha), { recursive: true });
fs.writeFileSync(path.join(fx.raw, sha, 'VERSION'), version + '\n');
}
/** The compare API's answer for base...head, in GitHub's shape (page 2 of one commit per page: no file list). */
function serveCompare(base: string, head: string, status: string, ahead: number, behind = 0): void {
fs.writeFileSync(path.join(fx.compare, base + '...' + head), JSON.stringify({
url: 'https://api.github.com/repos/garrytan/gstack/compare/' + base + '...' + head,
base_commit: { sha: base, commit: { message: 'base', verification: { verified: false, reason: 'unsigned' } } },
status, ahead_by: ahead, behind_by: behind, total_commits: ahead, commits: [],
}, null, 2));
}
function run(args: string[] = [], extra: Record<string, string> = {}) {
const env: Record<string, string | undefined> = { ...process.env, ...fx.env, GSTACK_DIR: fx.gstack, GSTACK_STATE_DIR: fx.state, GSTACK_REMOTE_REPO: fx.origin, GSTACK_REMOTE_RAW_BASE: 'file://' + fx.raw, GSTACK_COMPARE_API: 'file://' + fx.compare, PATH: path.join(fx.root, 'shim') + ':' + process.env.PATH, ...extra };
for (const k of ['GSTACK_STATE_ROOT', 'GSTACK_HOME', 'GSTACK_REMOTE_URL']) if (!(k in extra)) delete env[k];
const r = spawnSync('bash', [SCRIPT, ...args], { encoding: 'utf8', timeout: 30_000, env });
return { status: r.status, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' };
}
const cache = () => fs.readFileSync(path.join(fx.state, 'last-update-check'), 'utf8').split('\n');
const head = () => sh(fx.gstack, 'git rev-parse HEAD');
const short = (sha: string) => sha.slice(0, 7);
beforeEach(() => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-upd-main-'));
const gitconfig = path.join(root, 'gitconfig');
fs.writeFileSync(gitconfig, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
fx = { root, gstack: path.join(root, 'gstack'), origin: path.join(root, 'origin.git'), state: path.join(root, 'state'), raw: path.join(root, 'raw'), compare: path.join(root, 'compare'), gitLog: path.join(root, 'git.log'), env: { GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', HOME: root } };
for (const d of [fx.gstack, fx.state, fx.raw, fx.compare, path.join(root, 'shim'), path.join(fx.gstack, 'bin')]) fs.mkdirSync(d, { recursive: true });
const realGit = sh(root, 'command -v git');
fs.writeFileSync(path.join(root, 'shim', 'git'), '#!/usr/bin/env bash\ncase "$1" in fetch|pull|merge) echo "git $*" >> "' + fx.gitLog + '" ;; esac\nexec "' + realGit + '" "$@"\n', { mode: 0o755 });
for (const name of ['gstack-config', 'gstack-egress-lib.sh', 'gstack-state-root.sh', 'gstack-egress-receipt']) fs.symlinkSync(path.join(ROOT, 'bin', name), path.join(fx.gstack, 'bin', name));
fs.writeFileSync(path.join(fx.gstack, 'VERSION'), '1.0.0\n');
fs.writeFileSync(path.join(fx.gstack, '.gitignore'), 'bin/\n');
sh(root, 'git init -q --bare "' + fx.origin + '"');
sh(fx.gstack, 'git init -q -b main && git add -A && git commit -q -m base && git remote add origin "' + fx.origin + '" && git push -q origin main');
serveVersion(head(), '1.0.0');
});
afterEach(() => {
expect(fs.existsSync(fx.gitLog) ? fs.readFileSync(fx.gitLog, 'utf8') : '').toBe('');
fs.rmSync(fx.root, { recursive: true, force: true });
});
describe('equal VERSION, main moved past the checkout', () => {
test('a checkout at main stays silent and caches its identity', () => {
const r = run();
expect(r.stdout).toBe('');
expect(cache()).toEqual(['UP_TO_DATE 1.0.0', 'IDENTITY head=' + head() + ' remote=' + head() + ' upstream=' + fx.origin, '']);
});
test('two unreleased commits print UPGRADE_AVAILABLE <ver> <ver>+2 <sha>; the cache replays it without another compare, also after its TTL while main is unchanged', () => {
const local = head();
pushUpstream('c2');
const main = pushUpstream('c3');
serveCompare(local, main, 'ahead', 2);
const line = 'UPGRADE_AVAILABLE 1.0.0 1.0.0+2 ' + short(main);
expect(run().stdout).toBe(line);
expect(cache()).toEqual([line, 'IDENTITY head=' + local + ' remote=' + main + ' upstream=' + fx.origin, '']);
fs.rmSync(fx.compare, { recursive: true, force: true });
fs.rmSync(fx.raw, { recursive: true, force: true });
expect(run().stdout).toBe(line);
const old = new Date(Date.now() - 13 * 3600_000);
fs.utimesSync(path.join(fx.state, 'last-update-check'), old, old);
expect(run().stdout).toBe(line);
});
test('after a cached upgrade, fast-forwarding the checkout to main invalidates the cache: silent, UP_TO_DATE at the new HEAD', () => {
const local = head();
const main = pushUpstream('c2');
serveCompare(local, main, 'ahead', 1);
expect(run().stdout).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.0+1 ' + short(main));
spawnSync('git', ['-C', fx.gstack, 'fetch', '-q', 'origin'], { timeout: 30_000, env: { ...process.env, ...fx.env } });
sh(fx.gstack, 'git merge -q --ff-only origin/main');
expect(head()).toBe(main);
expect(run().stdout).toBe('');
expect(cache()[0]).toBe('UP_TO_DATE 1.0.0');
expect(cache()[1]).toBe('IDENTITY head=' + main + ' remote=' + main + ' upstream=' + fx.origin);
});
test('after a cached upgrade, a diverged local commit is unknown: silent CHECK_FAILED, and --force says diverged, never up to date', () => {
const main = pushUpstream('c2');
serveCompare(head(), main, 'ahead', 1);
expect(run().stdout).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.0+1 ' + short(main));
sh(fx.gstack, 'echo local >> LOCAL && git add LOCAL && git commit -q -m local');
const local = head();
serveCompare(local, main, 'diverged', 1, 1);
expect(run().stdout).toBe('');
expect(cache()[0]).toStartWith('CHECK_FAILED 1.0.0 ');
expect(cache()[0]).toEndWith(' diverged');
expect(cache()[1]).toBe('IDENTITY head=' + local + ' remote=' + main + ' upstream=' + fx.origin);
const forced = run(['--force']);
expect(forced.stdout).toBe('CHECK_FAILED this checkout (' + local.slice(0, 12) + ') has diverged from main (' + main.slice(0, 12) + ') at the same VERSION 1.0.0 — update status UNKNOWN, not up-to-date');
});
test('an unreadable compare (API down, rate limited) is unknown, not up to date and not an upgrade', () => {
pushUpstream('c2');
expect(run().stdout).toBe('');
expect(cache()[0]).toMatch(/^CHECK_FAILED 1\.0\.0 file:\/\/.*\/compare\/[0-9a-f]{12}\.\.\.[0-9a-f]{12} unreadable$/);
expect(run(['--force']).stdout).toMatch(/^CHECK_FAILED could not compare this checkout \([0-9a-f]{12}\) with main \([0-9a-f]{12}\) at .* — update status UNKNOWN, not up-to-date$/);
});
test('a checkout ahead of main (compare status behind) is up to date', () => {
const main = head();
sh(fx.gstack, 'echo dev >> DEV && git add DEV && git commit -q -m dev');
serveCompare(head(), main, 'behind', 0, 1);
expect(run().stdout).toBe('');
expect(cache()[0]).toBe('UP_TO_DATE 1.0.0');
});
test('a legacy one-line cache in a git checkout is ignored, and a different upstream invalidates a cached result', () => {
const local = head();
const main = pushUpstream('c2');
serveCompare(local, main, 'ahead', 1);
fs.writeFileSync(path.join(fx.state, 'last-update-check'), 'UP_TO_DATE 1.0.0\n');
expect(run().stdout).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.0+1 ' + short(main));
const mirror = path.join(fx.root, 'mirror.git');
sh(fx.root, 'git init -q --bare "' + mirror + '" && git -C "' + fx.gstack + '" push -q "' + mirror + '" ' + local + ':refs/heads/main');
expect(run([], { GSTACK_REMOTE_REPO: mirror }).stdout).toBe('');
expect(cache()).toEqual(['UP_TO_DATE 1.0.0', 'IDENTITY head=' + local + ' remote=' + local + ' upstream=' + mirror, '']);
});
test('the snooze is keyed by the main SHA: a snoozed SHA is quiet, the next main commit asks again once the cache expires', () => {
const local = head();
const main = pushUpstream('c2');
serveCompare(local, main, 'ahead', 1);
fs.writeFileSync(path.join(fx.state, 'update-snoozed'), short(main) + ' 1 ' + Math.floor(Date.now() / 1000) + '\n');
expect(run().stdout).toBe('');
expect(cache()[0]).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.0+1 ' + short(main));
const next = pushUpstream('c3');
serveCompare(local, next, 'ahead', 2);
const old = new Date(Date.now() - 13 * 3600_000);
fs.utimesSync(path.join(fx.state, 'last-update-check'), old, old);
expect(run().stdout).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.0+2 ' + short(next));
});
test("gstack-upgrade's Not-now block, with the fourth field substituted for {new}, snoozes exactly that SHA", () => {
const local = head();
const main = pushUpstream('c2');
serveCompare(local, main, 'ahead', 1);
const line = run().stdout;
const fields = line.split(' ');
expect(fields).toHaveLength(4);
const skill = fs.readFileSync(path.join(ROOT, 'gstack-upgrade', 'SKILL.md'), 'utf8');
const block = /```bash\n(GSTACK_STATE_ROOT=\$\(~\/\.claude\/skills\/gstack\/bin\/gstack-paths[^\n]*\n_SNOOZE_FILE=[\s\S]*?)```/.exec(skill)![1]!;
expect(block).toContain('_REMOTE_VER="{new}"');
expect(skill).toContain('On the four-field line, substitute the fourth field (the main SHA) for `{new}` in this block');
const bin = path.join(fx.root, '.claude', 'skills', 'gstack', 'bin');
fs.mkdirSync(bin, { recursive: true });
fs.mkdirSync(path.join(path.dirname(bin), 'lib'), { recursive: true });
for (const name of ['gstack-paths', 'gstack-state-root.sh']) fs.copyFileSync(path.join(ROOT, 'bin', name), path.join(bin, name));
for (const name of ['gstack-paths', 'gstack-state-root.sh']) fs.chmodSync(path.join(bin, name), 0o755);
const r = spawnSync('bash', ['-c', block.replace('{new}', fields[3]!)], { encoding: 'utf8', timeout: 15_000, env: { ...process.env, HOME: fx.root, GSTACK_STATE_ROOT: fx.state, GSTACK_ROOT: path.dirname(bin) } });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(fx.state, 'update-snoozed'), 'utf8')).toMatch(new RegExp('^' + short(main) + ' 1 \\d+\\n$'));
expect(run().stdout).toBe('');
});
test('unattended skill-end reports the four-field line from the two-line cache', () => {
fs.writeFileSync(path.join(fx.state, 'last-update-check'), 'UPGRADE_AVAILABLE 1.0.0 1.0.0+2 abc1234\nIDENTITY head=' + head() + ' remote=' + 'a'.repeat(40) + ' upstream=' + fx.origin + '\n');
const env: Record<string, string | undefined> = { ...process.env, ...fx.env, GSTACK_HOME: fx.state, GSTACK_SESSION_KIND: 'unattended' };
delete env.GSTACK_STATE_ROOT;
const r = spawnSync(path.join(ROOT, 'bin', 'gstack-skill-end'), ['--skill', 'review', '--outcome', 'success', '--session-id', 'sid-w12', '--tel-start', '1'], { cwd: fx.root, encoding: 'utf8', timeout: 30_000, env });
expect(r.stdout).toMatch(/^UPGRADE_AVAILABLE: 1\.0\.0\+2 \(main at abc1234\); run \/gstack-upgrade$/m);
});
});