agent-dealer 1.1.2 → 1.1.4

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.
Files changed (69) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +344 -11
  2. package/bundle/server/dist/adapters/git-worktree.test.js +716 -1
  3. package/bundle/server/dist/coordinator/admission.js +232 -9
  4. package/bundle/server/dist/coordinator/admission.test.js +227 -2
  5. package/bundle/server/dist/coordinator/agent-boundaries.js +88 -0
  6. package/bundle/server/dist/coordinator/auto-merge.js +10 -5
  7. package/bundle/server/dist/coordinator/commands.js +495 -34
  8. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +5 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +149 -7
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +397 -2
  11. package/bundle/server/dist/coordinator/execution-boundaries.test.js +493 -0
  12. package/bundle/server/dist/coordinator/execution-intervals.js +120 -0
  13. package/bundle/server/dist/coordinator/execution-intervals.test.js +109 -0
  14. package/bundle/server/dist/coordinator/external-merge-close.test.js +290 -0
  15. package/bundle/server/dist/coordinator/external-merge.js +67 -0
  16. package/bundle/server/dist/coordinator/failure-cause.js +478 -0
  17. package/bundle/server/dist/coordinator/failure-cause.test.js +287 -0
  18. package/bundle/server/dist/coordinator/failure-reason.js +2 -0
  19. package/bundle/server/dist/coordinator/failure-reason.test.js +21 -0
  20. package/bundle/server/dist/coordinator/human-resolution.js +38 -1
  21. package/bundle/server/dist/coordinator/latest-failure.js +51 -1
  22. package/bundle/server/dist/coordinator/latest-failure.test.js +55 -0
  23. package/bundle/server/dist/coordinator/merge-failure-retry.test.js +277 -0
  24. package/bundle/server/dist/coordinator/projection.js +9 -1
  25. package/bundle/server/dist/coordinator/push-with-lease.test.js +271 -0
  26. package/bundle/server/dist/coordinator/queue-history.js +324 -0
  27. package/bundle/server/dist/coordinator/queue-history.test.js +287 -0
  28. package/bundle/server/dist/coordinator/recovery.js +38 -1
  29. package/bundle/server/dist/coordinator/recovery.test.js +63 -0
  30. package/bundle/server/dist/coordinator/reviewer-effect.js +77 -2
  31. package/bundle/server/dist/coordinator/routing.js +23 -1
  32. package/bundle/server/dist/coordinator/routing.test.js +14 -0
  33. package/bundle/server/dist/coordinator/usage-cap-defer.js +39 -0
  34. package/bundle/server/dist/coordinator/worker-loop.js +2 -1
  35. package/bundle/server/dist/db/index.js +30 -0
  36. package/bundle/server/dist/db/schema.sql +28 -0
  37. package/bundle/server/dist/repository/admission-settings.js +86 -0
  38. package/bundle/server/dist/repository/admission-settings.test.js +78 -0
  39. package/bundle/server/dist/repository/failure-causes.js +104 -0
  40. package/bundle/server/dist/repository/human-actions.js +19 -0
  41. package/bundle/server/dist/repository/issues.js +19 -0
  42. package/bundle/server/dist/repository/queue-entries.js +86 -42
  43. package/bundle/server/dist/repository/workflow-events.js +8 -0
  44. package/bundle/server/dist/routes/execute-now.integration.test.js +305 -0
  45. package/bundle/server/dist/routes/issues.js +102 -19
  46. package/bundle/server/dist/routes/issues.test.js +159 -1
  47. package/bundle/server/dist/routes/queue.js +29 -2
  48. package/bundle/server/dist/routes/queue.test.js +65 -0
  49. package/bundle/server/package.json +2 -2
  50. package/bundle/server/static-ui/assets/index-Bva8TLlT.js +60 -0
  51. package/bundle/server/static-ui/assets/index-DDI4iqx0.css +1 -0
  52. package/bundle/server/static-ui/index.html +2 -2
  53. package/bundle/shared/dist/failure-cause.d.ts +67 -0
  54. package/bundle/shared/dist/failure-cause.js +67 -0
  55. package/bundle/shared/dist/index.d.ts +3 -2
  56. package/bundle/shared/dist/index.js +1 -0
  57. package/bundle/shared/dist/queue-entries.d.ts +67 -0
  58. package/bundle/shared/dist/queue-entries.js +25 -0
  59. package/bundle/shared/dist/workflow.d.ts +4 -4
  60. package/bundle/shared/dist/workflow.js +16 -0
  61. package/bundle/shared/dist/workflow.test.js +4 -0
  62. package/bundle/shared/package.json +1 -1
  63. package/dist/index.js +1 -0
  64. package/dist/issue.d.ts +3 -0
  65. package/dist/issue.js +15 -0
  66. package/dist/issue.test.js +44 -0
  67. package/package.json +1 -1
  68. package/bundle/server/static-ui/assets/index-CYZRXBZj.css +0 -1
  69. package/bundle/server/static-ui/assets/index-PU5ONp31.js +0 -60
@@ -11,12 +11,30 @@ import path from "node:path";
11
11
  // against findWorktreeForBranch/resolveDeveloperWorktree would fail.
12
12
  const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-home-")));
13
13
  process.env.AGENT_DEALER_HOME = home;
14
- const { createRoleWorktree, safeRemoveWorktree, inspectLeftoverWorktree, isWorktreeClean, withRepoLock, pushBranch, commitsAhead, findWorktreeForBranch, resolveDeveloperWorktree, worktreesRoot, WORKTREES_DIR_NAME, } = await import("./git-worktree.js");
14
+ const { branchExists, createRoleWorktree, safeRemoveWorktree, fetchFreshBase, inspectLeftoverWorktree, isWorktreeClean, withRepoLock, pushBranch, pushWithLease, commitsAhead, findWorktreeForBranch, resolveDeveloperWorktree, worktreesRoot, WORKTREES_DIR_NAME, } = await import("./git-worktree.js");
15
15
  let repo;
16
16
  let remote;
17
17
  function git(cwd, ...args) {
18
18
  return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
19
19
  }
