From 421e66d2a468ba88b6c5aa6083368f29069d8f02 Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 16 Oct 2026 23:25:36 +0000 Subject: [PATCH] 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 + ; 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> --- bin/gstack-skill-end | 4 +- bin/gstack-update-check | 149 +++++++++++++++++--- docs/unattended.md | 3 +- gstack-upgrade/SKILL.md | 10 +- gstack-upgrade/SKILL.md.tmpl | 10 +- test/update-check-main-moved.test.ts | 203 +++++++++++++++++++++++++++ 6 files changed, 350 insertions(+), 29 deletions(-) create mode 100644 test/update-check-main-moved.test.ts diff --git a/bin/gstack-skill-end b/bin/gstack-skill-end index dc0d10587..759a2676d 100755 --- a/bin/gstack-skill-end +++ b/bin/gstack-skill-end @@ -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)" diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 47650de37..6c0d65a69 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -4,18 +4,34 @@ # Output (one line, or nothing): # JUST_UPGRADED — marker found from recent upgrade # UPGRADE_AVAILABLE — remote VERSION differs from local +# UPGRADE_AVAILABLE + +# — VERSION is equal but main moved +# commits past this checkout's HEAD (the +# GitHub compare API says so); the snooze +# is keyed by # CHECK_FAILED — 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= remote= upstream= +# 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 — 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 — 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 — 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" diff --git a/docs/unattended.md b/docs/unattended.md index 906257591..5c2080f7b 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -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: ; run /gstack-upgrade`. + the local cache: `UPGRADE_AVAILABLE: ; run /gstack-upgrade` (`+ (main at )` when + main moved 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 diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index 60bfb85b2..4d0b455fc 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -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 + `, + main moved `` 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 ..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 `: follow Steps 2-6 above. +2. If `UPGRADE_AVAILABLE ` (or `UPGRADE_AVAILABLE + `: 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 diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index f5df5ae64..a2ccc8b25 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -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 + `, + main moved `` 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 ..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 `: follow Steps 2-6 above. +2. If `UPGRADE_AVAILABLE ` (or `UPGRADE_AVAILABLE + `: 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 diff --git a/test/update-check-main-moved.test.ts b/test/update-check-main-moved.test.ts new file mode 100644 index 000000000..609011dea --- /dev/null +++ b/test/update-check-main-moved.test.ts @@ -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 } +let fx: Fx; + +function sh(cwd: string, cmd: string, env: Record = 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 = {}) { + const env: Record = { ...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 +2 ; 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 = { ...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); + }); +});