@openparachute/vault 0.7.5 → 0.7.6-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,8 @@ import {
18
18
  sameRemoteIdentity,
19
19
  claimedRemoteOf,
20
20
  findConflictingVault,
21
+ findUnrelatedRemoteHistory,
22
+ unrelatedHistoryMessage,
21
23
  } from "./mirror-remote-guard.ts";
22
24
  import { writeMirrorConfigForVault, defaultMirrorConfig } from "./mirror-config.ts";
23
25
  import { writeCredentials } from "./mirror-credentials.ts";
@@ -267,3 +269,160 @@ describe("findConflictingVault", () => {
267
269
  expect(findConflictingVault("b", " ")).toBeNull();
268
270
  });
269
271
  });
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // vault#823 — unrelated-history guard, against REAL git repos.
275
+ //
276
+ // The failure being guarded is deterministic and reproduces in plain git:
277
+ // give a remote some history, `git init` a fresh mirror beside it, bind, push
278
+ // → `! [rejected] (non-fast-forward)`, forever. These tests build exactly that
279
+ // on disk rather than mocking the object database, because the whole claim is
280
+ // about what git does with two roots.
281
+ // ---------------------------------------------------------------------------
282
+
283
+ function git(cwd: string, ...args: string[]): void {
284
+ const proc = Bun.spawnSync(["git", ...args], {
285
+ cwd,
286
+ stdout: "ignore",
287
+ stderr: "ignore",
288
+ env: {
289
+ ...process.env,
290
+ GIT_AUTHOR_NAME: "t",
291
+ GIT_AUTHOR_EMAIL: "t@example.com",
292
+ GIT_COMMITTER_NAME: "t",
293
+ GIT_COMMITTER_EMAIL: "t@example.com",
294
+ GIT_CONFIG_GLOBAL: "/dev/null",
295
+ GIT_CONFIG_SYSTEM: "/dev/null",
296
+ },
297
+ });
298
+ if (!proc.success) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
299
+ }
300
+
301
+ function headShasOf(repo: string): string[] {
302
+ const proc = Bun.spawnSync(["git", "ls-remote", repo], { stdout: "pipe", stderr: "ignore" });
303
+ return new TextDecoder()
304
+ .decode(proc.stdout)
305
+ .split("\n")
306
+ .map((l) => l.split("\t")[0])
307
+ .filter((s) => /^[0-9a-f]{40}$/.test(s));
308
+ }
309
+
310
+ describe("findUnrelatedRemoteHistory (vault#823)", () => {
311
+ test("fresh mirror + non-empty remote → flagged, and the real push really is rejected", async () => {
312
+ const root = tmp("pv-unrelated-");
313
+ const remote = path.join(root, "remote.git");
314
+ fs.mkdirSync(remote, { recursive: true });
315
+ git(remote, "init", "-q", "--bare");
316
+
317
+ // Seed the remote with history, the way a prior machine's mirror would.
318
+ const seed = path.join(root, "seed");
319
+ fs.mkdirSync(seed);
320
+ git(seed, "init", "-q", "-b", "main");
321
+ fs.writeFileSync(path.join(seed, "n1.md"), "note one");
322
+ git(seed, "add", "-A");
323
+ git(seed, "commit", "-qm", "vault export 1");
324
+ git(seed, "push", "-q", remote, "HEAD:main");
325
+
326
+ // The imported box: notes came across, the mirror is a fresh `git init`.
327
+ const mirror = path.join(root, "mirror");
328
+ fs.mkdirSync(mirror);
329
+ git(mirror, "init", "-q", "-b", "main");
330
+ fs.writeFileSync(path.join(mirror, "n1.md"), "note one");
331
+ git(mirror, "add", "-A");
332
+ git(mirror, "commit", "-qm", "vault mirror seed");
333
+
334
+ const heads = headShasOf(remote);
335
+ expect(heads.length).toBeGreaterThan(0);
336
+
337
+ const found = await findUnrelatedRemoteHistory({
338
+ mirrorPath: mirror,
339
+ remoteUrl: `https://github.com/a/b.git`,
340
+ remoteHeads: heads,
341
+ });
342
+ expect(found).not.toBeNull();
343
+ expect(found?.mirrorIsFresh).toBe(false); // the dir exists, it's just unrelated
344
+
345
+ // And the thing the guard is predicting actually happens.
346
+ const push = Bun.spawnSync(["git", "push", remote, "HEAD:main"], {
347
+ cwd: mirror,
348
+ stdout: "ignore",
349
+ stderr: "pipe",
350
+ });
351
+ expect(push.success).toBe(false);
352
+ expect(new TextDecoder().decode(push.stderr)).toContain("rejected");
353
+ });
354
+
355
+ test("mirror cloned FROM the remote → not flagged (re-bind after token rotation)", async () => {
356
+ const root = tmp("pv-related-");
357
+ const remote = path.join(root, "remote.git");
358
+ fs.mkdirSync(remote, { recursive: true });
359
+ git(remote, "init", "-q", "--bare");
360
+ const seed = path.join(root, "seed");
361
+ fs.mkdirSync(seed);
362
+ git(seed, "init", "-q", "-b", "main");
363
+ fs.writeFileSync(path.join(seed, "n1.md"), "note one");
364
+ git(seed, "add", "-A");
365
+ git(seed, "commit", "-qm", "vault export 1");
366
+ git(seed, "push", "-q", remote, "HEAD:main");
367
+
368
+ const mirror = path.join(root, "mirror");
369
+ git(root, "clone", "-q", remote, mirror);
370
+
371
+ const found = await findUnrelatedRemoteHistory({
372
+ mirrorPath: mirror,
373
+ remoteUrl: "https://github.com/a/b.git",
374
+ remoteHeads: headShasOf(remote),
375
+ });
376
+ expect(found).toBeNull();
377
+ });
378
+
379
+ test("empty remote → not flagged (the ordinary fresh-repo setup)", async () => {
380
+ const root = tmp("pv-emptyremote-");
381
+ const mirror = path.join(root, "mirror");
382
+ fs.mkdirSync(mirror, { recursive: true });
383
+ git(mirror, "init", "-q", "-b", "main");
384
+ const found = await findUnrelatedRemoteHistory({
385
+ mirrorPath: mirror,
386
+ remoteUrl: "https://github.com/a/b.git",
387
+ remoteHeads: [],
388
+ });
389
+ expect(found).toBeNull();
390
+ });
391
+
392
+ test("no mirror on disk yet + non-empty remote → flagged as fresh", async () => {
393
+ const root = tmp("pv-nomirror-");
394
+ const found = await findUnrelatedRemoteHistory({
395
+ mirrorPath: path.join(root, "does-not-exist"),
396
+ remoteUrl: "https://github.com/a/b.git",
397
+ remoteHeads: ["a".repeat(40)],
398
+ });
399
+ expect(found).not.toBeNull();
400
+ expect(found?.mirrorIsFresh).toBe(true);
401
+ });
402
+
403
+ test("null mirrorPath + non-empty remote → flagged as fresh", async () => {
404
+ const found = await findUnrelatedRemoteHistory({
405
+ mirrorPath: null,
406
+ remoteUrl: "https://github.com/a/b.git",
407
+ remoteHeads: ["b".repeat(40)],
408
+ });
409
+ expect(found?.mirrorIsFresh).toBe(true);
410
+ });
411
+
412
+ test("message names the repo, the cause, and never leaks a token", () => {
413
+ const msg = unrelatedHistoryMessage({
414
+ remoteIdentity: "github.com/aaron/my-vault",
415
+ mirrorIsFresh: true,
416
+ });
417
+ expect(msg).toContain("github.com/aaron/my-vault");
418
+ expect(msg).toContain("non-fast-forward");
419
+ expect(msg).toContain("override=true");
420
+
421
+ // Unparseable URL falls back to a userinfo-stripped form, not the raw one.
422
+ const leaky = unrelatedHistoryMessage({
423
+ remoteIdentity: "not a url",
424
+ mirrorIsFresh: false,
425
+ });
426
+ expect(leaky).not.toContain("@");
427
+ });
428
+ });
@@ -271,3 +271,127 @@ export function remoteConflictMessage(conflict: RemoteConflict): string {
271
271
  `If you're sure (e.g. you just moved the repo between vaults), pass override=true to proceed anyway.`
272
272
  );
