From 8825d8eddb7e130b58fcfd8e3c5e2ec4800643eb Mon Sep 17 00:00:00 2001 From: Cloverings1 Date: Tue, 9 Jun 2026 15:45:40 -0500 Subject: [PATCH] Fix update-check apply mode --- bin/gstack-update-check | 73 ++++++++++++++++++- test/gstack-update-check-apply.test.ts | 98 ++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 test/gstack-update-check-apply.test.ts diff --git a/bin/gstack-update-check b/bin/gstack-update-check index d0486cb4c..195c2df16 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -3,6 +3,7 @@ # # Output (one line, or nothing): # JUST_UPGRADED — marker found from recent upgrade +# UPGRADED — --apply fast-forwarded the install # UPGRADE_AVAILABLE — remote VERSION differs from local # (nothing) — up to date, snoozed, disabled, or check skipped # @@ -22,8 +23,22 @@ 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}" +FORCE=0 +APPLY=0 +while [ $# -gt 0 ]; do + case "$1" in + --force) FORCE=1 ;; + --apply) APPLY=1 ;; + *) + echo "gstack-update-check: unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + # ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ── -if [ "${1:-}" = "--force" ]; then +if [ "$FORCE" -eq 1 ]; then rm -f "$CACHE_FILE" rm -f "$SNOOZE_FILE" fi @@ -101,6 +116,56 @@ check_snooze() { return 1 # snooze expired } +fail_apply() { + echo "gstack-update-check: --apply failed: $*" >&2 + exit 1 +} + +apply_upgrade() { + local old="$1" + local target="$2" + local branch upstream after + + git -C "$GSTACK_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || fail_apply "$GSTACK_DIR is not a git checkout" + + branch="$(git -C "$GSTACK_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + [ -n "$branch" ] || fail_apply "$GSTACK_DIR is on a detached HEAD" + + git -C "$GSTACK_DIR" diff --quiet --ignore-submodules -- \ + || fail_apply "$GSTACK_DIR has uncommitted tracked changes" + git -C "$GSTACK_DIR" diff --cached --quiet --ignore-submodules -- \ + || fail_apply "$GSTACK_DIR has staged changes" + + if ! GIT_TERMINAL_PROMPT=0 git -C "$GSTACK_DIR" fetch origin "$branch" >/dev/null 2>&1; then + GIT_TERMINAL_PROMPT=0 git -C "$GSTACK_DIR" fetch origin main >/dev/null 2>&1 \ + || fail_apply "could not fetch origin/$branch or origin/main" + fi + + upstream="origin/$branch" + if ! git -C "$GSTACK_DIR" rev-parse --verify "$upstream" >/dev/null 2>&1; then + upstream="origin/main" + git -C "$GSTACK_DIR" rev-parse --verify "$upstream" >/dev/null 2>&1 \ + || fail_apply "could not resolve origin/$branch or origin/main" + fi + + git -C "$GSTACK_DIR" merge --ff-only "$upstream" >/dev/null 2>&1 \ + || fail_apply "could not fast-forward to $upstream" + + after="" + if [ -f "$VERSION_FILE" ]; then + after="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]')" + fi + [ -n "$after" ] || fail_apply "VERSION is missing after fast-forward" + [ "$after" != "$old" ] || fail_apply "VERSION stayed at $old after fast-forward" + + mkdir -p "$STATE_DIR" + echo "$old" > "$MARKER_FILE" + rm -f "$CACHE_FILE" "$SNOOZE_FILE" + echo "UPGRADED $old $after" + exit 0 +} + # ─── Step 1: Read local version ────────────────────────────── LOCAL="" if [ -f "$VERSION_FILE" ]; then @@ -146,6 +211,9 @@ if [ -f "$CACHE_FILE" ]; then CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')" if [ "$CACHED_OLD" = "$LOCAL" ]; then CACHED_NEW="$(echo "$CACHED" | awk '{print $3}')" + if [ "$APPLY" -eq 1 ]; then + apply_upgrade "$LOCAL" "$CACHED_NEW" + fi if check_snooze "$CACHED_NEW"; then exit 0 # snoozed — stay quiet fi @@ -234,6 +302,9 @@ fi # REMOTE is strictly newer — upgrade available echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE" +if [ "$APPLY" -eq 1 ]; then + apply_upgrade "$LOCAL" "$REMOTE" +fi if check_snooze "$REMOTE"; then exit 0 # snoozed — stay quiet fi diff --git a/test/gstack-update-check-apply.test.ts b/test/gstack-update-check-apply.test.ts new file mode 100644 index 000000000..d79dc46d2 --- /dev/null +++ b/test/gstack-update-check-apply.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { spawnSync } from "child_process"; + +const ROOT = join(import.meta.dir, ".."); +const UPDATE_CHECK = join(ROOT, "bin", "gstack-update-check"); + +const cleanupDirs: string[] = []; + +function sh(args: string[], cwd: string, env: NodeJS.ProcessEnv = process.env) { + const result = spawnSync(args[0], args.slice(1), { + cwd, + env: { + ...env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + encoding: "utf-8", + }); + if (result.status !== 0) { + throw new Error(`${args.join(" ")} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } + return result; +} + +function makeInstallPair() { + const tmp = mkdtempSync(join(tmpdir(), "gstack-update-check-")); + cleanupDirs.push(tmp); + + const origin = join(tmp, "origin"); + const install = join(tmp, "install"); + const state = join(tmp, "state"); + mkdirSync(origin, { recursive: true }); + mkdirSync(join(origin, "bin"), { recursive: true }); + mkdirSync(state, { recursive: true }); + + writeFileSync(join(origin, "VERSION"), "1.0.0\n"); + writeFileSync(join(origin, "bin", "gstack-config"), "#!/bin/sh\nexit 0\n"); + chmodSync(join(origin, "bin", "gstack-config"), 0o755); + + sh(["git", "init", "-q", "-b", "main"], origin); + sh(["git", "add", "VERSION", "bin/gstack-config"], origin); + sh(["git", "commit", "-q", "-m", "initial"], origin); + sh(["git", "clone", "-q", origin, install], tmp); + + writeFileSync(join(origin, "VERSION"), "1.0.1\n"); + sh(["git", "add", "VERSION"], origin); + sh(["git", "commit", "-q", "-m", "release 1.0.1"], origin); + + return { tmp, origin, install, state }; +} + +function runUpdateCheck(repo: ReturnType, args: string[]) { + return spawnSync("bash", [UPDATE_CHECK, ...args], { + cwd: repo.install, + encoding: "utf-8", + timeout: 10_000, + env: { + ...process.env, + GSTACK_DIR: repo.install, + GSTACK_STATE_DIR: repo.state, + GSTACK_REMOTE_URL: `file://${join(repo.origin, "VERSION")}`, + }, + }); +} + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("gstack-update-check --apply", () => { + test("report-only check leaves the install unchanged", () => { + const repo = makeInstallPair(); + const result = runUpdateCheck(repo, ["--force"]); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("UPGRADE_AVAILABLE 1.0.0 1.0.1"); + expect(readFileSync(join(repo.install, "VERSION"), "utf-8").trim()).toBe("1.0.0"); + }); + + test("fast-forwards the install and writes the just-upgraded marker", () => { + const repo = makeInstallPair(); + const result = runUpdateCheck(repo, ["--force", "--apply"]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("UPGRADED 1.0.0 1.0.1"); + expect(readFileSync(join(repo.install, "VERSION"), "utf-8").trim()).toBe("1.0.1"); + expect(readFileSync(join(repo.state, "just-upgraded-from"), "utf-8").trim()).toBe("1.0.0"); + expect(existsSync(join(repo.state, "last-update-check"))).toBe(false); + }); +});