From d6e5c3fdcdb4b5fa2c1abd13cccb248e0f990fb4 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:57:58 -0700 Subject: [PATCH] chore(selfhost): script git-backed self-host updates, ignore stray backups (#1660) The maintainer host deploy is now a clean checkout tracking origin/main, but two gaps remained: nothing stopped an ad-hoc operator snapshot (e.g. `cp file file.bak-notes-20260707`) from silently dirtying `git status`, and the "pull upstream changes" flow was undocumented process rather than a script -- an operator had to remember to run git pull, then deploy-selfhost-prebuilt.sh, in that order, with no guard against a diverged local history quietly producing a merge commit. Add trailing `*.bak-*`/`*.backup-*` catch-alls to .gitignore (verified via `git ls-files` that nothing tracked matches either pattern), and add scripts/selfhost-update.sh: a thin wrapper around `git fetch` + `git merge --ff-only` + the existing deploy-selfhost-prebuilt.sh rebuild step + selfhost-post-update-check.sh. It refuses to proceed -- with no side effects and no script invoked -- on a dirty working tree, a checkout that isn't on the expected branch, or a non-fast-forward divergence, so it never rebases, force-merges, or picks a side on the operator's behalf. Document the new script in the self-hosting operations docs (the existing "Updating and rolling back" section) alongside the manual two-step flow it wraps, and note that .env, gittensory-config/, .deploy-backups/, and any *.local override files all already survive an update untouched. --- .gitignore | 7 + .../routes/docs.self-hosting-operations.tsx | 69 ++++- scripts/selfhost-update.sh | 82 ++++++ .../docs-selfhost-git-deploy-hygiene.test.ts | 88 +++++++ test/unit/selfhost-update-script.test.ts | 246 ++++++++++++++++++ 5 files changed, 483 insertions(+), 9 deletions(-) create mode 100755 scripts/selfhost-update.sh create mode 100644 test/unit/docs-selfhost-git-deploy-hygiene.test.ts create mode 100644 test/unit/selfhost-update-script.test.ts diff --git a/.gitignore b/.gitignore index 976aa5c24f..1acfa6e952 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,10 @@ site/.vitepress/cache/ !migrations/*.sql apps/gittensory-ui/public/downloads/gittensory-extension.zip .worker-configuration.gen-check.d.ts +# Ad-hoc operator backup files (e.g. `cp file.yml file.yml.bak-notes-20260707`) -- general +# catch-alls so a stray manual snapshot never dirties `git status` on a Git-backed self-host +# checkout. Trailing on purpose: the narrower gittensory-config.backup-*/ and .deploy-backups/ +# rules above already cover their specific directories, and nothing tracked in the repo matches +# either pattern (verified via `git ls-files | grep -E '\.bak-|\.backup-'`) (#1660). +*.bak-* +*.backup-* diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx index 0b1edb6517..4b53cab205 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx @@ -882,8 +882,15 @@ SENTRY_RELEASE=gittensory-selfhost@2026.07.05 Backup and scaling.
  • - Source path only: git pull and confirm git status is clean (no - uncommitted local changes the build would silently pick up). + Source path only: confirm git status is clean (no uncommitted local changes + the build would silently pick up). An ad-hoc snapshot like{" "} + cp docker-compose.yml docker-compose.yml.bak-notes-20260707 does not count + against this — the trailing *.bak-*/*.backup-* patterns in{" "} + .gitignore keep stray manual backups out of git status entirely, + on top of the narrower gittensory-config.backup-*/ and{" "} + .deploy-backups/ patterns that already covered those specific directories. + scripts/selfhost-update.sh (below) checks this for you and refuses to + continue on a dirty tree.
  • Image path only: note the current tag or digest from docker inspect on the @@ -921,19 +928,57 @@ GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost@sha256:... ./scripts/depl

    Path 2: build from the current git checkout

    - scripts/deploy-selfhost-prebuilt.sh is for a source-based deploy (this is how{" "} - GITTENSORY_VERSION ends up as a short git SHA instead of an image tag). It - builds the bundle inside a Dockerized Node container — the host itself never needs Node or - npm installed — then restarts only the gittensory service the same way as the - image path. + scripts/selfhost-update.sh is the recommended entry point for a Git-backed + source checkout (#1660) — it is the single command that turns git fetch + + fast-forward + rebuild + verify into one flow, instead of an operator having to remember the + right order: +

    + +

    + It refuses to continue, with a clear error and no side effects, on any of the three things + that make a plain git pull unsafe to script blindly: the working tree is not + clean, the checkout is not on the expected branch (main by default), or local + history has diverged from origin/main in a way that is not a fast-forward ( + git merge --ff-only — it never rebases, force-merges, or picks a side for you). + Only once the fast-forward succeeds does it call{" "} + scripts/deploy-selfhost-prebuilt.sh (below) to rebuild and restart, then{" "} + scripts/selfhost-post-update-check.sh to verify health — so a normal update is + one command and a failure at any step stops before the next one runs. +

    +

    + None of this touches operator-owned state: .env, the{" "} + gittensory-config/ mount, .deploy-backups/, any{" "} + *.local compose override or Alertmanager file, and every named data volume are + already gitignored or outside the source tree entirely, so a fetch-and-rebuild never touches + them. See the Quickstart for the initial + clone; this script assumes that checkout already exists and already tracks{" "} + origin/main. +

    + +

    + Want finer control — a pinned SENTRY_RELEASE, a Sentry source-map upload, or to + fetch and rebuild as separate manual steps? Call the two scripts it wraps directly:

    - SENTRY_RELEASE defaults to{" "} + scripts/deploy-selfhost-prebuilt.sh is the actual rebuild step (this is how{" "} + GITTENSORY_VERSION ends up as a short git SHA instead of an image tag). It + builds the bundle inside a Dockerized Node container — the host itself never needs Node or + npm installed — then restarts only the gittensory service the same way as the + image path. SENTRY_RELEASE defaults to{" "} gittensory-selfhost@<short git SHA of the current HEAD> unless you override it, so each deploy from a new commit gets a distinct release id automatically. When{" "} SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT are @@ -943,6 +988,12 @@ GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost@sha256:... ./scripts/depl

    Post-update checklist

    +

    + scripts/selfhost-update.sh already runs the health probe below for you unless + you set SELFHOST_SKIP_POST_UPDATE_CHECK=1. Run it manually after the image + path, after calling the two wrapped scripts directly, or after any manual{" "} + docker compose update. +

    1. Wait for the deploy script's health wait to finish (or run the helper below if you diff --git a/scripts/selfhost-update.sh b/scripts/selfhost-update.sh new file mode 100755 index 0000000000..d9c5878382 --- /dev/null +++ b/scripts/selfhost-update.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Git-backed self-host update flow: fetch, fast-forward-only, rebuild, verify (#1660). +# +# This is the single entry point for pulling upstream changes into a Git-backed self-host +# checkout. Before this script, an operator had to remember to run `git pull`, then +# deploy-selfhost-prebuilt.sh, then selfhost-post-update-check.sh, in that order, with no guard +# against a diverged local history silently creating a merge commit. This wraps all three into one +# command and refuses to proceed if the fast-forward is not clean: +# +# ./scripts/selfhost-update.sh +# +# What this preserves untouched (all already gitignored -- see .gitignore): +# - .env and any *_FILE secret mounts +# - gittensory-config/ (private per-repo .gittensory.yml policy) +# - .deploy-backups/ (operator deploy-backup snapshots) +# - any *.local compose override or alertmanager config files +# - named data volumes (gittensory-data, gittensory-pg, qdrant-data, gittensory-backups, +# grafana-data) -- untouched because this script only fetches source and rebuilds the +# gittensory app image; it never runs `docker volume` commands or touches compose profiles. +# +# Optional knobs: +# SELFHOST_UPDATE_REMOTE=upstream SELFHOST_UPDATE_BRANCH=main ./scripts/selfhost-update.sh +# SELFHOST_SKIP_POST_UPDATE_CHECK=1 ./scripts/selfhost-update.sh # skip the health probe step +set -euo pipefail + +REMOTE="${SELFHOST_UPDATE_REMOTE:-origin}" +BRANCH="${SELFHOST_UPDATE_BRANCH:-main}" +SKIP_POST_UPDATE_CHECK="${SELFHOST_SKIP_POST_UPDATE_CHECK:-0}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: required command not found: $1" >&2 + exit 1 + fi +} + +require_cmd git + +if ! git -C "$SCRIPT_DIR/.." rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "error: run this script from the gittensory git checkout" >&2 + exit 1 +fi + +cd "$SCRIPT_DIR/.." + +current_branch="$(git rev-parse --abbrev-ref HEAD)" +if [ "$current_branch" != "$BRANCH" ]; then + echo "error: currently on '$current_branch', expected '$BRANCH' -- checkout $BRANCH first, or" \ + "set SELFHOST_UPDATE_BRANCH=$current_branch if that is deliberate" >&2 + exit 1 +fi + +if [ -n "$(git status --porcelain)" ]; then + echo "error: working tree is not clean -- commit, stash, or discard local changes before updating" >&2 + git status --short >&2 + exit 1 +fi + +echo "selfhost update: fetching $REMOTE" +git fetch "$REMOTE" + +echo "selfhost update: fast-forwarding $BRANCH to $REMOTE/$BRANCH" +if ! git merge --ff-only "$REMOTE/$BRANCH"; then + echo "error: $BRANCH could not be fast-forwarded to $REMOTE/$BRANCH -- local history has" \ + "diverged (unpushed commits or a manual edit). Resolve manually; this script never rebases" \ + "or force-merges for you." >&2 + exit 1 +fi + +echo "selfhost update: rebuilding from the updated checkout" +"$SCRIPT_DIR/deploy-selfhost-prebuilt.sh" + +if [ "$SKIP_POST_UPDATE_CHECK" = "1" ]; then + echo "selfhost update: skipping post-update health check (SELFHOST_SKIP_POST_UPDATE_CHECK=1)" +else + echo "selfhost update: verifying health" + "$SCRIPT_DIR/selfhost-post-update-check.sh" +fi + +echo "selfhost update: complete ($(git rev-parse --short=8 HEAD))" diff --git a/test/unit/docs-selfhost-git-deploy-hygiene.test.ts b/test/unit/docs-selfhost-git-deploy-hygiene.test.ts new file mode 100644 index 0000000000..b30949a888 --- /dev/null +++ b/test/unit/docs-selfhost-git-deploy-hygiene.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +// Drift guard (#1660): the Git-backed update flow -- the .gitignore backup-file catch-alls, the +// scripts/selfhost-update.sh wrapper, and the operations docs describing it -- must stay aligned +// so an operator following the docs actually gets the script's real safety behavior. + +const GITIGNORE = ".gitignore"; +const UPDATE_SCRIPT = "scripts/selfhost-update.sh"; +const PREBUILT_SCRIPT = "scripts/deploy-selfhost-prebuilt.sh"; +const POST_UPDATE_SCRIPT = "scripts/selfhost-post-update-check.sh"; +const OPERATIONS = "apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx"; + +const gitignore = readFileSync(GITIGNORE, "utf8"); +const updateScript = readFileSync(UPDATE_SCRIPT, "utf8"); +const operations = readFileSync(OPERATIONS, "utf8"); + +describe("self-host git-deploy hygiene (#1660)", () => { + it(".gitignore catches ad-hoc operator backup files as trailing patterns", () => { + expect(gitignore).toContain("*.bak-*"); + expect(gitignore).toContain("*.backup-*"); + // Trailing: the general catch-alls must come after the narrower, already-shipped patterns + // they generalize, so this test fails loudly if a future edit reorders them. + const deployBackupsIndex = gitignore.indexOf(".deploy-backups/"); + const generalBakIndex = gitignore.indexOf("*.bak-*"); + const generalBackupIndex = gitignore.indexOf("*.backup-*"); + expect(deployBackupsIndex).toBeGreaterThan(-1); + expect(generalBakIndex).toBeGreaterThan(deployBackupsIndex); + expect(generalBackupIndex).toBeGreaterThan(deployBackupsIndex); + }); + + it("does not shadow any file actually tracked in the repo", () => { + // The real regression concern: a future PR could add a legitimately-tracked file whose name + // happens to match `*.bak-*` or `*.backup-*`, which would silently untrack it the moment + // someone re-clones. Ask git itself, rather than approximating the glob in JS, since git's + // own matcher is the one that actually enforces these patterns. + const result = spawnSync("git", ["ls-files"], { encoding: "utf8" }); + expect(result.status).toBe(0); + const trackedFiles = result.stdout.split("\n").filter(Boolean); + const bakLikeGlob = /(^|\/)[^/]*\.(bak|backup)-[^/]*$/; + const shadowed = trackedFiles.filter((path) => bakLikeGlob.test(path)); + expect(shadowed).toEqual([]); + }); + + it("wraps fetch, fast-forward-only merge, rebuild, and the post-update check", () => { + expect(updateScript).toContain("#!/usr/bin/env bash"); + expect(updateScript).toContain("set -euo pipefail"); + expect(updateScript).toContain("git fetch"); + expect(updateScript).toContain("git merge --ff-only"); + expect(updateScript).toContain(PREBUILT_SCRIPT.replace("scripts/", "")); + expect(updateScript).toContain(POST_UPDATE_SCRIPT.replace("scripts/", "")); + }); + + it("refuses to proceed on a dirty tree, the wrong branch, or a non-fast-forward", () => { + expect(updateScript).toContain("git status --porcelain"); + expect(updateScript).toContain("current_branch"); + expect(updateScript).toMatch(/if\s*\[\s*-n\s*"\$\(git status --porcelain\)"\s*\]/); + }); + + it("never force-pushes, hard-resets, or force-merges on the operator's behalf", () => { + expect(updateScript).not.toContain("git push"); + expect(updateScript).not.toContain("reset --hard"); + expect(updateScript).not.toContain("--force"); + expect(updateScript).not.toContain("clean -f"); + expect(updateScript).not.toContain("merge --no-ff"); + }); + + it("supports overriding the remote/branch and skipping the health probe", () => { + expect(updateScript).toContain("SELFHOST_UPDATE_REMOTE"); + expect(updateScript).toContain("SELFHOST_UPDATE_BRANCH"); + expect(updateScript).toContain("SELFHOST_SKIP_POST_UPDATE_CHECK"); + }); + + it("operations docs point operators at the wrapper script and its safety guarantees", () => { + expect(operations).toContain("scripts/selfhost-update.sh"); + expect(operations).toContain("*.bak-*"); + expect(operations).toContain("*.backup-*"); + expect(operations).toContain("git merge --ff-only"); + expect(operations).toContain("SELFHOST_SKIP_POST_UPDATE_CHECK"); + }); + + it("operations docs still name every operator-owned path the script must never touch", () => { + expect(operations).toContain("gittensory-config/"); + expect(operations).toContain(".deploy-backups/"); + expect(operations).toContain("*.local"); + }); +}); diff --git a/test/unit/selfhost-update-script.test.ts b/test/unit/selfhost-update-script.test.ts new file mode 100644 index 0000000000..8746209817 --- /dev/null +++ b/test/unit/selfhost-update-script.test.ts @@ -0,0 +1,246 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +// Real end-to-end execution of scripts/selfhost-update.sh (#1660) against a throwaway git remote, +// with deploy-selfhost-prebuilt.sh and selfhost-post-update-check.sh replaced by stubs that just +// log a call marker -- this exercises the actual fetch/fast-forward/rebuild/verify control flow +// (and its refusal paths) rather than only asserting on the script's source text. + +const REAL_SCRIPT = readFileSync(resolve("scripts/selfhost-update.sh"), "utf8"); + +const GIT_ENV = { + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@example.invalid", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@example.invalid", +}; + +const STUB_PREBUILT = `#!/usr/bin/env bash +set -euo pipefail +printf 'prebuilt-called\\n' >> "$CALL_LOG" +exit "\${STUB_PREBUILT_EXIT:-0}" +`; + +const STUB_POST_UPDATE = `#!/usr/bin/env bash +set -euo pipefail +printf 'post-update-called\\n' >> "$CALL_LOG" +exit "\${STUB_POST_UPDATE_EXIT:-0}" +`; + +function git(args: string[], cwd: string) { + const result = spawnSync("git", args, { cwd, encoding: "utf8", env: { ...process.env, ...GIT_ENV } }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${result.stderr}`); + } + return result; +} + +function headOf(cwd: string): string { + return git(["rev-parse", "HEAD"], cwd).stdout.trim(); +} + +const sandboxDirs: string[] = []; + +afterEach(() => { + while (sandboxDirs.length > 0) { + const dir = sandboxDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function createSandbox() { + const base = mkdtempSync(join(tmpdir(), "gittensory-selfhost-update-")); + sandboxDirs.push(base); + + const originDir = join(base, "origin.git"); + const seedDir = join(base, "seed"); + const checkoutDir = join(base, "checkout"); + const callLog = join(base, "calls.log"); + + // A bare "upstream" the seed repo pushes to and the checkout clones from. Forcing HEAD's symref + // to refs/heads/main before the first push means every later `git clone` of this bare repo + // checks out `main` directly -- no fallback branch-detection dance needed in the test itself. + git(["init", "-q", "--bare", originDir], base); + git(["symbolic-ref", "HEAD", "refs/heads/main"], originDir); + + // The real script plus its two stubbed collaborators are committed in the SEED repo, before + // checkoutDir ever clones -- committing them into checkoutDir instead (after cloning) would + // advance checkoutDir's history one commit past origin/main, and every later advanceOrigin() + // push from seedDir would then be rejected as a non-fast-forward against its own history. + mkdirSync(seedDir, { recursive: true }); + writeFileSync(join(seedDir, "README.md"), "seed\n"); + mkdirSync(join(seedDir, "scripts"), { recursive: true }); + writeFileSync(join(seedDir, "scripts", "selfhost-update.sh"), REAL_SCRIPT); + chmodSync(join(seedDir, "scripts", "selfhost-update.sh"), 0o755); + writeFileSync(join(seedDir, "scripts", "deploy-selfhost-prebuilt.sh"), STUB_PREBUILT); + chmodSync(join(seedDir, "scripts", "deploy-selfhost-prebuilt.sh"), 0o755); + writeFileSync(join(seedDir, "scripts", "selfhost-post-update-check.sh"), STUB_POST_UPDATE); + chmodSync(join(seedDir, "scripts", "selfhost-post-update-check.sh"), 0o755); + git(["init", "-q", "-b", "main", seedDir], base); + git(["remote", "add", "origin", originDir], seedDir); + git(["add", "-A"], seedDir); + git(["commit", "-q", "-m", "initial"], seedDir); + git(["push", "-q", "origin", "main"], seedDir); + + git(["clone", "-q", originDir, checkoutDir], base); + + return { base, originDir, seedDir, checkoutDir, callLog }; +} + +function advanceOrigin(seedDir: string, message: string) { + writeFileSync(join(seedDir, "README.md"), `${message}\n`, { flag: "a" }); + git(["add", "-A"], seedDir); + git(["commit", "-q", "-m", message], seedDir); + git(["push", "-q", "origin", "main"], seedDir); +} + +function readCallLog(callLog: string): string { + try { + return readFileSync(callLog, "utf8"); + } catch { + return ""; + } +} + +function run(checkoutDir: string, callLog: string, env: Record = {}) { + return spawnSync("bash", [join(checkoutDir, "scripts", "selfhost-update.sh")], { + cwd: checkoutDir, + encoding: "utf8", + env: { ...process.env, ...GIT_ENV, CALL_LOG: callLog, ...env }, + }); +} + +describe("selfhost-update.sh", () => { + it("fetches, fast-forwards, rebuilds, and verifies health on a clean checkout", () => { + const { seedDir, checkoutDir, callLog } = createSandbox(); + advanceOrigin(seedDir, "advance readme"); + + const result = run(checkoutDir, callLog); + + expect(result.status, result.stderr).toBe(0); + expect(readCallLog(callLog)).toBe("prebuilt-called\npost-update-called\n"); + expect(result.stdout).toContain("selfhost update: complete"); + expect(headOf(checkoutDir)).toBe(headOf(seedDir)); + }); + + it("is a safe no-op restart-equivalent when already up to date", () => { + const { checkoutDir, callLog } = createSandbox(); + + const result = run(checkoutDir, callLog); + + expect(result.status, result.stderr).toBe(0); + expect(readCallLog(callLog)).toBe("prebuilt-called\npost-update-called\n"); + }); + + it("skips the health probe when SELFHOST_SKIP_POST_UPDATE_CHECK=1", () => { + const { seedDir, checkoutDir, callLog } = createSandbox(); + advanceOrigin(seedDir, "advance for skip-check"); + + const result = run(checkoutDir, callLog, { SELFHOST_SKIP_POST_UPDATE_CHECK: "1" }); + + expect(result.status, result.stderr).toBe(0); + expect(readCallLog(callLog)).toBe("prebuilt-called\n"); + expect(result.stdout).toContain("skipping post-update health check"); + }); + + it("stops before the health check when the rebuild step fails", () => { + const { seedDir, checkoutDir, callLog } = createSandbox(); + advanceOrigin(seedDir, "advance for rebuild-fail"); + + const result = run(checkoutDir, callLog, { STUB_PREBUILT_EXIT: "1" }); + + expect(result.status).not.toBe(0); + expect(readCallLog(callLog)).toBe("prebuilt-called\n"); + }); + + it("refuses a dirty working tree and calls no script", () => { + const { seedDir, checkoutDir, callLog } = createSandbox(); + advanceOrigin(seedDir, "advance for dirty-tree"); + writeFileSync(join(checkoutDir, "README.md"), "local uncommitted edit\n"); + const beforeHead = headOf(checkoutDir); + + const result = run(checkoutDir, callLog); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("working tree is not clean"); + expect(readCallLog(callLog)).toBe(""); + expect(headOf(checkoutDir)).toBe(beforeHead); + }); + + it("refuses when the checkout is not on the expected branch and calls no script", () => { + const { seedDir, checkoutDir, callLog } = createSandbox(); + advanceOrigin(seedDir, "advance for wrong-branch"); + git(["checkout", "-q", "-b", "feature-x"], checkoutDir); + + const result = run(checkoutDir, callLog); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("currently on 'feature-x', expected 'main'"); + expect(readCallLog(callLog)).toBe(""); + }); + + it("accepts a non-default branch when SELFHOST_UPDATE_BRANCH names it explicitly", () => { + const { seedDir, checkoutDir, callLog } = createSandbox(); + git(["checkout", "-q", "-b", "release"], seedDir); + writeFileSync(join(seedDir, "README.md"), "release branch\n", { flag: "a" }); + git(["add", "-A"], seedDir); + git(["commit", "-q", "-m", "release commit"], seedDir); + git(["push", "-q", "origin", "release"], seedDir); + git(["fetch", "-q", "origin"], checkoutDir); + git(["checkout", "-q", "-b", "release", "origin/release"], checkoutDir); + + const result = run(checkoutDir, callLog, { SELFHOST_UPDATE_BRANCH: "release" }); + + expect(result.status, result.stderr).toBe(0); + expect(readCallLog(callLog)).toBe("prebuilt-called\npost-update-called\n"); + }); + + it("refuses a non-fast-forward divergence, calls no script, and leaves HEAD untouched", () => { + const { seedDir, checkoutDir, callLog } = createSandbox(); + advanceOrigin(seedDir, "advance for divergence"); + writeFileSync(join(checkoutDir, "local-only.txt"), "local\n"); + git(["add", "-A"], checkoutDir); + git(["commit", "-q", "-m", "local unpushed commit"], checkoutDir); + const beforeHead = headOf(checkoutDir); + + const result = run(checkoutDir, callLog); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("could not be fast-forwarded"); + expect(result.stderr).toContain("never rebases or force-merges"); + expect(readCallLog(callLog)).toBe(""); + expect(headOf(checkoutDir)).toBe(beforeHead); + }); + + it("supports a custom remote name via SELFHOST_UPDATE_REMOTE", () => { + const { seedDir, checkoutDir, callLog } = createSandbox(); + git(["remote", "rename", "origin", "upstream"], checkoutDir); + advanceOrigin(seedDir, "advance for custom remote"); + + const result = run(checkoutDir, callLog, { SELFHOST_UPDATE_REMOTE: "upstream" }); + + expect(result.status, result.stderr).toBe(0); + expect(readCallLog(callLog)).toBe("prebuilt-called\npost-update-called\n"); + expect(headOf(checkoutDir)).toBe(headOf(seedDir)); + }); + + it("fails fast outside a git checkout", () => { + const outside = mkdtempSync(join(tmpdir(), "gittensory-selfhost-update-nogit-")); + sandboxDirs.push(outside); + mkdirSync(join(outside, "scripts"), { recursive: true }); + writeFileSync(join(outside, "scripts", "selfhost-update.sh"), REAL_SCRIPT); + chmodSync(join(outside, "scripts", "selfhost-update.sh"), 0o755); + + const result = spawnSync("bash", [join(outside, "scripts", "selfhost-update.sh")], { + cwd: outside, + encoding: "utf8", + env: { ...process.env, ...GIT_ENV }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("run this script from the gittensory git checkout"); + }); +});