273
273
  }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Unrelated-history guard (vault#823).
277
+ //
278
+ // Field report: a vault imported from a git repo, then armed Sync against that
279
+ // same repo, and every push for five days was refused as non-fast-forward.
280
+ // `mirror-import.ts` clones to a TEMP dir, imports notes into the store, and
281
+ // deletes the temp dir — it never touches the mirror dir. The mirror is then
282
+ // stood up fresh by `bootstrapInternalMirror` (`git init` + seed commit), so
283
+ // its root commit has no relationship to anything on the remote. Two histories,
284
+ // no common ancestor, and no push can ever land. It is deterministic, not a
285
+ // race: ANY import onto a non-empty remote produces a mirror that can never
286
+ // push.
287
+ //
288
+ // The cross-vault guard above answers "is someone else using this repo?". This
289
+ // one answers a different question about the SAME bind: "can my history reach
290
+ // theirs at all?" — and it is the one the import path needs, because on that
291
+ // path the remote is not a stranger's repo, it is the repo we just cloned FROM.
292
+ //
293
+ // Detection, without a fetch: ask the remote for its head shas (`ls-remote`,
294
+ // already run at the PAT bind point), then ask the mirror whether it HOLDS any
295
+ // of those objects (`git cat-file -e`). A mirror that was cloned from — or has
296
+ // ever pushed to — the remote holds them. A freshly-`git init`ed one does not.
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /** A detected unrelated-history bind — the mirror can never push to this remote. */
300
+ export interface UnrelatedHistory {
301
+ /** Normalized repo identity, or the raw URL when it can't be parsed. */
302
+ remoteIdentity: string;
303
+ /** True when the mirror dir doesn't exist / holds no commits yet. */
304
+ mirrorIsFresh: boolean;
305
+ }
306
+
307
+ /**
308
+ * Does `mirrorPath` hold at least one of `remoteHeads`?
309
+ *
310
+ * `git cat-file -e <sha>^{commit}` is a local object-database lookup — no
311
+ * network, no fetch, no working-tree touch. Fails closed to `false` (we do not
312
+ * hold it) on any spawn error, which routes into "unrelated" and therefore into
313
+ * a warning rather than a silent arm. That is the safe direction: the cost of a
314
+ * false warning is one confused operator; the cost of a false all-clear is five
315
+ * days with no backup.
316
+ */
317
+ async function mirrorHoldsAnyRemoteHead(
318
+ mirrorPath: string,
319
+ remoteHeads: string[],
320
+ spawnImpl: typeof Bun.spawn = Bun.spawn,
321
+ ): Promise<boolean> {
322
+ for (const sha of remoteHeads) {
323
+ if (!/^[0-9a-f]{7,64}$/.test(sha)) continue;
324
+ try {
325
+ const proc = spawnImpl(["git", "cat-file", "-e", `${sha}^{commit}`], {
326
+ cwd: mirrorPath,
327
+ stdout: "ignore",
328
+ stderr: "ignore",
329
+ });
330
+ if ((await proc.exited) === 0) return true;
331
+ } catch {
332
+ // git missing / path unreadable — treat as "not held" and keep going.
333
+ }
334
+ }
335
+ return false;
336
+ }
337
+
338
+ /**
339
+ * Detect a bind whose pushes can never land: a non-empty remote whose history
340
+ * the local mirror cannot reach.
341
+ *
342
+ * Returns null (bind is fine) when:
343
+ * - the remote is EMPTY — nothing to conflict with; the first push defines
344
+ * the history. This is the ordinary "fresh repo" setup and must not warn.
345
+ * - the mirror already holds one of the remote's heads — it was cloned from
346
+ * there, or has pushed there before. Re-binding after a token rotation
347
+ * lands here and must not warn.
348
+ *
349
+ * Returns a struct (bind will never push) when the remote has refs and the
350
+ * mirror holds none of them — including the case where the mirror does not
351
+ * exist yet, because what gets created will be a fresh `git init`.
352
+ */
353
+ export async function findUnrelatedRemoteHistory(opts: {
354
+ mirrorPath: string | null;
355
+ remoteUrl: string;
356
+ /** Head shas from `git ls-remote` — empty array means an empty remote. */
357
+ remoteHeads: string[];
358
+ /** Test seam. */
359
+ spawnImpl?: typeof Bun.spawn;
360
+ }): Promise<UnrelatedHistory | null> {
361
+ const { mirrorPath, remoteUrl, remoteHeads, spawnImpl } = opts;
362
+ // An empty remote can't conflict — the first push establishes the history.
363
+ if (remoteHeads.length === 0) return null;
364
+
365
+ const remoteIdentity = normalizeRemoteIdentity(remoteUrl) ?? redactUrlForMessage(remoteUrl);
366
+
367
+ // No mirror on disk yet → whatever gets bootstrapped will be a fresh root.
368
+ if (mirrorPath === null || !existsSync(join(mirrorPath, ".git"))) {
369
+ return { remoteIdentity, mirrorIsFresh: true };
370
+ }
371
+ if (await mirrorHoldsAnyRemoteHead(mirrorPath, remoteHeads, spawnImpl)) return null;
372
+ return { remoteIdentity, mirrorIsFresh: false };
373
+ }
374
+
375
+ /**
376
+ * Strip any userinfo from a remote URL so an unparseable one can still be
377
+ * named in an operator-facing message without leaking a token.
378
+ */
379
+ function redactUrlForMessage(remote: string): string {
380
+ return remote.replace(/\/\/[^@/]*@/, "//");
381
+ }
382
+
383
+ /**
384
+ * Operator-facing message for a refused arm. Says what is wrong, why it can
385
+ * never self-correct, and what the three real options are — deliberately
386
+ * concrete, because "non-fast-forward" is exactly the error an operator cannot
387
+ * act on without knowing the histories are unrelated.
388
+ */
389
+ export function unrelatedHistoryMessage(found: UnrelatedHistory): string {
390
+ return (
391
+ `${found.remoteIdentity} already has commits that this vault's backup history doesn't share, ` +
392
+ `so every push would be rejected (non-fast-forward) and it would never recover on its own. ` +
393
+ `Importing brings your notes across but not the old backup history, which is why they don't line up. ` +
394
+ `Either back up to a new empty repo, or replace the repo's contents with this vault's history on purpose. ` +
395
+ `If you know they should be joined, pass override=true to arm Sync anyway.`
396
+ );
397
+ }
@@ -22,6 +22,7 @@ import {
22
22
  type MirrorDeps,
23
23
  } from "./mirror-manager.ts";
24
24
  import {
25
+ enableSyncToImportedRepo,
25
26
  _resetDeviceFlowSessionsForTest,
26
27
  handleAuthDelete,
27
28
  handleAuthGet,
@@ -2310,6 +2311,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2310
2311
  spawnCloneSuccess(fixture),
2311
2312
  undefined,
2312
2313
  manager,
2314
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2315
+ async () => ({ ok: true, heads: [] }),
2313
2316
  );
2314
2317
  expect(res.status).toBe(200);
2315
2318
  const body = (await res.json()) as {
@@ -2355,6 +2358,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2355
2358
  spawnCloneSuccess(fixture),
2356
2359
  undefined,
2357
2360
  manager,
2361
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2362
+ async () => ({ ok: true, heads: [] }),
2358
2363
  );
2359
2364
  expect(res.status).toBe(200);
2360
2365
  const body = (await res.json()) as {
@@ -2391,6 +2396,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2391
2396
  spawnCloneSuccess(fixture),
2392
2397
  undefined,
2393
2398
  manager,
2399
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2400
+ async () => ({ ok: true, heads: [] }),
2394
2401
  );
2395
2402
  expect(res.status).toBe(200);
2396
2403
  const body = (await res.json()) as {
@@ -2435,6 +2442,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2435
2442
  spawnCloneSuccess(fixture),
2436
2443
  undefined,
2437
2444
  manager,
2445
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2446
+ async () => ({ ok: true, heads: [] }),
2438
2447
  );
2439
2448
  expect(res.status).toBe(200);
2440
2449
  const body = (await res.json()) as {
@@ -2470,6 +2479,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2470
2479
  spawnCloneSuccess(fixture),
2471
2480
  undefined,
2472
2481
  manager,
2482
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2483
+ async () => ({ ok: true, heads: [] }),
2473
2484
  );
2474
2485
  expect(res.status).toBe(200);
2475
2486
  const body = (await res.json()) as { sync_enabled: boolean };
@@ -2516,6 +2527,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2516
2527
  spawnCloneSuccess(fixture),
2517
2528
  undefined,
2518
2529
  manager,
2530
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2531
+ async () => ({ ok: true, heads: [] }),
2519
2532
  );
