fix(mirrors): sync would have silently destroyed local-only history #5494
3 changed files+268−0
Modifiedsrc/__tests__/autopilot-mirror-sync-failure.test.ts+9−0View fileUnifiedSplit
@@ -38,7 +38,16 @@ let nextResult: { total: number; ok: number; failed: number } = {
3838 failed: 0,
3939};
4040
41// Spread the real module rather than replacing it. `mock.module` swaps the
42// WHOLE module for every importer in the process, so a partial factory here
43// deleted `getMirrorForRepo`, `runMirrorSync`, `validateUpstreamUrl` and the
44// rest for any suite sharing the run — mirrors.test.ts then died with
45// "Export named 'getMirrorForRepo' not found". Both files are in CI (neither
46// is in ci-test-excludes.txt), so the combined result depended on file order.
47const actualMirrors = await import("../lib/mirrors");
48
4149mock.module("../lib/mirrors", () => ({
50 ...actualMirrors,
4251 syncAllDue: async () => nextResult,
4352}));
4453
Addedsrc/__tests__/mirror-destructive-sync-guard.test.ts+156−0View fileUnifiedSplit
@@ -0,0 +1,156 @@
1/**
2 * Mirror sync must never destroy local-only history.
3 *
4 * `runMirrorSync` fetches `+refs/heads/*:refs/heads/*` with `--prune` — a
5 * force overwrite of every local branch plus deletion of any branch upstream
6 * lacks. On a repo holding the only copy of some work, that is unrecoverable
7 * loss, on a timer, with no confirmation anywhere.
8 *
9 * Measured across the estate on 2026-08-12, before any mirror row existed:
10 * enabling a mirror on vapron would have destroyed 531 commits, davenroe 4,
11 * zoobicon 2. The `repo_mirrors` table being empty is the only reason it never
12 * fired — the guard did not exist, the trigger simply was never pulled.
13 *
14 * These tests build real git repos and run real git. The counting logic is
15 * the piece worth proving against git itself rather than a mock, because the
16 * whole guard rests on `rev-list --branches --not --glob=<staging>` meaning
17 * exactly what we think it means.
18 */
19
20import { describe, it, expect, beforeAll, afterAll } from "bun:test";
21import { mkdtempSync, rmSync } from "node:fs";
22import { tmpdir } from "node:os";
23import { join } from "node:path";
24
25import { countLocalOnlyCommits, __internal } from "../lib/mirrors";
26
27const NS = __internal.CHECK_NAMESPACE;
28
29let root = "";
30
31function git(cwd: string, ...args: string[]): string {
32 const p = Bun.spawnSync(["git", ...args], {
33 cwd,
34 stdout: "pipe",
35 stderr: "pipe",
36 env: {
37 ...process.env,
38 GIT_AUTHOR_NAME: "t",
39 GIT_AUTHOR_EMAIL: "t@t.t",
40 GIT_COMMITTER_NAME: "t",
41 GIT_COMMITTER_EMAIL: "t@t.t",
42 },
43 });
44 if (p.exitCode !== 0) {
45 throw new Error(
46 `git ${args.join(" ")} failed in ${cwd}: ${p.stderr.toString()}`
47 );
48 }
49 return p.stdout.toString().trim();
50}
51
52/** A repo with one commit on main, plus an "upstream" staged into NS. */
53function makeRepo(name: string): string {
54 const dir = join(root, name);
55 Bun.spawnSync(["mkdir", "-p", dir]);
56 git(root, "init", "-q", "-b", "main", name);
57 Bun.write(join(dir, "a.txt"), "one\n");
58 git(dir, "add", ".");
59 git(dir, "commit", "-q", "-m", "one");
60 return dir;
61}
62
63/** Stage the repo's own current branches into the check namespace. */
64function stageSelf(dir: string): void {
65 const sha = git(dir, "rev-parse", "main");
66 git(dir, "update-ref", `${NS}/main`, sha);
67}
68
69beforeAll(() => {
70 root = mkdtempSync(join(tmpdir(), "gluecron-mirror-guard-"));
71});
72
73afterAll(() => {
74 if (root) rmSync(root, { recursive: true, force: true });
75});
76
77describe("countLocalOnlyCommits", () => {
78 it("reports 0 when every local commit is present upstream", () => {
79 const dir = makeRepo("in-sync");
80 stageSelf(dir);
81 return countLocalOnlyCommits(dir).then((n) => {
82 expect(n).toBe(0);
83 });
84 });
85
86 it("counts commits made locally after the upstream snapshot", async () => {
87 const dir = makeRepo("ahead");
88 stageSelf(dir); // upstream is at "one"
89 Bun.write(join(dir, "b.txt"), "two\n");
90 git(dir, "add", ".");
91 git(dir, "commit", "-q", "-m", "two");
92 Bun.write(join(dir, "c.txt"), "three\n");
93 git(dir, "add", ".");
94 git(dir, "commit", "-q", "-m", "three");
95
96 // These two commits exist nowhere else. A mirror sync would erase them.
97 expect(await countLocalOnlyCommits(dir)).toBe(2);
98 });
99
100 it("counts commits on a side branch upstream does not have", async () => {
101 const dir = makeRepo("side-branch");
102 stageSelf(dir);
103 git(dir, "checkout", "-q", "-b", "feature");
104 Bun.write(join(dir, "f.txt"), "feat\n");
105 git(dir, "add", ".");
106 git(dir, "commit", "-q", "-m", "feat");
107
108 // --prune would DELETE this branch outright; the commit is unreachable
109 // afterwards. Counting must see it even though main is in sync.
110 expect(await countLocalOnlyCommits(dir)).toBe(1);
111 });
112
113 it("reports 0 for a local branch whose tip is already upstream", async () => {
114 // A branch that only DUPLICATES upstream history loses no commits when
115 // pruned, so it must not block the sync. The guard protects commits, not
116 // ref names — conflating the two would make every mirror permanently stuck.
117 const dir = makeRepo("dup-branch");
118 stageSelf(dir);
119 git(dir, "branch", "copy-of-main");
120
121 expect(await countLocalOnlyCommits(dir)).toBe(0);
122 });
123
124 it("refuses (non-zero) rather than returning 0 when git cannot be read", async () => {
125 // An unreadable repo must never be reported as "nothing to lose" — that
126 // is the exact shape of the mirror-sync bug fixed on 2026-08-11, where an
127 // error was rendered indistinguishable from success.
128 const n = await countLocalOnlyCommits(join(root, "does-not-exist"));
129 expect(n).toBeGreaterThan(0);
130 });
131});
132
133describe("runMirrorSync destructive-fetch guard (source contract)", () => {
134 const src = Bun.file(
135 join(import.meta.dir, "../lib/mirrors.ts")
136 ).text();
137
138 it("stages upstream outside refs/heads before the overwriting fetch", async () => {
139 const s = await src;
140 // Staging must target the scratch namespace, never refs/heads.
141 expect(s).toContain("+refs/heads/*:${CHECK_NAMESPACE}/*");
142 expect(s).toContain('const CHECK_NAMESPACE = "refs/gluecron-mirror-check"');
143 });
144
145 it("checks the count and throws before running the destructive fetch", async () => {
146 const s = await src;
147 const guardAt = s.indexOf("countLocalOnlyCommits(repoPath)");
148 const destructiveAt = s.indexOf('"+refs/heads/*:refs/heads/*"');
149 expect(guardAt).toBeGreaterThan(-1);
150 expect(destructiveAt).toBeGreaterThan(-1);
151 // Order matters more than presence: a guard that runs after the fetch is
152 // decoration.
153 expect(guardAt).toBeLessThan(destructiveAt);
154 expect(s).toContain("refusing to sync:");
155 });
156});
Modifiedsrc/lib/mirrors.ts+103−0View fileUnifiedSplit
@@ -24,6 +24,13 @@ import { assertPublicUrl } from "./ssrf-guard";
2424
2525const MIRROR_REMOTE_NAME = "gluecron-mirror";
2626
27/**
28 * Scratch ref namespace used to stage upstream before the destructive fetch.
29 * Deliberately outside `refs/heads/*` and `refs/remotes/*` so staging cannot
30 * change anything a user, a clone, or `--prune` can see.
31 */
32const CHECK_NAMESPACE = "refs/gluecron-mirror-check";
33
2734/** Mirror upstreams may use git:// in addition to http(s). */
2835const MIRROR_SCHEMES = ["http:", "https:", "git:"];
2936const FETCH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -257,6 +264,58 @@ export async function runMirrorSync(
257264 throw new Error(`remote add failed: ${addRes.stderr}`);
258265 }
259266
267 // PREFLIGHT — refuse to destroy local-only history.
268 //
269 // The real fetch below is `+refs/heads/*:refs/heads/*` with `--prune`:
270 // a FORCE overwrite of every local branch plus deletion of any branch
271 // upstream does not have. On a repo whose only copy of some work lives
272 // here, that is unrecoverable data loss, executed on a timer, with no
273 // confirmation step anywhere.
274 //
275 // Measured 2026-08-12 across the estate, this was not hypothetical:
276 // enabling a mirror on vapron would have destroyed 531 commits, davenroe
277 // 4, zoobicon 2. The mirror table happened to be empty, which is the only
278 // reason it never fired.
279 //
280 // So: stage upstream in a scratch namespace first (touches nothing
281 // reachable), and only proceed when every local commit is already present
282 // upstream. Divergence is a failure, not something to resolve by
283 // overwriting — a mirror is a copy, and a copy that deletes the original
284 // is not a copy. `runMirrorSync` records the failure and, since
285 // 2026-08-11, the autopilot task surfaces it instead of swallowing it.
286 // `--prune` on the staging fetch keeps the namespace exact, so stale refs
287 // from a previous run can never make this look cleaner than it is.
288 const stageRes = await runGit(
289 [
290 "git",
291 "fetch",
292 "--prune",
293 "--no-write-fetch-head",
294 MIRROR_REMOTE_NAME,
295 `+refs/heads/*:${CHECK_NAMESPACE}/*`,
296 ],
297 repoPath,
298 FETCH_TIMEOUT_MS
299 );
300 if (stageRes.exitCode !== 0) {
301 throw new Error(
302 stageRes.stderr.slice(0, 4000) || "git fetch (preflight) failed"
303 );
304 }
305
306 const localOnly = await countLocalOnlyCommits(repoPath);
307 if (localOnly > 0) {
308 const how = Number.isFinite(localOnly)
309 ? `${localOnly} commit(s) exist here but not upstream`
310 : `could not determine what is local-only here`;
311 throw new Error(
312 `refusing to sync: ${how} — this mirror fetches ` +
313 `+refs/heads/*:refs/heads/* with --prune, which would overwrite and ` +
314 `delete them. Push those commits upstream (or remove the branches ` +
315 `carrying them) and the next sync will proceed.`
316 );
317 }
318
260319 const fetchRes = await runGit(
261320 [
262321 "git",
@@ -370,6 +429,49 @@ async function runGit(
370429 }
371430}
372431
432/**
433 * How many commits are reachable from a local branch but from no staged
434 * upstream branch — i.e. how much history the destructive fetch would erase.
435 *
436 * Exported for the test suite, which builds real repos: this is the one piece
437 * of the guard whose correctness is worth proving against git itself rather
438 * than a mock. Counting commits (not branches) is deliberate — a local branch
439 * whose tip is already upstream is safe to have pruned, since nothing is lost;
440 * only unreachable COMMITS are unrecoverable.
441 *
442 * Returns 0 when the count cannot be determined AND there is nothing staged
443 * (a genuinely empty upstream is handled by the caller's exit-code check).
444 * Any other failure returns a positive sentinel so the caller refuses — an
445 * unreadable repo must never be read as "nothing to lose".
446 */
447export async function countLocalOnlyCommits(repoPath: string): Promise<number> {
448 const res = await runGit(
449 [
450 "git",
451 "rev-list",
452 "--count",
453 "--branches",
454 "--not",
455 `--glob=${CHECK_NAMESPACE}/*`,
456 ],
457 repoPath
458 );
459 if (res.exitCode !== 0) {
460 console.error(
461 `[mirrors] local-only commit count failed in ${repoPath} (exit ${res.exitCode}): ${res.stderr}`
462 );
463 return Number.POSITIVE_INFINITY;
464 }
465 const n = Number.parseInt(res.stdout.trim(), 10);
466 if (!Number.isFinite(n)) {
467 console.error(
468 `[mirrors] local-only commit count unparseable in ${repoPath}: ${JSON.stringify(res.stdout)}`
469 );
470 return Number.POSITIVE_INFINITY;
471 }
472 return n;
473}
474
373475/** Returns mirrors that are due for a sync (used by admin cron trigger). */
374476export async function listDueMirrors(
375477 now: Date = new Date()
@@ -443,4 +545,5 @@ void and;
443545export const __internal = {
444546 MIRROR_REMOTE_NAME,
445547 FETCH_TIMEOUT_MS,
548 CHECK_NAMESPACE,
446549};
447550
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts