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.
+
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");
+ });
+});