2520
2533
  expect(res.status).toBe(200);
2521
2534
  const body = (await res.json()) as {
@@ -2569,6 +2582,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2569
2582
  spawnCloneSuccess(fixture),
2570
2583
  undefined,
2571
2584
  manager,
2585
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2586
+ async () => ({ ok: true, heads: [] }),
2572
2587
  );
2573
2588
  expect(res.status).toBe(200);
2574
2589
  const body = (await res.json()) as {
@@ -2626,6 +2641,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2626
2641
  spawnCloneSuccess(fixture),
2627
2642
  undefined,
2628
2643
  manager,
2644
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2645
+ async () => ({ ok: true, heads: [] }),
2629
2646
  );
2630
2647
  expect(res.status).toBe(200);
2631
2648
  const body = (await res.json()) as { sync_enabled: boolean };
@@ -2673,6 +2690,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2673
2690
  spawnCloneSuccess(fixture),
2674
2691
  undefined,
2675
2692
  manager,
2693
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2694
+ async () => ({ ok: true, heads: [] }),
2676
2695
  );
2677
2696
  expect(res.status).toBe(200);
2678
2697
  const body = (await res.json()) as {
@@ -2722,6 +2741,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2722
2741
  spawnCloneSuccess(fixture),
2723
2742
  undefined,
2724
2743
  manager,
2744
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2745
+ async () => ({ ok: true, heads: [] }),
2725
2746
  );
