This commit is contained in:
Jonas B. 2026-07-14 19:16:50 -07:00 committed by GitHub
commit 71d872b269
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 170 additions and 1 deletions

View File

@ -3,6 +3,7 @@
#
# Output (one line, or nothing):
# JUST_UPGRADED <old> <new> — marker found from recent upgrade
# UPGRADED <old> <new> — --apply fast-forwarded the install
# UPGRADE_AVAILABLE <old> <new> — 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

View File

@ -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<typeof makeInstallPair>, 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);
});
});