20
+ /** `git` with extra env — used to pin commit dates apart so rebuilt/cherry-picked
21
+ * equivalents never collide with the original SHAs within the same second. */
22
+ function gitEnv(cwd, env, ...args) {
23
+ return execFileSync("git", args, { cwd, encoding: "utf8", env: { ...process.env, ...env } }).trim();
24
+ }
25
+ /** Fixed past identity dates: commits made "then" always differ in SHA from an
26
+ * equivalent patch committed "now", however fast the test runs. */
27
+ const PAST_DATES = {
28
+ GIT_AUTHOR_DATE: "2020-01-01T00:00:00Z",
29
+ GIT_COMMITTER_DATE: "2020-01-01T00:00:00Z",
30
+ };
31
+ function commitFileAs(cwd, name, content, message, env) {
32
+ fs.writeFileSync(path.join(cwd, name), content);
33
+ const run = env ? (args) => gitEnv(cwd, env, ...args) : (args) => git(cwd, ...args);
34
+ run(["add", "."]);
35
+ run(["commit", "-m", message]);
36
+ return git(cwd, "rev-parse", "HEAD");
37
+ }
20
38
  before(() => {
21
39
  repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-repo-"));
22
40
  git(repo, "init", "-b", "main");
@@ -277,6 +295,311 @@ test("NOT-137: pushBranch rejection when local is strictly behind reports behind
277
295
  await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
278
296
  execFileSync("git", ["branch", "-D", branch], { cwd: repo });
279
297
  });
298
+ test("NOT-220: diverged push with remote tip == last known head and patch-equivalent remote commits recovers via the lease pin", async () => {
299
+ const branch = "issue-push-lease";
300
+ const dev = await createRoleWorktree({
301
+ repo,
302
+ role: "developer",
303
+ sessionId: "s-dev-push-lease",
304
+ ref: "main",
305
+ newBranch: branch,
306
+ });
307
+ const commitFile = (cwd, name, content, message) => {
308
+ fs.writeFileSync(path.join(cwd, name), content);
309
+ git(cwd, "add", ".");
310
+ git(cwd, "commit", "-m", message);
311
+ return git(cwd, "rev-parse", "HEAD");
312
+ };
313
+ const base = commitFile(dev.path, "base.txt", "base\n", "base");
314
+ assert.deepEqual(await pushBranch({ worktreePath: dev.path, branch }), { ok: true });
315
+ // The reviewed remote head: two commits Dealer recorded as its last known head.
316
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-lease-other-"));
317
+ execFileSync("git", ["clone", "-q", remote, other]);
318
+ git(other, "checkout", branch);
319
+ git(other, "config", "user.email", "test@example.com");
320
+ git(other, "config", "user.name", "Test");
321
+ // Past dates: the local cherry-picks below (made "now") can never collide with
322
+ // these SHAs, however fast the test runs.
323
+ const r1 = commitFileAs(other, "r1.txt", "r1\n", "remote1", PAST_DATES);
324
+ const remoteSha = commitFileAs(other, "r2.txt", "r2\n", "remote2", PAST_DATES);
325
+ git(other, "push", "origin", branch);
326
+ // Local rebuild: the same patches (cherry-picked, so new SHAs) plus one new commit.
327
+ git(dev.path, "fetch", "origin", branch);
328
+ git(dev.path, "reset", "--hard", base);
329
+ git(dev.path, "cherry-pick", r1);
330
+ git(dev.path, "cherry-pick", remoteSha);
331
+ const localSha = commitFile(dev.path, "new.txt", "new\n", "new work");
332
+ assert.notEqual(localSha, remoteSha);
333
+ // Drop upstream tracking so the test proves the LEASE push sets it like the plain push.
334
+ git(dev.path, "branch", "--unset-upstream");
335
+ const result = await pushBranch({ worktreePath: dev.path, branch, lastKnownHeadSha: remoteSha });
336
+ assert.equal(result.ok, true);
337
+ if (result.ok) {
338
+ // Audit facts: the rewrite pins old -> new SHA.
339
+ assert.deepEqual(result.leasePush, { oldSha: remoteSha, newSha: localSha });
340
+ }
341
+ assert.equal(git(remote, "rev-parse", branch), localSha, "the lease push must publish the local tip");
342
+ assert.equal(git(dev.path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"), `origin/${branch}`, "the lease retry must set upstream tracking like the plain push");
343
+ fs.rmSync(dev.path, { recursive: true, force: true });
344
+ fs.rmSync(other, { recursive: true, force: true });
345
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
346
+ execFileSync("git", ["branch", "-D", branch], { cwd: repo });
347
+ });
348
+ test("NOT-220: remote tip past the last known head keeps today's escalation — no force push attempted", async () => {
349
+ const branch = "issue-push-lease-moved";
350
+ const dev = await createRoleWorktree({
351
+ repo,
352
+ role: "developer",
353
+ sessionId: "s-dev-push-lease-moved",
354
+ ref: "main",
355
+ newBranch: branch,
356
+ });
357
+ fs.writeFileSync(path.join(dev.path, "base.txt"), "base\n");
358
+ git(dev.path, "add", ".");
359
+ git(dev.path, "commit", "-m", "base");
360
+ const base = git(dev.path, "rev-parse", "HEAD");
361
+ assert.deepEqual(await pushBranch({ worktreePath: dev.path, branch }), { ok: true });
362
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-lease-moved-"));
363
+ execFileSync("git", ["clone", "-q", remote, other]);
364
+ git(other, "checkout", branch);
365
+ git(other, "config", "user.email", "test@example.com");
366
+ git(other, "config", "user.name", "Test");
367
+ fs.writeFileSync(path.join(other, "r1.txt"), "r1\n");
368
+ git(other, "add", ".");
369
+ git(other, "commit", "-m", "remote1");
370
+ git(other, "push", "origin", branch);
371
+ const knownHead = git(other, "rev-parse", "HEAD");
372
+ // Someone else pushes after Dealer's last known head.
373
+ fs.writeFileSync(path.join(other, "r2.txt"), "r2\n");
374
+ git(other, "add", ".");
375
+ git(other, "commit", "-m", "remote2");
376
+ git(other, "push", "origin", branch);
377
+ const movedSha = git(other, "rev-parse", "HEAD");
378
+ // Local diverges with genuinely different content.
379
+ git(dev.path, "fetch", "origin", branch);
380
+ git(dev.path, "reset", "--hard", base);
381
+ fs.writeFileSync(path.join(dev.path, "local.txt"), "local\n");
382
+ git(dev.path, "add", ".");
383
+ git(dev.path, "commit", "-m", "local");
384
+ const result = await pushBranch({ worktreePath: dev.path, branch, lastKnownHeadSha: knownHead });
385
+ assert.equal(result.ok, false);
386
+ if (!result.ok) {
387
+ assert.equal(result.rejected, true);
388
+ assert.ok(result.facts);
389
+ assert.equal(result.facts.relationship, "diverged");
390
+ assert.equal(result.facts.remoteSha, movedSha);
391
+ }
392
+ assert.equal(git(remote, "rev-parse", branch), movedSha, "no force push may run once the remote moved past the known head");
393
+ fs.rmSync(dev.path, { recursive: true, force: true });
394
+ fs.rmSync(other, { recursive: true, force: true });
395
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
396
+ execFileSync("git", ["branch", "-D", branch], { cwd: repo });
397
+ });
398
+ test("NOT-220: a remote-only commit with different content escalates — no force push attempted", async () => {
399
+ const branch = "issue-push-lease-content";
400
+ const dev = await createRoleWorktree({
401
+ repo,
402
+ role: "developer",
403
+ sessionId: "s-dev-push-lease-content",
404
+ ref: "main",
405
+ newBranch: branch,
406
+ });
407
+ fs.writeFileSync(path.join(dev.path, "base.txt"), "base\n");
408
+ git(dev.path, "add", ".");
409
+ git(dev.path, "commit", "-m", "base");
410
+ const base = git(dev.path, "rev-parse", "HEAD");
411
+ assert.deepEqual(await pushBranch({ worktreePath: dev.path, branch }), { ok: true });
412
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-lease-content-"));
413
+ execFileSync("git", ["clone", "-q", remote, other]);
414
+ git(other, "checkout", branch);
415
+ git(other, "config", "user.email", "test@example.com");
416
+ git(other, "config", "user.name", "Test");
417
+ fs.writeFileSync(path.join(other, "remote.txt"), "remote work\n");
418
+ git(other, "add", ".");
419
+ git(other, "commit", "-m", "remote-only");
420
+ git(other, "push", "origin", branch);
421
+ const remoteSha = git(other, "rev-parse", "HEAD");
422
+ // Local diverges with content that matches nothing on the remote.
423
+ git(dev.path, "fetch", "origin", branch);
424
+ git(dev.path, "reset", "--hard", base);
425
+ fs.writeFileSync(path.join(dev.path, "local.txt"), "local work\n");
426
+ git(dev.path, "add", ".");
427
+ git(dev.path, "commit", "-m", "local-only");
428
+ const result = await pushBranch({ worktreePath: dev.path, branch, lastKnownHeadSha: remoteSha });
429
+ assert.equal(result.ok, false);
430
+ if (!result.ok) {
431
+ assert.equal(result.rejected, true);
432
+ assert.ok(result.facts);
433
+ assert.equal(result.facts.relationship, "diverged");
434
+ assert.equal(result.facts.remoteSha, remoteSha);
435
+ }
436
+ assert.equal(git(remote, "rev-parse", branch), remoteSha, "a remote-only commit without a local equivalent must never be overwritten");
437
+ fs.rmSync(dev.path, { recursive: true, force: true });
438
+ fs.rmSync(other, { recursive: true, force: true });
439
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
440
+ execFileSync("git", ["branch", "-D", branch], { cwd: repo });
441
+ });
442
+ test("NOT-220: a lease pin for a SHA origin has left behind fails instead of overwriting — the race escalates", async () => {
443
+ const branch = "issue-push-lease-race";
444
+ const dev = await createRoleWorktree({
445
+ repo,
446
+ role: "developer",
447
+ sessionId: "s-dev-push-lease-race",
448
+ ref: "main",
449
+ newBranch: branch,
450
+ });
451
+ fs.writeFileSync(path.join(dev.path, "base.txt"), "base\n");
452
+ git(dev.path, "add", ".");
453
+ git(dev.path, "commit", "-m", "base");
454
+ const base = git(dev.path, "rev-parse", "HEAD");
455
+ assert.deepEqual(await pushBranch({ worktreePath: dev.path, branch }), { ok: true });
456
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-lease-race-"));
457
+ execFileSync("git", ["clone", "-q", remote, other]);
458
+ git(other, "checkout", branch);
459
+ git(other, "config", "user.email", "test@example.com");
460
+ git(other, "config", "user.name", "Test");
461
+ commitFileAs(other, "r1.txt", "r1\n", "remote1", PAST_DATES);
462
+ git(other, "push", "origin", branch);
463
+ const knownHead = git(other, "rev-parse", "HEAD");
464
+ // Local rebuild, patch-equivalent to the remote tip plus new work — eligible for now.
465
+ git(dev.path, "fetch", "origin", branch);
466
+ git(dev.path, "reset", "--hard", base);
467
+ git(dev.path, "cherry-pick", knownHead);
468
+ fs.writeFileSync(path.join(dev.path, "new.txt"), "new\n");
469
+ git(dev.path, "add", ".");
470
+ git(dev.path, "commit", "-m", "new work");
471
+ // Capture the fetched tip via a plain rejection (no known head, so no retry) …
472
+ const stale = await pushBranch({ worktreePath: dev.path, branch });
473
+ assert.equal(stale.ok, false);
474
+ if (!stale.ok)
475
+ assert.equal(stale.facts?.remoteSha, knownHead);
476
+ // … then origin moves before the lease push runs: the stale pin must fail.
477
+ fs.writeFileSync(path.join(other, "r2.txt"), "r2\n");
478
+ git(other, "add", ".");
479
+ git(other, "commit", "-m", "remote2");
480
+ git(other, "push", "origin", branch);
481
+ const movedSha = git(other, "rev-parse", "HEAD");
482
+ const raced = await pushWithLease({
483
+ cwd: dev.path,
484
+ branch,
485
+ localRef: "HEAD",
486
+ expectedRemoteSha: knownHead,
487
+ });
488
+ assert.equal(raced.ok, false, "a lease pinned to a superseded SHA must fail");
489
+ assert.equal(git(remote, "rev-parse", branch), movedSha, "the stale lease must not overwrite the new tip");
490
+ // And the full path now escalates against the fresh tip instead of force-pushing.
491
+ const escalated = await pushBranch({ worktreePath: dev.path, branch, lastKnownHeadSha: knownHead });
492
+ assert.equal(escalated.ok, false);
493
+ if (!escalated.ok) {
494
+ assert.equal(escalated.rejected, true);
495
+ assert.equal(escalated.facts?.remoteSha, movedSha);
496
+ }
497
+ assert.equal(git(remote, "rev-parse", branch), movedSha);
498
+ fs.rmSync(dev.path, { recursive: true, force: true });
499
+ fs.rmSync(other, { recursive: true, force: true });
500
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
501
+ execFileSync("git", ["branch", "-D", branch], { cwd: repo });
502
+ });
503
+ test("NOT-220: a remote-only merge commit is never lease-pushed even when cherry proves its side equivalent", async () => {
504
+ // `git cherry` never lists merge commits, so cherry alone would approve this range
505
+ // (the side commit is patch-equivalent locally) while saying nothing about the merge
506
+ // itself — a GitHub-style 'Update branch' merge must keep today's escalation.
507
+ const branch = "issue-push-lease-merge";
508
+ const dev = await createRoleWorktree({
509
+ repo,
510
+ role: "developer",
511
+ sessionId: "s-dev-push-lease-merge",
512
+ ref: "main",
513
+ newBranch: branch,
514
+ });
515
+ fs.writeFileSync(path.join(dev.path, "app.txt"), "v1\n");
516
+ git(dev.path, "add", ".");
517
+ git(dev.path, "commit", "-m", "base");
518
+ const base = git(dev.path, "rev-parse", "HEAD");
519
+ assert.deepEqual(await pushBranch({ worktreePath: dev.path, branch }), { ok: true });
520
+ // Local rebuilds the same change directly on the branch.
521
+ git(dev.path, "reset", "--hard", base);
522
+ fs.writeFileSync(path.join(dev.path, "app.txt"), "v1\npatched\n");
523
+ git(dev.path, "add", ".");
524
+ git(dev.path, "commit", "-m", "patch app");
525
+ // Remote carries the identical patch on a side branch, merged with --no-ff.
526
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-lease-merge-"));
527
+ execFileSync("git", ["clone", "-q", remote, other]);
528
+ git(other, "checkout", branch);
529
+ git(other, "config", "user.email", "test@example.com");
530
+ git(other, "config", "user.name", "Test");
531
+ git(other, "checkout", "-b", "side");
532
+ fs.writeFileSync(path.join(other, "app.txt"), "v1\npatched\n");
533
+ git(other, "add", ".");
534
+ git(other, "commit", "-m", "patch app on side");
535
+ git(other, "checkout", branch);
536
+ git(other, "merge", "--no-ff", "-m", "merge side", "side");
537
+ git(other, "push", "origin", branch);
538
+ const mergeSha = git(other, "rev-parse", "HEAD");
539
+ const result = await pushBranch({ worktreePath: dev.path, branch, lastKnownHeadSha: mergeSha });
540
+ assert.equal(result.ok, false);
541
+ if (!result.ok) {
542
+ assert.equal(result.rejected, true);
543
+ assert.ok(result.facts);
544
+ assert.equal(result.facts.relationship, "diverged");
545
+ assert.equal(result.facts.remoteSha, mergeSha);
546
+ }
547
+ assert.equal(git(remote, "rev-parse", branch), mergeSha, "a remote-only merge commit must never be lease-overwritten");
548
+ fs.rmSync(dev.path, { recursive: true, force: true });
549
+ fs.rmSync(other, { recursive: true, force: true });
550
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
551
+ execFileSync("git", ["branch", "-D", branch], { cwd: repo });
552
+ });
553
+ test("NOT-220: a remote-only range of pure merges (empty cherry output) proves nothing and escalates", async () => {
554
+ // Both sides merged the same upstream tip and got different merge SHAs: the only
555
+ // remote-only commit is the remote merge itself, so `git cherry` prints nothing —
556
+ // an empty output must not count as "all equivalent".
557
+ const branch = "issue-push-lease-merges";
558
+ const dev = await createRoleWorktree({
559
+ repo,
560
+ role: "developer",
561
+ sessionId: "s-dev-push-lease-merges",
562
+ ref: "main",
563
+ newBranch: branch,
564
+ });
565
+ fs.writeFileSync(path.join(dev.path, "base.txt"), "base\n");
566
+ git(dev.path, "add", ".");
567
+ git(dev.path, "commit", "-m", "base");
568
+ assert.deepEqual(await pushBranch({ worktreePath: dev.path, branch }), { ok: true });
569
+ // A shared upstream commit both sides will merge.
570
+ git(repo, "checkout", "-q", "main");
571
+ fs.writeFileSync(path.join(repo, "upstream.txt"), "upstream\n");
572
+ git(repo, "add", ".");
573
+ git(repo, "commit", "-m", "upstream");
574
+ git(repo, "push", "-q", "origin", "main");
575
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-lease-merges-"));
576
+ execFileSync("git", ["clone", "-q", remote, other]);
577
+ git(other, "checkout", branch);
578
+ git(other, "config", "user.email", "test@example.com");
579
+ git(other, "config", "user.name", "Test");
580
+ git(other, "fetch", "origin", "main");
581
+ // Past dates: the local merge below (made "now") must get a different merge SHA.
582
+ gitEnv(other, PAST_DATES, "merge", "--no-ff", "-m", "merge main", "origin/main");
583
+ git(other, "push", "origin", branch);
584
+ const mergeSha = git(other, "rev-parse", "HEAD");
585
+ // Local merges the same upstream tip — same content, different merge SHA.
586
+ git(dev.path, "fetch", "origin", "main");
587
+ git(dev.path, "fetch", "origin", branch);
588
+ git(dev.path, "merge", "--no-ff", "-m", "merge main", "origin/main");
589
+ const result = await pushBranch({ worktreePath: dev.path, branch, lastKnownHeadSha: mergeSha });
590
+ assert.equal(result.ok, false);
591
+ if (!result.ok) {
592
+ assert.equal(result.rejected, true);
593
+ assert.ok(result.facts);
594
+ assert.equal(result.facts.relationship, "diverged");
595
+ assert.equal(result.facts.remoteSha, mergeSha);
596
+ }
597
+ assert.equal(git(remote, "rev-parse", branch), mergeSha, "an unproven merge-only range must never be lease-overwritten");
598
+ fs.rmSync(dev.path, { recursive: true, force: true });
599
+ fs.rmSync(other, { recursive: true, force: true });
600
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
601
+ execFileSync("git", ["branch", "-D", branch], { cwd: repo });
602
+ });
280
603
  test("inspectLeftoverWorktree classifies missing / clean / dirty", async () => {
281
604
  assert.equal(await inspectLeftoverWorktree("/no/such/path"), "missing");
282
605
  const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dev4", ref: "issue-1" });
@@ -500,3 +823,395 @@ test("NOT-127: resolveDeveloperWorktree still escalates a dirty leftover when th
500
823
  fs.rmSync(leftover.path, { recursive: true, force: true });
501
824
  await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
502
825
  });
826
+ // ---------------------------------------------------------------- NOT-197: fresh base
827
+ /** An isolated repo + file remote pair, so tests can move or break origin without
828
+ * touching the module-level fixture every other test shares. */
829
+ function makeIsoRepoPair(tag) {
830
+ const isoRepo = fs.mkdtempSync(path.join(os.tmpdir(), `dealer-wt197-repo-${tag}-`));
831
+ git(isoRepo, "init", "-b", "main");
832
+ git(isoRepo, "config", "user.email", "test@example.com");
833
+ git(isoRepo, "config", "user.name", "Test");
834
+ fs.writeFileSync(path.join(isoRepo, "README.md"), "hello\n");
835
+ git(isoRepo, "add", ".");
836
+ git(isoRepo, "commit", "-m", "init");
837
+ const isoRemote = fs.mkdtempSync(path.join(os.tmpdir(), `dealer-wt197-remote-${tag}-`));
838
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", isoRemote]);
839
+ git(isoRepo, "remote", "add", "origin", isoRemote);
840
+ git(isoRepo, "push", "-q", "origin", "main");
841
+ return { isoRepo, isoRemote };
842
+ }
843
+ /** Advance origin/main from a separate clone so the repo's own local main stays stale. */
844
+ function advanceOriginMain(isoRemote, file) {
845
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt197-other-"));
846
+ try {
847
+ execFileSync("git", ["clone", "-q", isoRemote, other]);
848
+ git(other, "config", "user.email", "test@example.com");
849
+ git(other, "config", "user.name", "Test");
850
+ fs.writeFileSync(path.join(other, file), "upstream\n");
851
+ git(other, "add", ".");
852
+ git(other, "commit", "-q", "-m", "upstream change");
853
+ git(other, "push", "-q", "origin", "main");
854
+ return git(other, "rev-parse", "HEAD");
855
+ }
856
+ finally {
857
+ fs.rmSync(other, { recursive: true, force: true });
858
+ }
859
+ }
860
+ async function removeIsoWorktree(isoRepo, worktreePath, branch) {
861
+ fs.rmSync(worktreePath, { recursive: true, force: true });
862
+ await withRepoLock(isoRepo, async () => execFileSync("git", ["worktree", "prune"], { cwd: isoRepo }));
863
+ execFileSync("git", ["branch", "-D", branch], { cwd: isoRepo });
864
+ }
865
+ test("NOT-197: a fresh developer branch starts from the fetched origin/<base>, not the stale local base", async () => {
866
+ const { isoRepo, isoRemote } = makeIsoRepoPair("fresh");
867
+ try {
868
+ const staleLocalMain = git(isoRepo, "rev-parse", "main");
869
+ const originTip = advanceOriginMain(isoRemote, "upstream-change.txt");
870
+ assert.notEqual(staleLocalMain, originTip, "the local main must lag origin/main for this test to mean anything");
871
+ assert.equal(git(isoRepo, "rev-parse", "main"), staleLocalMain, "advancing origin must not move the local main");
872
+ const resolved = await resolveDeveloperWorktree({
873
+ repo: isoRepo,
874
+ sessionId: "s-fresh-base",
875
+ branchName: "issue-fresh-base",
876
+ baseBranch: "main",
877
+ reuseBranch: false,
878
+ });
879
+ assert.equal(resolved.kind, "created");
880
+ if (resolved.kind !== "created")
881
+ return;
882
+ assert.equal(resolved.baseSha, originTip, "the recorded base must be the fetched origin/main tip");
883
+ assert.equal(resolved.baseRef, "origin/main");
884
+ assert.equal(git(isoRepo, "rev-parse", "issue-fresh-base"), originTip, "the new branch must start exactly at the origin/main tip, not the stale local main");
885
+ assert.equal(git(isoRepo, "rev-parse", "main"), staleLocalMain, "the stale local base itself is left untouched");
886
+ await removeIsoWorktree(isoRepo, resolved.path, "issue-fresh-base");
887
+ }
888
+ finally {
889
+ fs.rmSync(isoRepo, { recursive: true, force: true });
890
+ fs.rmSync(isoRemote, { recursive: true, force: true });
891
+ }
892
+ });
893
+ test("NOT-197: a failed pre-branch fetch returns base_unavailable and creates no branch", async () => {
894
+ const { isoRepo, isoRemote } = makeIsoRepoPair("fail");
895
+ try {
896
+ git(isoRepo, "remote", "set-url", "origin", path.join(isoRemote, "does-not-exist.git"));
897
+ const resolved = await resolveDeveloperWorktree({
898
+ repo: isoRepo,
899
+ sessionId: "s-fetch-fail",
900
+ branchName: "issue-no-base",
901
+ baseBranch: "main",
902
+ reuseBranch: false,
903
+ });
904
+ assert.equal(resolved.kind, "base_unavailable");
905
+ if (resolved.kind === "base_unavailable")
906
+ assert.match(resolved.reason, /fetch/i);
907
+ assert.equal(await branchExists(isoRepo, "issue-no-base"), false, "a failed fetch must never fall back to cutting the branch from the stale local base");
908
+ }
909
+ finally {
910
+ fs.rmSync(isoRepo, { recursive: true, force: true });
911
+ fs.rmSync(isoRemote, { recursive: true, force: true });
912
+ }
913
+ });
914
+ test("NOT-219: the reuse path fetches origin/<branch> first — a failed fetch defers instead of checking out the stale local branch", async () => {
915
+ // Supersedes the NOT-197 expectation that reuse never fetches: starting a repair
916
+ // round from the stale local ref is exactly the false-divergence bug (NOT-219), so
917
+ // a failed fetch returns base_unavailable like the fresh-branch path does.
918
+ const { isoRepo, isoRemote } = makeIsoRepoPair("reuse");
919
+ try {
920
+ git(isoRepo, "branch", "issue-reuse-219");
921
+ // Break origin so thoroughly that any fetch attempt would fail.
922
+ git(isoRepo, "remote", "set-url", "origin", path.join(isoRemote, "does-not-exist.git"));
923
+ const broken = await fetchFreshBase(isoRepo, "main");
924
+ assert.equal(broken.ok, false, "sanity: a fetch against this origin really does fail");
925
+ const before = git(isoRepo, "rev-parse", "issue-reuse-219");
926
+ const leftover = await createRoleWorktree({ repo: isoRepo, role: "developer", sessionId: "s-reuse-left", ref: "issue-reuse-219" });
927
+ const resolved = await resolveDeveloperWorktree({
928
+ repo: isoRepo,
929
+ sessionId: "s-reuse-new",
930
+ branchName: "issue-reuse-219",
931
+ baseBranch: "main",
932
+ reuseBranch: true,
933
+ });
934
+ assert.equal(resolved.kind, "base_unavailable");
935
+ if (resolved.kind === "base_unavailable")
936
+ assert.match(resolved.reason, /fetch/i);
937
+ assert.equal(git(isoRepo, "rev-parse", "issue-reuse-219"), before, "a deferred start must not move the local branch");
938
+ assert.ok(fs.existsSync(leftover.path), "a deferred start must not touch the leftover worktree");
939
+ assert.ok(!fs.existsSync(path.join(worktreesRoot(isoRepo), "s-reuse-new-developer")), "no worktree may be created for a deferred start");
940
+ fs.rmSync(leftover.path, { recursive: true, force: true });
941
+ await withRepoLock(isoRepo, async () => execFileSync("git", ["worktree", "prune"], { cwd: isoRepo }));
942
+ execFileSync("git", ["branch", "-D", "issue-reuse-219"], { cwd: isoRepo });
943
+ }
944
+ finally {
945
+ fs.rmSync(isoRepo, { recursive: true, force: true });
946
+ fs.rmSync(isoRemote, { recursive: true, force: true });
947
+ }
948
+ });
949
+ test("NOT-197: fetchFreshBase bounds a hanging fetch with a timeout instead of hanging", async () => {
950
+ const { isoRepo, isoRemote } = makeIsoRepoPair("timeout");
951
+ try {
952
+ // An ssh remote whose "ssh" is a script that sleeps: the fetch can only end when our
953
+ // timeout kills it, so this deterministically exercises the timeout classification.
954
+ const sleeper = path.join(isoRepo, "fake-ssh.sh");
955
+ fs.writeFileSync(sleeper, "#!/bin/sh\nsleep 30\n");
956
+ fs.chmodSync(sleeper, 0o755);
957
+ git(isoRepo, "remote", "set-url", "origin", "ssh://git@example.invalid/repo.git");
958
+ git(isoRepo, "config", "core.sshCommand", sleeper);
959
+ const startedAt = Date.now();
960
+ const result = await fetchFreshBase(isoRepo, "main", 1000);
961
+ const elapsedMs = Date.now() - startedAt;
962
+ assert.equal(result.ok, false);
963
+ if (!result.ok)
964
+ assert.match(result.reason, /timed out/);
965
+ assert.ok(elapsedMs < 20_000, `the fetch must be bounded (took ${elapsedMs}ms)`);
966
+ }
967
+ finally {
968
+ fs.rmSync(isoRepo, { recursive: true, force: true });
969
+ fs.rmSync(isoRemote, { recursive: true, force: true });
970
+ }
971
+ });
972
+ // ---------------------------------------------------------------- NOT-219: repair from the pushed tip
973
+ /** Push commit(s) to origin/<branch> from a separate clone, leaving the repo's own
974
+ * local branch ref stale — the NOT-219 round-1 shape (Dealer pushed HEAD to
975
+ * origin/<branch> while the clone's local ref stayed at its creation point). */
976
+ function advanceOriginBranch(isoRemote, branch, files) {
977
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt219-other-"));
978
+ try {
979
+ execFileSync("git", ["clone", "-q", isoRemote, other]);
980
+ git(other, "config", "user.email", "test@example.com");
981
+ git(other, "config", "user.name", "Test");
982
+ git(other, "checkout", "-q", branch);
983
+ for (const file of files) {
984
+ fs.writeFileSync(path.join(other, file), "remote work\n");
985
+ }
986
+ git(other, "add", ".");
987
+ git(other, "commit", "-q", "-m", "round-1 work");
988
+ git(other, "push", "-q", "origin", branch);
989
+ return git(other, "rev-parse", "HEAD");
990
+ }
991
+ finally {
992
+ fs.rmSync(other, { recursive: true, force: true });
993
+ }
994
+ }
995
+ /** Commit on the repo's own local branch without pushing — a local-only tip. */
996
+ function commitLocally(isoRepo, branch, file) {
997
+ const wt = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt219-local-"));
998
+ try {
999
+ execFileSync("git", ["worktree", "add", "-q", wt, branch], { cwd: isoRepo });
1000
+ fs.writeFileSync(path.join(wt, file), "local work\n");
1001
+ git(wt, "add", ".");
1002
+ git(wt, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-q", "-m", "local work");
1003
+ return git(wt, "rev-parse", "HEAD");
1004
+ }
1005
+ finally {
1006
+ execFileSync("git", ["worktree", "remove", "--force", wt], { cwd: isoRepo });
1007
+ }
1008
+ }
1009
+ test("NOT-219: a repair round starts at the pushed origin/<branch> tip, not the stale local branch", async () => {
1010
+ const { isoRepo, isoRemote } = makeIsoRepoPair("219repair");
1011
+ try {
1012
+ const base = git(isoRepo, "rev-parse", "main");
1013
+ git(isoRepo, "branch", "issue-219-repair", base);
1014
+ git(isoRepo, "push", "-q", "origin", "issue-219-repair");
1015
+ // Round 1's commits land on the remote while the clone's local ref stays at base.
1016
+ const pushedTip = advanceOriginBranch(isoRemote, "issue-219-repair", ["round1-a.txt", "round1-b.txt"]);
1017
+ assert.equal(git(isoRepo, "rev-parse", "issue-219-repair"), base, "the local ref must stay stale for this test to mean anything");
1018
+ const resolved = await resolveDeveloperWorktree({
1019
+ repo: isoRepo,
1020
+ sessionId: "s-219-repair",
1021
+ branchName: "issue-219-repair",
1022
+ baseBranch: "main",
1023
+ reuseBranch: true,
1024
+ });
1025
+ assert.equal(resolved.kind, "created");
1026
+ if (resolved.kind !== "created")
1027
+ return;
1028
+ const head = git(resolved.path, "rev-parse", "HEAD");
1029
+ assert.equal(head, pushedTip, "the repair worktree must start at the pushed tip, not the stale base");
1030
+ assert.equal(git(resolved.path, "rev-list", "--count", "origin/issue-219-repair..HEAD"), "0");
1031
+ assert.equal(git(isoRepo, "rev-parse", "issue-219-repair"), pushedTip, "the stale local ref is advanced to the pushed tip");
1032
+ await removeIsoWorktree(isoRepo, resolved.path, "issue-219-repair");
1033
+ }
1034
+ finally {
1035
+ fs.rmSync(isoRepo, { recursive: true, force: true });
1036
+ fs.rmSync(isoRemote, { recursive: true, force: true });
1037
+ }
1038
+ });
1039
+ test("NOT-219: a repair round cuts the local branch at the pushed tip when the clone has no local ref for it", async () => {
1040
+ // The exact incident shape: round 1 committed on a side branch, so the local
1041
+ // issue branch was never even created — only origin/<branch> (what Dealer pushed)
1042
+ // knows the tip.
1043
+ const { isoRepo, isoRemote } = makeIsoRepoPair("219nolocal");
1044
+ try {
1045
+ const base = git(isoRepo, "rev-parse", "main");
1046
+ const seeder = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt219-seed-"));
1047
+ let pushedTip;
1048
+ try {
1049
+ execFileSync("git", ["clone", "-q", isoRemote, seeder]);
1050
+ git(seeder, "config", "user.email", "test@example.com");
1051
+ git(seeder, "config", "user.name", "Test");
1052
+ git(seeder, "checkout", "-q", "-b", "issue-219-nolocal", base);
1053
+ fs.writeFileSync(path.join(seeder, "round1.txt"), "round-1 work\n");
1054
+ git(seeder, "add", ".");
1055
+ git(seeder, "commit", "-q", "-m", "round-1 work");
1056
+ git(seeder, "push", "-q", "origin", "issue-219-nolocal");
1057
+ pushedTip = git(seeder, "rev-parse", "HEAD");
1058
+ }
1059
+ finally {
1060
+ fs.rmSync(seeder, { recursive: true, force: true });
1061
+ }
1062
+ await withRepoLock(isoRepo, async () => execFileSync("git", ["fetch", "-q", "origin"], { cwd: isoRepo }));
1063
+ assert.equal(await branchExists(isoRepo, "issue-219-nolocal"), false);
1064
+ const resolved = await resolveDeveloperWorktree({
1065
+ repo: isoRepo,
1066
+ sessionId: "s-219-nolocal",
1067
+ branchName: "issue-219-nolocal",
1068
+ baseBranch: "main",
1069
+ reuseBranch: true,
1070
+ });
1071
+ assert.equal(resolved.kind, "created");
1072
+ if (resolved.kind !== "created")
1073
+ return;
1074
+ assert.equal(git(resolved.path, "rev-parse", "HEAD"), pushedTip);
1075
+ assert.equal(git(resolved.path, "rev-list", "--count", "origin/issue-219-nolocal..HEAD"), "0");
1076
+ assert.equal(git(isoRepo, "rev-parse", "issue-219-nolocal"), pushedTip);
1077
+ await removeIsoWorktree(isoRepo, resolved.path, "issue-219-nolocal");
1078
+ }
1079
+ finally {
1080
+ fs.rmSync(isoRepo, { recursive: true, force: true });
1081
+ fs.rmSync(isoRemote, { recursive: true, force: true });
1082
+ }
1083
+ });
1084
+ test("NOT-219: local commits the remote lacks are preserved, never reset away", async () => {
1085
+ const { isoRepo, isoRemote } = makeIsoRepoPair("219diverged");
1086
+ try {
1087
+ const base = git(isoRepo, "rev-parse", "main");
1088
+ git(isoRepo, "branch", "issue-219-diverged", base);
1089
+ git(isoRepo, "push", "-q", "origin", "issue-219-diverged");
1090
+ // Diverge: the remote advances (round-1 push) AND the local ref carries a commit
1091
+ // the remote lacks.
1092
+ const remoteTip = advanceOriginBranch(isoRemote, "issue-219-diverged", ["remote-only.txt"]);
1093
+ const localTip = commitLocally(isoRepo, "issue-219-diverged", "local-only.txt");
1094
+ assert.notEqual(localTip, remoteTip);
1095
+ const resolved = await resolveDeveloperWorktree({
1096
+ repo: isoRepo,
1097
+ sessionId: "s-219-diverged",
1098
+ branchName: "issue-219-diverged",
1099
+ baseBranch: "main",
1100
+ reuseBranch: true,
1101
+ });
1102
+ assert.equal(resolved.kind, "created");
1103
+ if (resolved.kind !== "created")
1104
+ return;
1105
+ assert.equal(git(resolved.path, "rev-parse", "HEAD"), localTip, "the worktree must keep the local tip — its unique commits are never discarded");
1106
+ assert.equal(git(isoRepo, "rev-parse", "issue-219-diverged"), localTip, "the local ref itself is untouched");
1107
+ await removeIsoWorktree(isoRepo, resolved.path, "issue-219-diverged");
1108
+ }
1109
+ finally {
1110
+ fs.rmSync(isoRepo, { recursive: true, force: true });
1111
+ fs.rmSync(isoRemote, { recursive: true, force: true });
1112
+ }
1113
+ });
1114
+ test("NOT-219: reuse with no remote branch yet checks out the local branch without deferring", async () => {
1115
+ // A same-round retry whose branch was never pushed: `git fetch origin <branch>`
1116
+ // reports "couldn't find remote ref" — that is not a network failure, so the local
1117
+ // branch is used as-is instead of deferring.
1118
+ const { isoRepo, isoRemote } = makeIsoRepoPair("219unpushed");
1119
+ try {
1120
+ git(isoRepo, "branch", "issue-219-unpushed");
1121
+ const localTip = commitLocally(isoRepo, "issue-219-unpushed", "retry-work.txt");
1122
+ const resolved = await resolveDeveloperWorktree({
1123
+ repo: isoRepo,
1124
+ sessionId: "s-219-unpushed",
1125
+ branchName: "issue-219-unpushed",
1126
+ baseBranch: "main",
1127
+ reuseBranch: true,
1128
+ });
1129
+ assert.equal(resolved.kind, "created");
1130
+ if (resolved.kind !== "created")
1131
+ return;
1132
+ assert.equal(git(resolved.path, "rev-parse", "HEAD"), localTip);
1133
+ await removeIsoWorktree(isoRepo, resolved.path, "issue-219-unpushed");
1134
+ }
1135
+ finally {
1136
+ fs.rmSync(isoRepo, { recursive: true, force: true });
1137
+ fs.rmSync(isoRemote, { recursive: true, force: true });
1138
+ }
1139
+ });
1140
+ test("NOT-219: a clean leftover holding the stale local branch is reused at the pushed tip", async () => {
1141
+ const { isoRepo, isoRemote } = makeIsoRepoPair("219leftover");
1142
+ try {
1143
+ const base = git(isoRepo, "rev-parse", "main");
1144
+ git(isoRepo, "branch", "issue-219-leftover", base);
1145
+ git(isoRepo, "push", "-q", "origin", "issue-219-leftover");
1146
+ const leftover = await createRoleWorktree({ repo: isoRepo, role: "developer", sessionId: "s-219-left", ref: "issue-219-leftover" });
1147
+ const pushedTip = advanceOriginBranch(isoRemote, "issue-219-leftover", ["round1.txt"]);
1148
+ assert.equal(git(leftover.path, "rev-parse", "HEAD"), base);
1149
+ const resolved = await resolveDeveloperWorktree({
1150
+ repo: isoRepo,
1151
+ sessionId: "s-219-left-new",
1152
+ branchName: "issue-219-leftover",
1153
+ baseBranch: "main",
1154
+ reuseBranch: true,
1155
+ });
1156
+ assert.equal(resolved.kind, "reused");
1157
+ if (resolved.kind !== "reused")
1158
+ return;
1159
+ assert.equal(resolved.path, leftover.path);
1160
+ assert.equal(git(leftover.path, "rev-parse", "HEAD"), pushedTip, "the reused leftover advances to the pushed tip");
1161
+ fs.rmSync(leftover.path, { recursive: true, force: true });
1162
+ await withRepoLock(isoRepo, async () => execFileSync("git", ["worktree", "prune"], { cwd: isoRepo }));
1163
+ execFileSync("git", ["branch", "-D", "issue-219-leftover"], { cwd: isoRepo });
1164
+ }
1165
+ finally {
1166
+ fs.rmSync(isoRepo, { recursive: true, force: true });
1167
+ fs.rmSync(isoRemote, { recursive: true, force: true });
1168
+ }
1169
+ });
1170
+ test("NOT-219: fastForwardLocalBranchToSha advances a stale ref, creates a missing one, and never discards local-only commits", async () => {
1171
+ const { fastForwardLocalBranchToSha } = await import("./git-worktree.js");
1172
+ const { isoRepo, isoRemote } = makeIsoRepoPair("219ff");
1173
+ try {
1174
+ const base = git(isoRepo, "rev-parse", "main");
1175
+ git(isoRepo, "branch", "issue-219-ff", base);
1176
+ git(isoRepo, "push", "-q", "origin", "issue-219-ff");
1177
+ const remoteTip = advanceOriginBranch(isoRemote, "issue-219-ff", ["remote.txt"]);
1178
+ // The helper classifies ancestry locally, so the pushed objects must be present
1179
+ // (in production the reuse-path fetch / the pushing worktree guarantees this).
1180
+ git(isoRepo, "fetch", "-q", "origin", "issue-219-ff");
1181
+ assert.equal(await fastForwardLocalBranchToSha({ repo: isoRepo, branch: "issue-219-ff", sha: remoteTip }), true);
1182
+ assert.equal(git(isoRepo, "rev-parse", "issue-219-ff"), remoteTip);
1183
+ // Missing local ref is created at the pushed SHA.
1184
+ execFileSync("git", ["branch", "-D", "issue-219-ff"], { cwd: isoRepo });
1185
+ assert.equal(await fastForwardLocalBranchToSha({ repo: isoRepo, branch: "issue-219-ff", sha: remoteTip }), true);
1186
+ assert.equal(git(isoRepo, "rev-parse", "issue-219-ff"), remoteTip);
1187
+ // Diverged: the local ref carries a commit the pushed SHA lacks — preserved.
1188
+ const localTip = commitLocally(isoRepo, "issue-219-ff", "local.txt");
1189
+ assert.equal(await fastForwardLocalBranchToSha({ repo: isoRepo, branch: "issue-219-ff", sha: remoteTip }), false);
1190
+ assert.equal(git(isoRepo, "rev-parse", "issue-219-ff"), localTip);
1191
+ execFileSync("git", ["branch", "-D", "issue-219-ff"], { cwd: isoRepo });
1192
+ }
1193
+ finally {
1194
+ fs.rmSync(isoRepo, { recursive: true, force: true });
1195
+ fs.rmSync(isoRemote, { recursive: true, force: true });
1196
+ }
1197
+ });
1198
+ test("NOT-197: fetchFreshBase uses the local base as-is when the repo has no origin remote", async () => {
1199
+ const bare = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt197-noorigin-"));
1200
+ try {
1201
+ git(bare, "init", "-b", "main");
1202
+ git(bare, "config", "user.email", "test@example.com");
1203
+ git(bare, "config", "user.name", "Test");
1204
+ fs.writeFileSync(path.join(bare, "README.md"), "hello\n");
1205
+ git(bare, "add", ".");
1206
+ git(bare, "commit", "-m", "init");
1207
+ const result = await fetchFreshBase(bare, "main");
1208
+ assert.equal(result.ok, true);
1209
+ if (result.ok) {
1210
+ assert.equal(result.ref, "main");
1211
+ assert.equal(result.sha, git(bare, "rev-parse", "main"));
1212
+ }
1213
+ }
1214
+ finally {
1215
+ fs.rmSync(bare, { recursive: true, force: true });
1216
+ }
1217
+ });