2726
2747
  expect(res.status).toBe(200);
2727
2748
  const body = (await res.json()) as {
@@ -2766,6 +2787,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2766
2787
  spawnCloneSuccess(fixture),
2767
2788
  undefined,
2768
2789
  manager,
2790
+ // vault#823: hermetic probe — reachable, empty remote (no guard fire).
2791
+ async () => ({ ok: true, heads: [] }),
2769
2792
  );
2770
2793
  expect(res.status).toBe(200);
2771
2794
  const body = (await res.json()) as {
@@ -3165,3 +3188,127 @@ describe("cross-vault remote-clobber guard (vault#482)", () => {
3165
3188
  });
3166
3189
  });
3167
3190
 
3191
+ // ---------------------------------------------------------------------------
3192
+ // vault#823 — enableSyncToImportedRepo must consult the unrelated-history
3193
+ // guard, not just contain a call to it.
3194
+ //
3195
+ // The lesson from hub#820, applied before shipping rather than after: proving
3196
+ // `findUnrelatedRemoteHistory` returns a struct proves nothing about whether
3197
+ // the arm path reads it. Mutating `if (unrelated)` to `if (false && unrelated)`
3198
+ // left the whole vault suite green, which is how this test came to exist.
3199
+ // ---------------------------------------------------------------------------
3200
+
3201
+ describe("enableSyncToImportedRepo — unrelated-history guard (vault#823)", () => {
3202
+ /** Minimal manager stand-in: the guard branch only reads `mirror_path`. */
3203
+ function stubManager(mirrorPath: string | null): MirrorManager {
3204
+ return {
3205
+ getStatus: () => ({ mirror_path: mirrorPath }),
3206
+ } as unknown as MirrorManager;
3207
+ }
3208
+
3209
+ test("non-empty remote + fresh mirror → sync NOT armed, and the reason is stated", async () => {
3210
+ const res = await enableSyncToImportedRepo({
3211
+ vaultName: "solo",
3212
+ remoteUrl: "https://github.com/aaron/my-vault.git",
3213
+ auth: { kind: "none" },
3214
+ manager: stubManager(null), // nothing bootstrapped yet — the import case
3215
+ probeOverride: async () => ({ ok: true, heads: ["a".repeat(40)] }),
3216
+ });
3217
+ expect(res.sync_enabled).toBe(false);
3218
+ expect(res.warning).toContain("github.com/aaron/my-vault");
3219
+ expect(res.warning).toContain("non-fast-forward");
3220
+ // It must not be mistaken for the "no credential" refusal further down —
3221
+ // that one fires for auth.kind === "none" and would also return false.
3222
+ expect(res.warning).not.toContain("write credentials");
3223
+ });
3224
+
3225
+ test("empty remote → guard stays out of the way (falls through to the credential check)", async () => {
3226
+ const res = await enableSyncToImportedRepo({
3227
+ vaultName: "solo",
3228
+ remoteUrl: "https://github.com/aaron/fresh.git",
3229
+ auth: { kind: "none" },
3230
+ manager: stubManager(null),
3231
+ probeOverride: async () => ({ ok: true, heads: [] }),
3232
+ });
3233
+ expect(res.sync_enabled).toBe(false);
3234
+ // Reached the NEXT refusal, which proves the history guard declined to fire.
3235
+ expect(res.warning).toContain("write credentials");
3236
+ });
3237
+
3238
+ test("override=true skips the guard entirely", async () => {
3239
+ const res = await enableSyncToImportedRepo({
3240
+ vaultName: "solo",
3241
+ remoteUrl: "https://github.com/aaron/my-vault.git",
3242
+ auth: { kind: "none" },
3243
+ manager: stubManager(null),
3244
+ override: true,
3245
+ probeOverride: async () => ({ ok: true, heads: ["a".repeat(40)] }),
3246
+ });
3247
+ expect(res.warning).toContain("write credentials");
3248
+ expect(res.warning).not.toContain("non-fast-forward");
3249
+ });
3250
+
3251
+ test("unreachable remote fails OPEN — a network blip must not block setup", async () => {
3252
+ const res = await enableSyncToImportedRepo({
3253
+ vaultName: "solo",
3254
+ remoteUrl: "https://github.com/aaron/my-vault.git",
3255
+ auth: { kind: "none" },
3256
+ manager: stubManager(null),
3257
+ probeOverride: async () => ({ ok: false, error: "could not resolve host" }),
3258
+ });
3259
+ expect(res.warning).toContain("write credentials");
3260
+ expect(res.warning).not.toContain("non-fast-forward");
3261
+ });
3262
+
3263
+ test("a skipped check is SAID, not silent — the advisory rides on a successful arm", async () => {
3264
+ // Uni's catch on #646: this guard runs at BIND time and there may be no
3265
+ // next bind. An operator arms Sync once and walks away, so a probe blip
3266
+ // that silently skips the check reproduces the exact five-day silence the
3267
+ // guard exists to prevent. Failing open is still right; failing open
3268
+ // WITHOUT saying so is not.
3269
+ let armed = false;
3270
+ const manager = {
3271
+ getStatus: () => (armed ? { mirror_path: "/tmp/m", enabled: true } : { mirror_path: null }),
3272
+ getEffectiveConfig: () => ({ enabled: false, auto_push: false, location: "internal" }),
3273
+ reload: async () => {
3274
+ armed = true;
3275
+ },
3276
+ } as unknown as MirrorManager;
3277
+
3278
+ const res = await enableSyncToImportedRepo({
3279
+ vaultName: "solo",
3280
+ remoteUrl: "https://github.com/aaron/my-vault.git",
3281
+ auth: { kind: "pat", token: "ghp_x" },
3282
+ manager,
3283
+ probeOverride: async () => ({ ok: false, error: "could not resolve host" }),
3284
+ });
3285
+ expect(res.sync_enabled).toBe(true);
3286
+ expect(res.warning).toContain("couldn't reach the repo");
3287
+ // Points at a surface that works TODAY — the vault's own Git remote
3288
+ // section, which renders status.last_push_error. Naming the hub account
3289
+ // tile instead would make hub#820 a precondition for text a user reads.
3290
+ expect(res.warning).toContain("Git remote section");
3291
+ expect(res.warning).not.toContain("account page");
3292
+ });
3293
+
3294
+ test("a probe that succeeded adds no advisory", async () => {
3295
+ let armed = false;
3296
+ const manager = {
3297
+ getStatus: () => (armed ? { mirror_path: "/tmp/m", enabled: true } : { mirror_path: null }),
3298
+ getEffectiveConfig: () => ({ enabled: false, auto_push: false, location: "internal" }),
3299
+ reload: async () => {
3300
+ armed = true;
3301
+ },
3302
+ } as unknown as MirrorManager;
3303
+
3304
+ const res = await enableSyncToImportedRepo({
3305
+ vaultName: "solo",
3306
+ remoteUrl: "https://github.com/aaron/fresh.git",
3307
+ auth: { kind: "pat", token: "ghp_x" },
3308
+ manager,
3309
+ probeOverride: async () => ({ ok: true, heads: [] }),
3310
+ });
3311
+ expect(res.sync_enabled).toBe(true);
3312
+ expect(res.warning ?? "").not.toContain("couldn't reach");
3313
+ });
3314
+ });