@remixhq/core 0.1.10 → 0.1.12

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.
package/dist/collab.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  getCollabBindingPath,
3
3
  readCollabBinding,
4
+ readCollabBindingState,
4
5
  reserveAvailableDirPath,
5
6
  reserveDirectory,
6
- writeCollabBinding
7
- } from "./chunk-GEHSFPCD.js";
7
+ writeCollabBinding,
8
+ writeCollabBindingSnapshot
9
+ } from "./chunk-IXWQWFYT.js";
10
+ import "./chunk-HZNEDSRS.js";
8
11
  import {
9
12
  assertRepoSnapshotUnchanged,
10
13
  buildRepoFingerprint,
@@ -38,7 +41,7 @@ import {
38
41
  summarizeUnifiedDiff,
39
42
  validateUnifiedDiff,
40
43
  writeTempUnifiedDiffBackup
41
- } from "./chunk-J3J4PBQ7.js";
44
+ } from "./chunk-RREREIGW.js";
42
45
  import {
43
46
  REMIX_ERROR_CODES
44
47
  } from "./chunk-GC2MOT3U.js";
@@ -55,29 +58,29 @@ function describeBranch(value) {
55
58
  const normalized = String(value ?? "").trim();
56
59
  return normalized || "(detached)";
57
60
  }
58
- function isPreferredBranchMatch(currentBranch, preferredBranch) {
61
+ function isBoundBranchMatch(currentBranch, branchName) {
59
62
  const current = String(currentBranch ?? "").trim();
60
- const preferred = String(preferredBranch ?? "").trim();
61
- if (!preferred || !current) return true;
62
- return current === preferred;
63
+ const expected = String(branchName ?? "").trim();
64
+ if (!expected || !current) return true;
65
+ return current === expected;
63
66
  }
64
- function buildPreferredBranchMismatchHint(params) {
67
+ function buildBranchMismatchHint(params) {
65
68
  const overrideFlag = params.overrideFlag?.trim() || "--allow-branch-mismatch";
66
69
  return [
67
70
  `Current branch: ${describeBranch(params.currentBranch)}`,
68
- `Preferred branch: ${describeBranch(params.preferredBranch)}`,
69
- `Switch to ${describeBranch(params.preferredBranch)} or rerun with ${overrideFlag} if this is intentional.`
71
+ `Bound branch: ${describeBranch(params.branchName)}`,
72
+ `Switch to ${describeBranch(params.branchName)} or rerun with ${overrideFlag} if this is intentional.`
70
73
  ].join("\n");
71
74
  }
72
- function assertPreferredBranchMatch(params) {
75
+ function assertBoundBranchMatch(params) {
73
76
  if (params.allowBranchMismatch) return;
74
- if (isPreferredBranchMatch(params.currentBranch, params.preferredBranch)) return;
75
- throw new RemixError(`Current branch does not match this checkout's Remix preferred branch while running ${params.operation}.`, {
77
+ if (isBoundBranchMatch(params.currentBranch, params.branchName)) return;
78
+ throw new RemixError(`Current branch does not match this checkout's bound Remix branch while running ${params.operation}.`, {
76
79
  code: REMIX_ERROR_CODES.PREFERRED_BRANCH_MISMATCH,
77
80
  exitCode: 2,
78
- hint: buildPreferredBranchMismatchHint({
81
+ hint: buildBranchMismatchHint({
79
82
  currentBranch: params.currentBranch,
80
- preferredBranch: params.preferredBranch,
83
+ branchName: params.branchName,
81
84
  overrideFlag: params.overrideFlag
82
85
  })
83
86
  });
@@ -141,6 +144,52 @@ function sanitizeCheckoutDirName(value) {
141
144
  function buildDashboardAppUrl(appId) {
142
145
  return `https://dashboard.remix.one/apps/${encodeURIComponent(appId)}`;
143
146
  }
147
+ async function resolveProjectLaneIfAuthoritative(api, params) {
148
+ const branchName = String(params.branchName ?? "").trim();
149
+ if (!branchName) return null;
150
+ if (!params.projectId && !params.repoFingerprint && !params.remoteUrl) return null;
151
+ const readLane = async () => {
152
+ const laneResp = await api.resolveProjectLaneBinding({
153
+ projectId: params.projectId ?? void 0,
154
+ repoFingerprint: params.repoFingerprint ?? void 0,
155
+ remoteUrl: params.remoteUrl ?? void 0,
156
+ defaultBranch: params.defaultBranch ?? void 0,
157
+ branchName
158
+ });
159
+ return unwrapResponseObject(laneResp, "project lane binding");
160
+ };
161
+ let lane = await readLane();
162
+ const shouldRepairResolvedLane = lane.status === "resolved" && Boolean(
163
+ params.expectedUpstreamAppId && (!lane.upstreamAppId || lane.upstreamAppId !== params.expectedUpstreamAppId)
164
+ );
165
+ if (shouldRepairResolvedLane && params.createIfMissing && params.seedAppId) {
166
+ const ensuredResp = await api.ensureProjectLaneBinding({
167
+ projectId: params.projectId ?? void 0,
168
+ repoFingerprint: params.repoFingerprint ?? void 0,
169
+ remoteUrl: params.remoteUrl ?? void 0,
170
+ defaultBranch: params.defaultBranch ?? void 0,
171
+ branchName,
172
+ seedAppId: params.seedAppId
173
+ });
174
+ lane = unwrapResponseObject(ensuredResp, "project lane binding");
175
+ }
176
+ if (lane.status !== "resolved" && params.createIfMissing && params.seedAppId) {
177
+ const ensuredResp = await api.ensureProjectLaneBinding({
178
+ projectId: params.projectId ?? void 0,
179
+ repoFingerprint: params.repoFingerprint ?? void 0,
180
+ remoteUrl: params.remoteUrl ?? void 0,
181
+ defaultBranch: params.defaultBranch ?? void 0,
182
+ branchName,
183
+ seedAppId: params.seedAppId
184
+ });
185
+ lane = unwrapResponseObject(ensuredResp, "project lane binding");
186
+ }
187
+ if (lane.status !== "resolved") return null;
188
+ if (params.projectId && lane.projectId && lane.projectId !== params.projectId) return null;
189
+ if (params.currentAppId && lane.currentAppId && lane.currentAppId !== params.currentAppId) return null;
190
+ if (params.expectedUpstreamAppId && lane.upstreamAppId && lane.upstreamAppId !== params.expectedUpstreamAppId) return null;
191
+ return lane;
192
+ }
144
193
  async function pollAppReady(api, appId) {
145
194
  const started = Date.now();
146
195
  let delay = 2e3;
@@ -285,6 +334,239 @@ async function pollMergeRequestCompletion(api, mrId, params) {
285
334
  throw new RemixError("Timed out waiting for merge approval to complete.", { exitCode: 1 });
286
335
  }
287
336
 
337
+ // src/application/collab/laneBindingResolver.ts
338
+ function normalizeBranchName(value) {
339
+ const normalized = String(value ?? "").trim();
340
+ return normalized || null;
341
+ }
342
+ function buildBindingFromLane(state, lane) {
343
+ if (!lane.currentAppId || !lane.upstreamAppId) return null;
344
+ const resolvedBranch = normalizeBranchName(lane.branchName) ?? state.currentBranch ?? null;
345
+ const resolvedDefaultBranch = normalizeBranchName(lane.defaultBranch) ?? normalizeBranchName(state.defaultBranch);
346
+ const explicitRootProjectId = state.explicitRootBinding?.projectId ?? null;
347
+ const bindingMode = explicitRootProjectId && lane.projectId === explicitRootProjectId && resolvedBranch && resolvedBranch === resolvedDefaultBranch ? "explicit_root" : "lane";
348
+ return {
349
+ schemaVersion: 3,
350
+ projectId: lane.projectId ?? state.projectId,
351
+ currentAppId: lane.currentAppId,
352
+ upstreamAppId: lane.upstreamAppId,
353
+ threadId: lane.threadId ?? null,
354
+ repoFingerprint: lane.repoFingerprint ?? state.repoFingerprint ?? null,
355
+ remoteUrl: lane.remoteUrl ?? state.remoteUrl ?? null,
356
+ defaultBranch: lane.defaultBranch ?? state.defaultBranch ?? null,
357
+ laneId: lane.laneId ?? null,
358
+ branchName: resolvedBranch,
359
+ bindingMode
360
+ };
361
+ }
362
+ function shouldPersistRemoteLaneMetadata(localBinding, lane) {
363
+ return Boolean(
364
+ !localBinding.laneId && lane.laneId || !localBinding.threadId && lane.threadId || !localBinding.repoFingerprint && lane.repoFingerprint || !localBinding.remoteUrl && lane.remoteUrl || !localBinding.defaultBranch && lane.defaultBranch || !localBinding.branchName && lane.branchName
365
+ );
366
+ }
367
+ function shouldRequireRemoteLaneForCurrentBranch(params) {
368
+ if (!params.currentBranch) return false;
369
+ const defaultBranch = normalizeBranchName(params.defaultBranch);
370
+ if (params.currentBranch === defaultBranch) return false;
371
+ return !params.binding.laneId || params.binding.currentAppId === params.binding.upstreamAppId;
372
+ }
373
+ async function persistResolvedLane(repoRoot, binding) {
374
+ await writeCollabBinding(repoRoot, {
375
+ projectId: binding.projectId,
376
+ currentAppId: binding.currentAppId,
377
+ upstreamAppId: binding.upstreamAppId,
378
+ threadId: binding.threadId,
379
+ repoFingerprint: binding.repoFingerprint,
380
+ remoteUrl: binding.remoteUrl,
381
+ defaultBranch: binding.defaultBranch,
382
+ laneId: binding.laneId,
383
+ branchName: binding.branchName,
384
+ bindingMode: binding.bindingMode
385
+ });
386
+ return readCollabBinding(repoRoot);
387
+ }
388
+ function buildAmbiguousResolution(params) {
389
+ return {
390
+ status: "ambiguous_family_selection",
391
+ currentBranch: params.currentBranch,
392
+ projectIds: Array.isArray(params.lane.projectIds) ? params.lane.projectIds.filter((value) => typeof value === "string" && value.trim().length > 0) : [],
393
+ repoFingerprint: params.lane.repoFingerprint ?? params.state.repoFingerprint,
394
+ remoteUrl: params.lane.remoteUrl ?? params.state.remoteUrl,
395
+ defaultBranch: params.lane.defaultBranch ?? params.state.defaultBranch
396
+ };
397
+ }
398
+ async function resolveActiveLaneBinding(params) {
399
+ const state = await readCollabBindingState(params.repoRoot);
400
+ if (!state) {
401
+ return { status: "not_bound", currentBranch: null };
402
+ }
403
+ const currentBranch = normalizeBranchName(state.currentBranch);
404
+ const localBinding = state.binding;
405
+ if (localBinding) {
406
+ const requireRemoteLane = shouldRequireRemoteLaneForCurrentBranch({
407
+ binding: localBinding,
408
+ currentBranch,
409
+ defaultBranch: state.defaultBranch
410
+ });
411
+ if (!params.api || !currentBranch) {
412
+ return {
413
+ status: "resolved",
414
+ source: "local",
415
+ binding: localBinding,
416
+ currentBranch
417
+ };
418
+ }
419
+ const laneResp2 = await params.api.resolveProjectLaneBinding({
420
+ projectId: state.explicitRootBinding?.projectId ?? (requireRemoteLane ? void 0 : localBinding.projectId ?? state.projectId ?? void 0),
421
+ repoFingerprint: state.repoFingerprint ?? void 0,
422
+ remoteUrl: state.remoteUrl ?? void 0,
423
+ defaultBranch: state.defaultBranch ?? void 0,
424
+ branchName: currentBranch
425
+ });
426
+ const lane2 = unwrapResponseObject(laneResp2, "project lane binding");
427
+ if (lane2.status === "ambiguous_family_selection") {
428
+ return buildAmbiguousResolution({ state, currentBranch, lane: lane2 });
429
+ }
430
+ if (lane2.status === "resolved") {
431
+ const resolvedBranch = normalizeBranchName(lane2.branchName);
432
+ const resolvedProjectId = lane2.projectId ?? state.projectId;
433
+ const branchConflict = Boolean(resolvedBranch && localBinding.branchName && resolvedBranch !== localBinding.branchName);
434
+ const appConflict = Boolean(lane2.currentAppId && lane2.currentAppId !== localBinding.currentAppId);
435
+ const upstreamConflict = Boolean(lane2.upstreamAppId && lane2.upstreamAppId !== localBinding.upstreamAppId);
436
+ const projectConflict = Boolean(resolvedProjectId && localBinding.projectId && resolvedProjectId !== localBinding.projectId);
437
+ if (branchConflict || appConflict || upstreamConflict || projectConflict) {
438
+ if (requireRemoteLane) {
439
+ const binding = buildBindingFromLane(state, lane2);
440
+ if (binding) {
441
+ return {
442
+ status: "resolved",
443
+ source: "remote",
444
+ binding,
445
+ currentBranch
446
+ };
447
+ }
448
+ }
449
+ return {
450
+ status: "binding_conflict",
451
+ binding: localBinding,
452
+ resolvedLane: lane2,
453
+ currentBranch
454
+ };
455
+ }
456
+ if (shouldPersistRemoteLaneMetadata(localBinding, lane2)) {
457
+ const binding = buildBindingFromLane(state, lane2);
458
+ if (binding) {
459
+ return {
460
+ status: "resolved",
461
+ source: "remote",
462
+ binding,
463
+ currentBranch
464
+ };
465
+ }
466
+ }
467
+ }
468
+ if (requireRemoteLane) {
469
+ return {
470
+ status: "missing_branch_binding",
471
+ currentBranch,
472
+ projectId: lane2.projectId ?? state.projectId,
473
+ repoFingerprint: lane2.repoFingerprint ?? state.repoFingerprint,
474
+ remoteUrl: lane2.remoteUrl ?? state.remoteUrl,
475
+ defaultBranch: lane2.defaultBranch ?? state.defaultBranch,
476
+ upstreamAppId: lane2.upstreamAppId ?? localBinding.upstreamAppId ?? null,
477
+ threadId: lane2.threadId ?? localBinding.threadId ?? null
478
+ };
479
+ }
480
+ return {
481
+ status: "resolved",
482
+ source: "local",
483
+ binding: localBinding,
484
+ currentBranch
485
+ };
486
+ }
487
+ if (!params.api || !currentBranch) {
488
+ return {
489
+ status: "missing_branch_binding",
490
+ currentBranch,
491
+ projectId: state.projectId,
492
+ repoFingerprint: state.repoFingerprint,
493
+ remoteUrl: state.remoteUrl,
494
+ defaultBranch: state.defaultBranch,
495
+ upstreamAppId: null,
496
+ threadId: null
497
+ };
498
+ }
499
+ const laneResp = await params.api.resolveProjectLaneBinding({
500
+ projectId: state.explicitRootBinding?.projectId ?? state.projectId ?? void 0,
501
+ repoFingerprint: state.repoFingerprint ?? void 0,
502
+ remoteUrl: state.remoteUrl ?? void 0,
503
+ defaultBranch: state.defaultBranch ?? void 0,
504
+ branchName: currentBranch
505
+ });
506
+ const lane = unwrapResponseObject(laneResp, "project lane binding");
507
+ if (lane.status === "ambiguous_family_selection") {
508
+ return buildAmbiguousResolution({ state, currentBranch, lane });
509
+ }
510
+ if (lane.status === "resolved") {
511
+ const binding = buildBindingFromLane(state, lane);
512
+ if (binding) {
513
+ return {
514
+ status: "resolved",
515
+ source: "remote",
516
+ binding,
517
+ currentBranch
518
+ };
519
+ }
520
+ }
521
+ return {
522
+ status: "missing_branch_binding",
523
+ currentBranch,
524
+ projectId: lane.projectId ?? state.explicitRootBinding?.projectId ?? state.projectId,
525
+ repoFingerprint: lane.repoFingerprint ?? state.repoFingerprint,
526
+ remoteUrl: lane.remoteUrl ?? state.remoteUrl,
527
+ defaultBranch: lane.defaultBranch ?? state.defaultBranch,
528
+ upstreamAppId: lane.upstreamAppId ?? state.explicitRootBinding?.upstreamAppId ?? null,
529
+ threadId: lane.threadId ?? state.explicitRootBinding?.threadId ?? null
530
+ };
531
+ }
532
+ async function ensureActiveLaneBinding(params) {
533
+ const resolved = await resolveActiveLaneBinding({
534
+ repoRoot: params.repoRoot,
535
+ api: params.api
536
+ });
537
+ if (resolved.status === "resolved") {
538
+ if (resolved.source === "local") {
539
+ return resolved.binding;
540
+ }
541
+ return persistResolvedLane(params.repoRoot, resolved.binding);
542
+ }
543
+ if (resolved.status === "binding_conflict") {
544
+ throw new RemixError("Current branch binding conflicts with the server-resolved Remix lane.", {
545
+ exitCode: 2,
546
+ hint: `Local app ${resolved.binding.currentAppId}; server app ${resolved.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before running ${params.operation ?? "this command"}.`
547
+ });
548
+ }
549
+ if (resolved.status === "ambiguous_family_selection") {
550
+ throw new RemixError("Multiple canonical Remix families match this repository.", {
551
+ exitCode: 2,
552
+ hint: "This checkout is not specific enough to choose a single family for the current branch. Continue from a checkout already bound to the intended family, or run `remix collab init --force-new` to create a new canonical family."
553
+ });
554
+ }
555
+ if (resolved.status === "not_bound") {
556
+ return null;
557
+ }
558
+ if (!resolved.currentBranch) {
559
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
560
+ exitCode: 2,
561
+ hint: `Switch to a named branch before running ${params.operation ?? "this command"}.`
562
+ });
563
+ }
564
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
565
+ exitCode: 2,
566
+ hint: `Run \`remix collab init\` on branch ${resolved.currentBranch} before running ${params.operation ?? "this command"}.`
567
+ });
568
+ }
569
+
288
570
  // src/application/collab/recordingPreflight.ts
289
571
  async function collabRecordingPreflight(params) {
290
572
  let repoRoot;
@@ -297,7 +579,7 @@ async function collabRecordingPreflight(params) {
297
579
  repoRoot: null,
298
580
  appId: null,
299
581
  currentBranch: null,
300
- preferredBranch: null,
582
+ branchName: null,
301
583
  headCommitHash: null,
302
584
  worktreeClean: false,
303
585
  syncStatus: null,
@@ -309,14 +591,14 @@ async function collabRecordingPreflight(params) {
309
591
  hint: message
310
592
  };
311
593
  }
312
- const binding = await readCollabBinding(repoRoot);
313
- if (!binding) {
594
+ const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api });
595
+ if (bindingResolution.status === "not_bound") {
314
596
  return {
315
597
  status: "not_bound",
316
598
  repoRoot,
317
599
  appId: null,
318
600
  currentBranch: null,
319
- preferredBranch: null,
601
+ branchName: null,
320
602
  headCommitHash: null,
321
603
  worktreeClean: false,
322
604
  syncStatus: null,
@@ -328,19 +610,74 @@ async function collabRecordingPreflight(params) {
328
610
  hint: "Run `remix collab init` first."
329
611
  };
330
612
  }
613
+ if (bindingResolution.status === "missing_branch_binding") {
614
+ return {
615
+ status: "branch_binding_missing",
616
+ repoRoot,
617
+ appId: null,
618
+ currentBranch: bindingResolution.currentBranch,
619
+ branchName: bindingResolution.currentBranch,
620
+ headCommitHash: null,
621
+ worktreeClean: false,
622
+ syncStatus: null,
623
+ syncTargetCommitHash: null,
624
+ syncTargetCommitId: null,
625
+ reconcileTargetHeadCommitHash: null,
626
+ reconcileTargetHeadCommitId: null,
627
+ warnings: [],
628
+ hint: `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`
629
+ };
630
+ }
631
+ if (bindingResolution.status === "ambiguous_family_selection") {
632
+ return {
633
+ status: "family_ambiguous",
634
+ repoRoot,
635
+ appId: null,
636
+ currentBranch: bindingResolution.currentBranch,
637
+ branchName: bindingResolution.currentBranch,
638
+ headCommitHash: null,
639
+ worktreeClean: false,
640
+ syncStatus: null,
641
+ syncTargetCommitHash: null,
642
+ syncTargetCommitId: null,
643
+ reconcileTargetHeadCommitHash: null,
644
+ reconcileTargetHeadCommitId: null,
645
+ warnings: [],
646
+ hint: "Multiple canonical Remix families match this repository. Continue from a checkout already bound to the intended family, or run `remix collab init --force-new` to create a new canonical family."
647
+ };
648
+ }
649
+ if (bindingResolution.status === "binding_conflict") {
650
+ return {
651
+ status: "metadata_conflict",
652
+ repoRoot,
653
+ appId: bindingResolution.binding.currentAppId,
654
+ currentBranch: bindingResolution.currentBranch,
655
+ branchName: bindingResolution.binding.branchName,
656
+ headCommitHash: null,
657
+ worktreeClean: false,
658
+ syncStatus: null,
659
+ syncTargetCommitHash: null,
660
+ syncTargetCommitId: null,
661
+ reconcileTargetHeadCommitHash: null,
662
+ reconcileTargetHeadCommitId: null,
663
+ warnings: [],
664
+ hint: `Local binding for ${bindingResolution.currentBranch ?? "(detached)"} points to app ${bindingResolution.binding.currentAppId}, but the server resolved lane ${bindingResolution.resolvedLane.laneId ?? "(unknown)"} / app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before recording work.`
665
+ };
666
+ }
667
+ const binding = bindingResolution.binding;
331
668
  const [currentBranch, headCommitHash, worktreeStatus] = await Promise.all([
332
669
  getCurrentBranch(repoRoot),
333
670
  getHeadCommitHash(repoRoot),
334
671
  getWorktreeStatus(repoRoot)
335
672
  ]);
336
- const preferredBranch = binding.preferredBranch ?? null;
673
+ const branchName = binding.branchName ?? null;
337
674
  if (!headCommitHash) {
338
675
  return {
339
676
  status: "missing_head",
340
677
  repoRoot,
341
678
  appId: binding.currentAppId,
342
679
  currentBranch,
343
- preferredBranch,
680
+ branchName,
344
681
  headCommitHash: null,
345
682
  worktreeClean: worktreeStatus.isClean,
346
683
  syncStatus: null,
@@ -352,13 +689,13 @@ async function collabRecordingPreflight(params) {
352
689
  hint: "Failed to resolve local HEAD commit."
353
690
  };
354
691
  }
355
- if (!params.allowBranchMismatch && !isPreferredBranchMatch(currentBranch, preferredBranch)) {
692
+ if (!params.allowBranchMismatch && !isBoundBranchMatch(currentBranch, branchName)) {
356
693
  return {
357
694
  status: "branch_mismatch",
358
695
  repoRoot,
359
696
  appId: binding.currentAppId,
360
697
  currentBranch,
361
- preferredBranch,
698
+ branchName,
362
699
  headCommitHash,
363
700
  worktreeClean: worktreeStatus.isClean,
364
701
  syncStatus: null,
@@ -367,9 +704,9 @@ async function collabRecordingPreflight(params) {
367
704
  reconcileTargetHeadCommitHash: null,
368
705
  reconcileTargetHeadCommitId: null,
369
706
  warnings: [],
370
- hint: buildPreferredBranchMismatchHint({
707
+ hint: buildBranchMismatchHint({
371
708
  currentBranch,
372
- preferredBranch
709
+ branchName
373
710
  })
374
711
  };
375
712
  }
@@ -387,7 +724,7 @@ async function collabRecordingPreflight(params) {
387
724
  repoRoot,
388
725
  appId: binding.currentAppId,
389
726
  currentBranch,
390
- preferredBranch,
727
+ branchName,
391
728
  headCommitHash,
392
729
  worktreeClean: worktreeStatus.isClean,
393
730
  syncStatus: sync.status,
@@ -405,7 +742,7 @@ async function collabRecordingPreflight(params) {
405
742
  repoRoot,
406
743
  appId: binding.currentAppId,
407
744
  currentBranch,
408
- preferredBranch,
745
+ branchName,
409
746
  headCommitHash,
410
747
  worktreeClean: worktreeStatus.isClean,
411
748
  syncStatus: sync.status,
@@ -430,7 +767,7 @@ async function collabRecordingPreflight(params) {
430
767
  repoRoot,
431
768
  appId: binding.currentAppId,
432
769
  currentBranch,
433
- preferredBranch,
770
+ branchName,
434
771
  headCommitHash,
435
772
  worktreeClean: worktreeStatus.isClean,
436
773
  syncStatus: sync.status,
@@ -448,7 +785,7 @@ async function collabRecordingPreflight(params) {
448
785
  repoRoot,
449
786
  appId: binding.currentAppId,
450
787
  currentBranch,
451
- preferredBranch,
788
+ branchName,
452
789
  headCommitHash,
453
790
  worktreeClean: worktreeStatus.isClean,
454
791
  syncStatus: sync.status,
@@ -465,7 +802,7 @@ async function collabRecordingPreflight(params) {
465
802
  repoRoot,
466
803
  appId: binding.currentAppId,
467
804
  currentBranch,
468
- preferredBranch,
805
+ branchName,
469
806
  headCommitHash,
470
807
  worktreeClean: worktreeStatus.isClean,
471
808
  syncStatus: sync.status,
@@ -697,7 +1034,11 @@ import os2 from "os";
697
1034
  import path2 from "path";
698
1035
  async function collabSync(params) {
699
1036
  const repoRoot = await findGitRoot(params.cwd);
700
- const binding = await readCollabBinding(repoRoot);
1037
+ const binding = await ensureActiveLaneBinding({
1038
+ repoRoot,
1039
+ api: params.api,
1040
+ operation: "`remix collab sync`"
1041
+ });
701
1042
  if (!binding) {
702
1043
  throw new RemixError("Repository is not bound to Remix.", {
703
1044
  exitCode: 2,
@@ -706,9 +1047,9 @@ async function collabSync(params) {
706
1047
  }
707
1048
  await ensureCleanWorktree(repoRoot);
708
1049
  const branch = await requireCurrentBranch(repoRoot);
709
- assertPreferredBranchMatch({
1050
+ assertBoundBranchMatch({
710
1051
  currentBranch: branch,
711
- preferredBranch: binding.preferredBranch,
1052
+ branchName: binding.branchName,
712
1053
  allowBranchMismatch: params.allowBranchMismatch,
713
1054
  operation: "`remix collab sync`"
714
1055
  });
@@ -785,9 +1126,9 @@ async function collabSync(params) {
785
1126
  });
786
1127
  await ensureCleanWorktree(lockedRepoRoot);
787
1128
  const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
788
- assertPreferredBranchMatch({
1129
+ assertBoundBranchMatch({
789
1130
  currentBranch: lockedBranch,
790
- preferredBranch: binding.preferredBranch,
1131
+ branchName: binding.branchName,
791
1132
  allowBranchMismatch: params.allowBranchMismatch,
792
1133
  operation: "`remix collab sync`"
793
1134
  });
@@ -820,6 +1161,18 @@ function assertSupportedRecordingPreflight(preflight) {
820
1161
  hint: preflight.hint
821
1162
  });
822
1163
  }
1164
+ if (preflight.status === "branch_binding_missing") {
1165
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
1166
+ exitCode: 2,
1167
+ hint: preflight.hint
1168
+ });
1169
+ }
1170
+ if (preflight.status === "family_ambiguous") {
1171
+ throw new RemixError("Multiple canonical Remix families match this repository.", {
1172
+ exitCode: 2,
1173
+ hint: preflight.hint
1174
+ });
1175
+ }
823
1176
  if (preflight.status === "not_git_repo") {
824
1177
  throw new RemixError(preflight.hint || "Not inside a git repository.", {
825
1178
  exitCode: 2,
@@ -833,9 +1186,9 @@ function assertSupportedRecordingPreflight(preflight) {
833
1186
  });
834
1187
  }
835
1188
  if (preflight.status === "branch_mismatch") {
836
- assertPreferredBranchMatch({
1189
+ assertBoundBranchMatch({
837
1190
  currentBranch: preflight.currentBranch,
838
- preferredBranch: preflight.preferredBranch,
1191
+ branchName: preflight.branchName,
839
1192
  allowBranchMismatch: false,
840
1193
  operation: "`remix collab add`"
841
1194
  });
@@ -855,7 +1208,11 @@ function assertSupportedRecordingPreflight(preflight) {
855
1208
  }
856
1209
  async function collabAdd(params) {
857
1210
  const repoRoot = await findGitRoot(params.cwd);
858
- const binding = await readCollabBinding(repoRoot);
1211
+ const binding = await ensureActiveLaneBinding({
1212
+ repoRoot,
1213
+ api: params.api,
1214
+ operation: "`remix collab add`"
1215
+ });
859
1216
  if (!binding) {
860
1217
  throw new RemixError("Repository is not bound to Remix.", {
861
1218
  exitCode: 2,
@@ -876,9 +1233,9 @@ async function collabAdd(params) {
876
1233
  });
877
1234
  assertSupportedRecordingPreflight(preflight);
878
1235
  const branch = preflight.currentBranch;
879
- assertPreferredBranchMatch({
1236
+ assertBoundBranchMatch({
880
1237
  currentBranch: branch,
881
- preferredBranch: binding.preferredBranch,
1238
+ branchName: binding.branchName,
882
1239
  allowBranchMismatch: params.allowBranchMismatch,
883
1240
  operation: "`remix collab add`"
884
1241
  });
@@ -1051,6 +1408,7 @@ async function collabAdd(params) {
1051
1408
  });
1052
1409
  const resp = await params.api.createChangeStep(binding.currentAppId, {
1053
1410
  threadId: binding.threadId ?? void 0,
1411
+ collabLaneId: binding.laneId ?? void 0,
1054
1412
  prompt,
1055
1413
  assistantResponse: assistantResponse ?? void 0,
1056
1414
  diff,
@@ -1126,6 +1484,18 @@ function assertSupportedRecordingPreflight2(preflight) {
1126
1484
  hint: preflight.hint
1127
1485
  });
1128
1486
  }
1487
+ if (preflight.status === "branch_binding_missing") {
1488
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
1489
+ exitCode: 2,
1490
+ hint: preflight.hint
1491
+ });
1492
+ }
1493
+ if (preflight.status === "family_ambiguous") {
1494
+ throw new RemixError("Multiple canonical Remix families match this repository.", {
1495
+ exitCode: 2,
1496
+ hint: preflight.hint
1497
+ });
1498
+ }
1129
1499
  if (preflight.status === "not_git_repo") {
1130
1500
  throw new RemixError(preflight.hint || "Not inside a git repository.", {
1131
1501
  exitCode: 2,
@@ -1139,9 +1509,9 @@ function assertSupportedRecordingPreflight2(preflight) {
1139
1509
  });
1140
1510
  }
1141
1511
  if (preflight.status === "branch_mismatch") {
1142
- assertPreferredBranchMatch({
1512
+ assertBoundBranchMatch({
1143
1513
  currentBranch: preflight.currentBranch,
1144
- preferredBranch: preflight.preferredBranch,
1514
+ branchName: preflight.branchName,
1145
1515
  allowBranchMismatch: false,
1146
1516
  operation: "`remix collab record-turn`"
1147
1517
  });
@@ -1161,7 +1531,11 @@ function assertSupportedRecordingPreflight2(preflight) {
1161
1531
  }
1162
1532
  async function collabRecordTurn(params) {
1163
1533
  const repoRoot = await findGitRoot(params.cwd);
1164
- const binding = await readCollabBinding(repoRoot);
1534
+ const binding = await ensureActiveLaneBinding({
1535
+ repoRoot,
1536
+ api: params.api,
1537
+ operation: "`remix collab record-turn`"
1538
+ });
1165
1539
  if (!binding) {
1166
1540
  throw new RemixError("Repository is not bound to Remix.", {
1167
1541
  exitCode: 2,
@@ -1193,9 +1567,9 @@ async function collabRecordTurn(params) {
1193
1567
  });
1194
1568
  }
1195
1569
  const branch = await getCurrentBranch(repoRoot);
1196
- assertPreferredBranchMatch({
1570
+ assertBoundBranchMatch({
1197
1571
  currentBranch: branch,
1198
- preferredBranch: binding.preferredBranch,
1572
+ branchName: binding.branchName,
1199
1573
  allowBranchMismatch: params.allowBranchMismatch,
1200
1574
  operation: "`remix collab record-turn`"
1201
1575
  });
@@ -1209,6 +1583,7 @@ async function collabRecordTurn(params) {
1209
1583
  });
1210
1584
  const resp = await params.api.createCollabTurn(binding.currentAppId, {
1211
1585
  threadId: binding.threadId ?? void 0,
1586
+ collabLaneId: binding.laneId ?? void 0,
1212
1587
  prompt,
1213
1588
  assistantResponse,
1214
1589
  actor: params.actor,
@@ -1221,7 +1596,8 @@ async function collabRecordTurn(params) {
1221
1596
  },
1222
1597
  idempotencyKey
1223
1598
  });
1224
- return unwrapResponseObject(resp, "collab turn");
1599
+ const turn = unwrapResponseObject(resp, "collab turn");
1600
+ return turn;
1225
1601
  }
1226
1602
 
1227
1603
  // src/application/collab/collabFinalizeTurn.ts
@@ -1231,7 +1607,11 @@ function collectWarnings(value) {
1231
1607
  }
1232
1608
  async function collabFinalizeTurn(params) {
1233
1609
  const repoRoot = await findGitRoot(params.cwd);
1234
- const binding = await readCollabBinding(repoRoot);
1610
+ const binding = await ensureActiveLaneBinding({
1611
+ repoRoot,
1612
+ api: params.api,
1613
+ operation: "`remix collab finalize-turn`"
1614
+ });
1235
1615
  if (!binding) {
1236
1616
  throw new RemixError("Repository is not bound to Remix.", {
1237
1617
  exitCode: 2,
@@ -1339,7 +1719,11 @@ async function collabApprove(params) {
1339
1719
  operation: "collabApproveSyncTarget"
1340
1720
  },
1341
1721
  async ({ repoRoot, warnings }) => {
1342
- const binding = await readCollabBinding(repoRoot);
1722
+ const binding = await ensureActiveLaneBinding({
1723
+ repoRoot,
1724
+ api: params.api,
1725
+ operation: "`remix collab approve --sync-target-repo`"
1726
+ });
1343
1727
  if (!binding) {
1344
1728
  throw new RemixError("Repository is not bound to Remix.", {
1345
1729
  exitCode: 2,
@@ -1348,9 +1732,9 @@ async function collabApprove(params) {
1348
1732
  }
1349
1733
  await ensureCleanWorktree(repoRoot, "`remix collab approve --sync-target-repo`");
1350
1734
  const branch = await requireCurrentBranch(repoRoot);
1351
- assertPreferredBranchMatch({
1735
+ assertBoundBranchMatch({
1352
1736
  currentBranch: branch,
1353
- preferredBranch: binding.preferredBranch,
1737
+ branchName: binding.branchName,
1354
1738
  allowBranchMismatch: params.allowBranchMismatch,
1355
1739
  operation: "`remix collab approve --sync-target-repo`"
1356
1740
  });
@@ -1433,10 +1817,6 @@ function isSubpath(parentPath, candidatePath) {
1433
1817
  const relative = path4.relative(parentPath, candidatePath);
1434
1818
  return relative === "" || !relative.startsWith("..") && !path4.isAbsolute(relative);
1435
1819
  }
1436
- function buildPreferredCheckoutBranch(appId) {
1437
- const normalized = appId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
1438
- return `remix/remix/${normalized || "app"}`;
1439
- }
1440
1820
  async function resolveCheckoutDestination(params) {
1441
1821
  if (params.outputDir?.trim()) {
1442
1822
  const preferredRepoRoot = path4.resolve(params.outputDir.trim());
@@ -1501,7 +1881,9 @@ async function materializeAppCheckout(params) {
1501
1881
  const bundle = await params.api.downloadAppBundle(params.appId);
1502
1882
  await fs4.writeFile(bundlePath, bundle.data);
1503
1883
  await cloneGitBundleToDirectory(bundlePath, repoRoot);
1504
- await checkoutLocalBranch(repoRoot, buildPreferredCheckoutBranch(params.appId));
1884
+ if (params.expectedBranchName?.trim()) {
1885
+ await checkoutLocalBranch(repoRoot, params.expectedBranchName.trim());
1886
+ }
1505
1887
  await ensureGitInfoExcludeEntries(repoRoot, [".remix/"]);
1506
1888
  } catch (err) {
1507
1889
  await fs4.rm(repoRoot, { recursive: true, force: true }).catch(() => {
@@ -1510,15 +1892,15 @@ async function materializeAppCheckout(params) {
1510
1892
  } finally {
1511
1893
  await fs4.rm(bundleTempDir, { recursive: true, force: true });
1512
1894
  }
1513
- const remoteUrl = normalizeGitRemote(await getRemoteOriginUrl(repoRoot));
1514
- const defaultBranch = await getDefaultBranch(repoRoot) ?? await getCurrentBranch(repoRoot) ?? null;
1515
- const preferredBranch = await getCurrentBranch(repoRoot) ?? buildPreferredCheckoutBranch(params.appId);
1895
+ const branchName = await getCurrentBranch(repoRoot) ?? params.expectedBranchName?.trim() ?? null;
1896
+ const remoteUrl = normalizeGitRemote(params.expectedRemoteUrl ?? await getRemoteOriginUrl(repoRoot));
1897
+ const defaultBranch = params.expectedDefaultBranch?.trim() ?? await getDefaultBranch(repoRoot) ?? branchName ?? null;
1516
1898
  const repoFingerprint = remoteUrl ? await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch }) : null;
1517
1899
  return {
1518
1900
  repoRoot,
1519
1901
  remoteUrl,
1520
1902
  defaultBranch,
1521
- preferredBranch,
1903
+ branchName,
1522
1904
  repoFingerprint
1523
1905
  };
1524
1906
  }
@@ -1533,29 +1915,55 @@ async function collabCheckout(params) {
1533
1915
  });
1534
1916
  }
1535
1917
  const app = await pollAppReady(params.api, appId);
1918
+ const collab = app.collab && typeof app.collab === "object" ? app.collab : null;
1919
+ const source = app.source && typeof app.source === "object" ? app.source : null;
1920
+ const authoritativeBranchName = typeof collab?.branchName === "string" && collab.branchName.trim() || typeof source?.branch === "string" && source.branch.trim() || typeof source?.defaultBranch === "string" && source.defaultBranch.trim() || null;
1921
+ const authoritativeRemoteUrl = typeof source?.remoteUrl === "string" && source.remoteUrl.trim() || null;
1922
+ const authoritativeDefaultBranch = typeof source?.defaultBranch === "string" && source.defaultBranch.trim() || null;
1536
1923
  const checkout = await materializeAppCheckout({
1537
1924
  api: params.api,
1538
1925
  cwd: params.cwd,
1539
1926
  appId: String(app.id),
1540
1927
  outputDir: params.outputDir ?? null,
1541
- defaultDirName: sanitizeCheckoutDirName(String(app.name || app.id))
1928
+ defaultDirName: sanitizeCheckoutDirName(String(app.name || app.id)),
1929
+ expectedBranchName: authoritativeBranchName,
1930
+ expectedRemoteUrl: authoritativeRemoteUrl,
1931
+ expectedDefaultBranch: authoritativeDefaultBranch
1542
1932
  });
1543
1933
  const upstreamAppId = String(app.forkedFromAppId ?? app.id);
1544
- const bindingPath = await writeCollabBinding(checkout.repoRoot, {
1934
+ const laneId = typeof collab?.laneId === "string" ? collab.laneId : null;
1935
+ const repoFingerprint = typeof source?.repoFingerprint === "string" ? source.repoFingerprint : checkout.repoFingerprint;
1936
+ const remoteUrl = typeof source?.remoteUrl === "string" ? source.remoteUrl : checkout.remoteUrl;
1937
+ const defaultBranch = typeof source?.defaultBranch === "string" ? source.defaultBranch : checkout.defaultBranch;
1938
+ const branchName = authoritativeBranchName ?? checkout.branchName;
1939
+ const authoritativeLane = await resolveProjectLaneIfAuthoritative(params.api, {
1545
1940
  projectId: String(app.projectId),
1941
+ repoFingerprint,
1942
+ remoteUrl,
1943
+ defaultBranch,
1944
+ branchName,
1546
1945
  currentAppId: String(app.id),
1547
- upstreamAppId,
1548
- threadId: app.threadId ? String(app.threadId) : null,
1549
- repoFingerprint: checkout.repoFingerprint,
1550
- remoteUrl: checkout.remoteUrl,
1551
- defaultBranch: checkout.defaultBranch,
1552
- preferredBranch: checkout.preferredBranch
1946
+ expectedUpstreamAppId: upstreamAppId,
1947
+ createIfMissing: true,
1948
+ seedAppId: String(app.id)
1949
+ });
1950
+ const bindingPath = await writeCollabBinding(checkout.repoRoot, {
1951
+ projectId: authoritativeLane?.projectId ?? String(app.projectId),
1952
+ currentAppId: authoritativeLane?.currentAppId ?? String(app.id),
1953
+ upstreamAppId: authoritativeLane?.upstreamAppId ?? upstreamAppId,
1954
+ threadId: authoritativeLane?.threadId ?? (app.threadId ? String(app.threadId) : null),
1955
+ repoFingerprint: authoritativeLane?.repoFingerprint ?? repoFingerprint,
1956
+ remoteUrl: authoritativeLane?.remoteUrl ?? remoteUrl,
1957
+ defaultBranch: authoritativeLane?.defaultBranch ?? defaultBranch,
1958
+ laneId: authoritativeLane?.laneId ?? laneId,
1959
+ branchName: authoritativeLane?.branchName ?? branchName,
1960
+ bindingMode: "lane"
1553
1961
  });
1554
1962
  return {
1555
- appId: String(app.id),
1556
- dashboardUrl: buildDashboardAppUrl(String(app.id)),
1557
- projectId: String(app.projectId),
1558
- upstreamAppId,
1963
+ appId: authoritativeLane?.currentAppId ?? String(app.id),
1964
+ dashboardUrl: buildDashboardAppUrl(authoritativeLane?.currentAppId ?? String(app.id)),
1965
+ projectId: authoritativeLane?.projectId ?? String(app.projectId),
1966
+ upstreamAppId: authoritativeLane?.upstreamAppId ?? upstreamAppId,
1559
1967
  bindingPath,
1560
1968
  repoRoot: checkout.repoRoot
1561
1969
  };
@@ -1577,14 +1985,32 @@ async function resolveQueueAppId(params) {
1577
1985
  });
1578
1986
  }
1579
1987
  const repoRoot = await findGitRoot(params.cwd);
1580
- const binding = await readCollabBinding(repoRoot);
1581
- if (!binding) {
1988
+ const bindingResolution = await resolveActiveLaneBinding({ repoRoot });
1989
+ if (bindingResolution.status === "not_bound") {
1582
1990
  throw new RemixError("Repository is not bound to Remix.", {
1583
1991
  exitCode: 2,
1584
1992
  hint: "Bind the repository first or pass `appId` explicitly for the app-scoped merge-request queue."
1585
1993
  });
1586
1994
  }
1587
- return binding.currentAppId;
1995
+ if (bindingResolution.status === "missing_branch_binding") {
1996
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
1997
+ exitCode: 2,
1998
+ hint: `Switch back to a bound branch or create a lane by recording work on ${bindingResolution.currentBranch ?? "this branch"}.`
1999
+ });
2000
+ }
2001
+ if (bindingResolution.status === "binding_conflict") {
2002
+ throw new RemixError("Current branch binding conflicts with the server-resolved Remix lane.", {
2003
+ exitCode: 2,
2004
+ hint: `Local app ${bindingResolution.binding.currentAppId}; server app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}.`
2005
+ });
2006
+ }
2007
+ if (bindingResolution.status === "ambiguous_family_selection") {
2008
+ throw new RemixError("Multiple canonical Remix families match this repository.", {
2009
+ exitCode: 2,
2010
+ hint: "This checkout does not identify a single canonical family. Continue from a checkout already bound to the intended family, or pass `appId` explicitly for the queue request."
2011
+ });
2012
+ }
2013
+ return bindingResolution.binding.currentAppId;
1588
2014
  }
1589
2015
  async function collabListMergeRequests(params) {
1590
2016
  const appId = await resolveQueueAppId({
@@ -1615,12 +2041,38 @@ async function collabListMergeRequests(params) {
1615
2041
  async function resolveScopeTarget(params) {
1616
2042
  if (params.targetId?.trim()) return params.targetId.trim();
1617
2043
  const repoRoot = await findGitRoot(params.cwd);
1618
- const binding = await readCollabBinding(repoRoot);
1619
- if (!binding) {
2044
+ const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api });
2045
+ if (bindingResolution.status === "not_bound") {
1620
2046
  throw new RemixError("Repository is not bound to Remix and no explicit target id was provided.", { exitCode: 2 });
1621
2047
  }
1622
- if (params.scope === "project") return binding.projectId;
2048
+ if (bindingResolution.status === "missing_branch_binding") {
2049
+ throw new RemixError("Current branch is not yet bound to a Remix lane and no explicit target id was provided.", {
2050
+ exitCode: 2
2051
+ });
2052
+ }
2053
+ if (bindingResolution.status === "binding_conflict") {
2054
+ throw new RemixError("Current branch binding conflicts with the server-resolved Remix lane.", {
2055
+ exitCode: 2,
2056
+ hint: `Local app ${bindingResolution.binding.currentAppId}; server app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}.`
2057
+ });
2058
+ }
2059
+ if (bindingResolution.status === "ambiguous_family_selection") {
2060
+ throw new RemixError("Multiple canonical Remix families match this repository and no explicit target id was provided.", {
2061
+ exitCode: 2,
2062
+ hint: "This checkout does not identify a single canonical family. Continue from a checkout already bound to the intended family, or pass `targetId` explicitly."
2063
+ });
2064
+ }
2065
+ const binding = bindingResolution.binding;
2066
+ if (params.scope === "project") {
2067
+ if (!binding.projectId) {
2068
+ throw new RemixError("Could not resolve the project for the current repository binding.", { exitCode: 2 });
2069
+ }
2070
+ return binding.projectId;
2071
+ }
1623
2072
  if (params.scope === "app") return binding.currentAppId;
2073
+ if (!binding.projectId) {
2074
+ throw new RemixError("Could not resolve the project for the current repository binding.", { exitCode: 2 });
2075
+ }
1624
2076
  const project = unwrapResponseObject(await params.api.getProject(binding.projectId), "project");
1625
2077
  const organizationId = typeof project.organizationId === "string" ? project.organizationId : null;
1626
2078
  if (!organizationId) {
@@ -1740,6 +2192,85 @@ ${text}`.trim() || null
1740
2192
  }
1741
2193
 
1742
2194
  // src/application/collab/collabInit.ts
2195
+ function requireResolvedLaneBinding(lane, params) {
2196
+ if (lane.status === "resolved" && lane.currentAppId && lane.upstreamAppId) {
2197
+ return lane;
2198
+ }
2199
+ const branchLabel = params.branchName ?? "the current branch";
2200
+ const laneStatus = String(lane.status ?? "");
2201
+ throw new RemixError(`Failed to resolve a Remix lane for ${branchLabel}.`, {
2202
+ exitCode: 1,
2203
+ hint: laneStatus === "binding_not_found" ? `Run ${params.operation} again after the repository has been initialized.` : laneStatus === "ambiguous_family_selection" ? "Multiple canonical Remix families match this repository. Continue from a checkout already bound to the intended family, or rerun with `--force-new` to create a new family." : `Remix did not return complete lane metadata for ${branchLabel}.`
2204
+ });
2205
+ }
2206
+ function resolveProjectBindingResult(response) {
2207
+ const payload = response?.responseObject;
2208
+ if (!payload || typeof payload !== "object") {
2209
+ return { status: "not_found" };
2210
+ }
2211
+ if (payload.status === "ambiguous_family_selection") {
2212
+ const projectIds = Array.isArray(payload.projectIds) ? payload.projectIds.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
2213
+ return {
2214
+ status: "ambiguous_family_selection",
2215
+ candidateCount: typeof payload.candidateCount === "number" && Number.isFinite(payload.candidateCount) ? payload.candidateCount : projectIds.length,
2216
+ projectIds
2217
+ };
2218
+ }
2219
+ if (payload.projectId && payload.appId) {
2220
+ return {
2221
+ status: "resolved",
2222
+ projectId: String(payload.projectId),
2223
+ appId: String(payload.appId),
2224
+ upstreamAppId: String(payload.upstreamAppId ?? payload.appId),
2225
+ threadId: payload.threadId ? String(payload.threadId) : null
2226
+ };
2227
+ }
2228
+ return { status: "not_found" };
2229
+ }
2230
+ function throwAmbiguousFamilyError(params) {
2231
+ const familyCount = params.candidateCount || params.projectIds.length;
2232
+ const projectHint = params.projectIds.length > 0 ? ` Matching project ids: ${params.projectIds.join(", ")}.` : "";
2233
+ throw new RemixError("Multiple canonical Remix families already match this repository.", {
2234
+ exitCode: 2,
2235
+ hint: `Plain \`remix collab init\` cannot safely choose among ${familyCount} matching canonical families for repo fingerprint ${params.repoFingerprint}${params.remoteUrl ? ` (${params.remoteUrl})` : ""}.${projectHint} Run \`remix collab init --force-new\` to create a new canonical family, or continue from a checkout already bound to the intended family.`
2236
+ });
2237
+ }
2238
+ async function resolveOrEnsureLaneBinding(params) {
2239
+ const resolvePayload = {
2240
+ projectId: params.projectId ?? void 0,
2241
+ repoFingerprint: params.repoFingerprint,
2242
+ remoteUrl: params.remoteUrl ?? void 0,
2243
+ defaultBranch: params.defaultBranch ?? void 0,
2244
+ branchName: params.branchName
2245
+ };
2246
+ let lane = unwrapResponseObject(
2247
+ await params.api.resolveProjectLaneBinding(resolvePayload),
2248
+ "project lane binding"
2249
+ );
2250
+ if (lane.status !== "resolved") {
2251
+ lane = unwrapResponseObject(
2252
+ await params.api.ensureProjectLaneBinding({
2253
+ ...resolvePayload,
2254
+ seedAppId: params.seedAppId ?? void 0
2255
+ }),
2256
+ "project lane binding"
2257
+ );
2258
+ }
2259
+ return requireResolvedLaneBinding(lane, {
2260
+ branchName: params.branchName,
2261
+ operation: params.operation
2262
+ });
2263
+ }
2264
+ function branchBindingFromLane(lane, mode, fallback) {
2265
+ return {
2266
+ projectId: lane.projectId ?? fallback.projectId,
2267
+ currentAppId: lane.currentAppId ?? fallback.currentAppId,
2268
+ upstreamAppId: lane.upstreamAppId ?? fallback.upstreamAppId,
2269
+ threadId: lane.threadId ?? fallback.threadId,
2270
+ laneId: lane.laneId ?? null,
2271
+ bindingMode: mode
2272
+ };
2273
+ }
1743
2274
  async function collabInit(params) {
1744
2275
  return withRepoMutationLock(
1745
2276
  {
@@ -1758,38 +2289,220 @@ async function collabInit(params) {
1758
2289
  const remoteUrl = normalizeGitRemote(await getRemoteOriginUrl(repoRoot));
1759
2290
  const currentBranch = await getCurrentBranch(repoRoot);
1760
2291
  const defaultBranch = await getDefaultBranch(repoRoot) ?? currentBranch;
1761
- const preferredBranch = currentBranch ?? defaultBranch ?? null;
2292
+ const branchName = currentBranch ?? defaultBranch ?? null;
1762
2293
  const repoFingerprint = await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch });
1763
2294
  const repoSnapshot = await captureRepoSnapshot(repoRoot);
1764
- if (!params.forceNew) {
1765
- const bindingResp = await params.api.resolveProjectBinding({
1766
- repoFingerprint,
1767
- remoteUrl: remoteUrl ?? void 0
2295
+ const localBindingState = await readCollabBindingState(repoRoot, { persist: true });
2296
+ if (!params.forceNew && localBindingState?.explicitRootBinding && branchName) {
2297
+ const explicitRoot = localBindingState.explicitRootBinding;
2298
+ const explicitProjectId = explicitRoot.projectId ?? localBindingState.projectId;
2299
+ let canonicalLane2 = null;
2300
+ let boundProjectId2 = explicitProjectId;
2301
+ let boundCurrentAppId2 = explicitRoot.currentAppId;
2302
+ let boundUpstreamAppId2 = explicitRoot.upstreamAppId;
2303
+ let boundThreadId2 = explicitRoot.threadId;
2304
+ let boundLaneId2 = explicitRoot.laneId;
2305
+ if (defaultBranch && branchName !== defaultBranch) {
2306
+ canonicalLane2 = await resolveOrEnsureLaneBinding({
2307
+ api: params.api,
2308
+ projectId: explicitProjectId ?? void 0,
2309
+ repoFingerprint,
2310
+ remoteUrl,
2311
+ defaultBranch,
2312
+ branchName: defaultBranch,
2313
+ operation: "`remix collab init`"
2314
+ });
2315
+ const lane = await resolveOrEnsureLaneBinding({
2316
+ api: params.api,
2317
+ projectId: canonicalLane2.projectId ?? explicitProjectId ?? void 0,
2318
+ repoFingerprint,
2319
+ remoteUrl,
2320
+ defaultBranch,
2321
+ branchName,
2322
+ operation: "`remix collab init`"
2323
+ });
2324
+ boundProjectId2 = lane.projectId ?? boundProjectId2;
2325
+ boundCurrentAppId2 = lane.currentAppId ?? boundCurrentAppId2;
2326
+ boundUpstreamAppId2 = lane.upstreamAppId ?? boundUpstreamAppId2;
2327
+ boundThreadId2 = lane.threadId ?? boundThreadId2;
2328
+ boundLaneId2 = lane.laneId ?? null;
2329
+ } else {
2330
+ canonicalLane2 = await resolveOrEnsureLaneBinding({
2331
+ api: params.api,
2332
+ projectId: explicitProjectId ?? void 0,
2333
+ repoFingerprint,
2334
+ remoteUrl,
2335
+ defaultBranch,
2336
+ branchName,
2337
+ operation: "`remix collab init`"
2338
+ });
2339
+ boundProjectId2 = canonicalLane2.projectId ?? boundProjectId2;
2340
+ boundCurrentAppId2 = canonicalLane2.currentAppId ?? boundCurrentAppId2;
2341
+ boundUpstreamAppId2 = canonicalLane2.upstreamAppId ?? boundUpstreamAppId2;
2342
+ boundThreadId2 = canonicalLane2.threadId ?? boundThreadId2;
2343
+ boundLaneId2 = canonicalLane2.laneId ?? null;
2344
+ }
2345
+ const readyApp = await pollAppReady(params.api, boundCurrentAppId2);
2346
+ boundProjectId2 = String(readyApp.projectId ?? boundProjectId2);
2347
+ boundThreadId2 = readyApp.threadId ? String(readyApp.threadId) : boundThreadId2;
2348
+ await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
2349
+ operation: "`remix collab init`",
2350
+ recoveryHint: "The repository changed while the local binding was being initialized. Review the local changes and rerun `remix collab init`."
2351
+ });
2352
+ await writeCollabBinding(repoRoot, {
2353
+ projectId: canonicalLane2?.projectId ?? explicitProjectId ?? null,
2354
+ currentAppId: canonicalLane2?.currentAppId ?? explicitRoot.currentAppId,
2355
+ upstreamAppId: canonicalLane2?.upstreamAppId ?? canonicalLane2?.currentAppId ?? explicitRoot.upstreamAppId ?? explicitRoot.currentAppId,
2356
+ threadId: canonicalLane2?.threadId ?? explicitRoot.threadId,
2357
+ repoFingerprint: canonicalLane2?.repoFingerprint ?? explicitRoot.repoFingerprint ?? repoFingerprint,
2358
+ remoteUrl: canonicalLane2?.remoteUrl ?? explicitRoot.remoteUrl ?? remoteUrl,
2359
+ defaultBranch: canonicalLane2?.defaultBranch ?? explicitRoot.defaultBranch ?? defaultBranch ?? null,
2360
+ laneId: canonicalLane2?.laneId ?? explicitRoot.laneId,
2361
+ branchName: defaultBranch,
2362
+ bindingMode: "explicit_root"
1768
2363
  });
1769
- const existing = bindingResp?.responseObject;
1770
- if (existing?.projectId && existing?.appId) {
2364
+ if (defaultBranch && branchName !== defaultBranch) {
2365
+ await writeCollabBinding(repoRoot, {
2366
+ projectId: boundProjectId2,
2367
+ currentAppId: boundCurrentAppId2,
2368
+ upstreamAppId: boundUpstreamAppId2,
2369
+ threadId: boundThreadId2,
2370
+ repoFingerprint,
2371
+ remoteUrl,
2372
+ defaultBranch: defaultBranch ?? null,
2373
+ laneId: boundLaneId2,
2374
+ branchName,
2375
+ bindingMode: "lane"
2376
+ });
2377
+ }
2378
+ return {
2379
+ reused: true,
2380
+ projectId: boundProjectId2 ?? explicitProjectId ?? "",
2381
+ appId: boundCurrentAppId2,
2382
+ dashboardUrl: buildDashboardAppUrl(boundCurrentAppId2),
2383
+ upstreamAppId: boundUpstreamAppId2,
2384
+ bindingPath: path5.join(repoRoot, ".remix", "config.json"),
2385
+ repoRoot,
2386
+ bindingMode: defaultBranch && branchName !== defaultBranch ? "lane" : "explicit_root",
2387
+ createdCanonicalFamily: false,
2388
+ ...warnings.length > 0 ? { warnings } : {}
2389
+ };
2390
+ }
2391
+ if (!params.forceNew) {
2392
+ const bindingResolution = resolveProjectBindingResult(
2393
+ await params.api.resolveProjectBinding({
2394
+ repoFingerprint,
2395
+ remoteUrl: remoteUrl ?? void 0,
2396
+ branchName: branchName ?? void 0
2397
+ })
2398
+ );
2399
+ if (bindingResolution.status === "ambiguous_family_selection") {
2400
+ throwAmbiguousFamilyError({
2401
+ repoFingerprint,
2402
+ remoteUrl,
2403
+ projectIds: bindingResolution.projectIds,
2404
+ candidateCount: bindingResolution.candidateCount
2405
+ });
2406
+ }
2407
+ if (bindingResolution.status === "resolved") {
2408
+ const initialProjectId = bindingResolution.projectId;
2409
+ const initialCurrentAppId = bindingResolution.appId;
2410
+ const initialUpstreamAppId = bindingResolution.upstreamAppId;
2411
+ const initialThreadId = bindingResolution.threadId;
2412
+ let boundProjectId2 = initialProjectId;
2413
+ let boundCurrentAppId2 = initialCurrentAppId;
2414
+ let boundUpstreamAppId2 = initialUpstreamAppId;
2415
+ let boundThreadId2 = initialThreadId;
2416
+ let boundLaneId2 = null;
2417
+ let canonicalLane2 = null;
2418
+ if (branchName) {
2419
+ if (defaultBranch && branchName !== defaultBranch) {
2420
+ canonicalLane2 = await resolveOrEnsureLaneBinding({
2421
+ api: params.api,
2422
+ repoFingerprint,
2423
+ remoteUrl,
2424
+ defaultBranch,
2425
+ branchName: defaultBranch,
2426
+ seedAppId: initialCurrentAppId,
2427
+ operation: "`remix collab init`"
2428
+ });
2429
+ const lane = await resolveOrEnsureLaneBinding({
2430
+ api: params.api,
2431
+ projectId: canonicalLane2.projectId ?? void 0,
2432
+ repoFingerprint,
2433
+ remoteUrl,
2434
+ defaultBranch,
2435
+ branchName,
2436
+ operation: "`remix collab init`"
2437
+ });
2438
+ boundProjectId2 = lane.projectId ?? boundProjectId2;
2439
+ boundCurrentAppId2 = lane.currentAppId ?? boundCurrentAppId2;
2440
+ boundUpstreamAppId2 = lane.upstreamAppId ?? boundUpstreamAppId2;
2441
+ boundThreadId2 = lane.threadId ?? boundThreadId2;
2442
+ boundLaneId2 = lane.laneId ?? null;
2443
+ } else {
2444
+ const lane = await resolveOrEnsureLaneBinding({
2445
+ api: params.api,
2446
+ repoFingerprint,
2447
+ remoteUrl,
2448
+ defaultBranch,
2449
+ branchName,
2450
+ seedAppId: initialCurrentAppId,
2451
+ operation: "`remix collab init`"
2452
+ });
2453
+ canonicalLane2 = lane;
2454
+ boundProjectId2 = lane.projectId ?? boundProjectId2;
2455
+ boundCurrentAppId2 = lane.currentAppId ?? boundCurrentAppId2;
2456
+ boundUpstreamAppId2 = lane.upstreamAppId ?? boundUpstreamAppId2;
2457
+ boundThreadId2 = lane.threadId ?? boundThreadId2;
2458
+ boundLaneId2 = lane.laneId ?? null;
2459
+ }
2460
+ }
2461
+ if (boundCurrentAppId2) {
2462
+ const readyApp = await pollAppReady(params.api, boundCurrentAppId2);
2463
+ boundProjectId2 = String(readyApp.projectId ?? boundProjectId2);
2464
+ boundThreadId2 = readyApp.threadId ? String(readyApp.threadId) : boundThreadId2;
2465
+ }
1771
2466
  await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
1772
2467
  operation: "`remix collab init`",
1773
2468
  recoveryHint: "The repository changed while the local binding was being initialized. Review the local changes and rerun `remix collab init`."
1774
2469
  });
2470
+ if (canonicalLane2 && defaultBranch && branchName && branchName !== defaultBranch) {
2471
+ await writeCollabBinding(repoRoot, {
2472
+ projectId: canonicalLane2.projectId ?? null,
2473
+ currentAppId: canonicalLane2.currentAppId ?? boundCurrentAppId2,
2474
+ upstreamAppId: canonicalLane2.upstreamAppId ?? canonicalLane2.currentAppId ?? boundCurrentAppId2,
2475
+ threadId: canonicalLane2.threadId ?? null,
2476
+ repoFingerprint: canonicalLane2.repoFingerprint ?? repoFingerprint,
2477
+ remoteUrl: canonicalLane2.remoteUrl ?? remoteUrl,
2478
+ defaultBranch: canonicalLane2.defaultBranch ?? defaultBranch,
2479
+ laneId: canonicalLane2.laneId ?? null,
2480
+ branchName: defaultBranch,
2481
+ bindingMode: "lane"
2482
+ });
2483
+ }
1775
2484
  const bindingPath2 = await writeCollabBinding(repoRoot, {
1776
- projectId: String(existing.projectId),
1777
- currentAppId: String(existing.appId),
1778
- upstreamAppId: String(existing.upstreamAppId ?? existing.appId),
1779
- threadId: existing.threadId ? String(existing.threadId) : null,
2485
+ projectId: boundProjectId2,
2486
+ currentAppId: boundCurrentAppId2,
2487
+ upstreamAppId: boundUpstreamAppId2,
2488
+ threadId: boundThreadId2,
1780
2489
  repoFingerprint,
1781
2490
  remoteUrl,
1782
2491
  defaultBranch: defaultBranch ?? null,
1783
- preferredBranch
2492
+ laneId: boundLaneId2,
2493
+ branchName,
2494
+ bindingMode: "lane"
1784
2495
  });
1785
2496
  return {
1786
2497
  reused: true,
1787
- projectId: String(existing.projectId),
1788
- appId: String(existing.appId),
1789
- dashboardUrl: buildDashboardAppUrl(String(existing.appId)),
1790
- upstreamAppId: String(existing.upstreamAppId ?? existing.appId),
2498
+ projectId: boundProjectId2,
2499
+ appId: boundCurrentAppId2,
2500
+ dashboardUrl: buildDashboardAppUrl(boundCurrentAppId2),
2501
+ upstreamAppId: boundUpstreamAppId2,
1791
2502
  bindingPath: bindingPath2,
1792
2503
  repoRoot,
2504
+ bindingMode: "lane",
2505
+ createdCanonicalFamily: false,
1793
2506
  ...warnings.length > 0 ? { warnings } : {}
1794
2507
  };
1795
2508
  }
@@ -1817,6 +2530,7 @@ async function collabInit(params) {
1817
2530
  path: params.path?.trim() || void 0,
1818
2531
  platform: "generic",
1819
2532
  isPublic: false,
2533
+ branch: defaultBranch && branchName && branchName !== defaultBranch ? defaultBranch : currentBranch ?? void 0,
1820
2534
  remoteUrl: remoteUrl ?? void 0,
1821
2535
  defaultBranch: defaultBranch ?? void 0,
1822
2536
  repoFingerprint,
@@ -1824,31 +2538,136 @@ async function collabInit(params) {
1824
2538
  });
1825
2539
  const imported = unwrapResponseObject(importResp, "import");
1826
2540
  const app = await pollAppReady(params.api, String(imported.appId));
2541
+ let boundProjectId = String(app.projectId);
2542
+ let boundCurrentAppId = String(app.id);
2543
+ let boundUpstreamAppId = String(app.id);
2544
+ let boundThreadId = app.threadId ? String(app.threadId) : null;
2545
+ let boundLaneId = null;
2546
+ let canonicalLane = null;
2547
+ if (branchName) {
2548
+ if (defaultBranch && branchName !== defaultBranch) {
2549
+ canonicalLane = await resolveOrEnsureLaneBinding({
2550
+ api: params.api,
2551
+ projectId: boundProjectId,
2552
+ repoFingerprint,
2553
+ remoteUrl,
2554
+ defaultBranch,
2555
+ branchName: defaultBranch,
2556
+ seedAppId: String(app.id),
2557
+ operation: "`remix collab init`"
2558
+ });
2559
+ const lane = await resolveOrEnsureLaneBinding({
2560
+ api: params.api,
2561
+ projectId: canonicalLane.projectId ?? boundProjectId,
2562
+ repoFingerprint,
2563
+ remoteUrl,
2564
+ defaultBranch,
2565
+ branchName,
2566
+ operation: "`remix collab init`"
2567
+ });
2568
+ boundProjectId = lane.projectId ?? boundProjectId;
2569
+ boundCurrentAppId = lane.currentAppId ?? boundCurrentAppId;
2570
+ boundUpstreamAppId = lane.upstreamAppId ?? boundUpstreamAppId;
2571
+ boundThreadId = lane.threadId ?? boundThreadId;
2572
+ boundLaneId = lane.laneId ?? null;
2573
+ } else {
2574
+ const lane = await resolveOrEnsureLaneBinding({
2575
+ api: params.api,
2576
+ projectId: boundProjectId,
2577
+ repoFingerprint,
2578
+ remoteUrl,
2579
+ defaultBranch,
2580
+ branchName,
2581
+ seedAppId: String(app.id),
2582
+ operation: "`remix collab init`"
2583
+ });
2584
+ canonicalLane = lane;
2585
+ boundProjectId = lane.projectId ?? boundProjectId;
2586
+ boundCurrentAppId = lane.currentAppId ?? boundCurrentAppId;
2587
+ boundUpstreamAppId = lane.upstreamAppId ?? boundUpstreamAppId;
2588
+ boundThreadId = lane.threadId ?? boundThreadId;
2589
+ boundLaneId = lane.laneId ?? null;
2590
+ }
2591
+ }
2592
+ if (boundCurrentAppId) {
2593
+ const readyApp = await pollAppReady(params.api, boundCurrentAppId);
2594
+ boundProjectId = String(readyApp.projectId ?? boundProjectId);
2595
+ boundThreadId = readyApp.threadId ? String(readyApp.threadId) : boundThreadId;
2596
+ }
1827
2597
  await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
1828
2598
  operation: "`remix collab init`",
1829
2599
  recoveryHint: "The repository changed before the Remix binding was written. Review the local changes and rerun `remix collab init`."
1830
2600
  });
1831
- const bindingPath = await writeCollabBinding(repoRoot, {
1832
- projectId: String(app.projectId),
1833
- currentAppId: String(app.id),
1834
- upstreamAppId: String(app.id),
1835
- threadId: app.threadId ? String(app.threadId) : null,
1836
- repoFingerprint,
1837
- remoteUrl,
1838
- defaultBranch: defaultBranch ?? null,
1839
- preferredBranch
1840
- });
2601
+ const bindingMode = params.forceNew && (!defaultBranch || branchName === defaultBranch) ? "explicit_root" : "lane";
2602
+ let bindingPath;
2603
+ if (params.forceNew && defaultBranch && canonicalLane) {
2604
+ const canonicalBinding = branchBindingFromLane(canonicalLane, "explicit_root", {
2605
+ projectId: canonicalLane.projectId ?? boundProjectId,
2606
+ currentAppId: canonicalLane.currentAppId ?? boundCurrentAppId,
2607
+ upstreamAppId: canonicalLane.upstreamAppId ?? canonicalLane.currentAppId ?? boundCurrentAppId,
2608
+ threadId: canonicalLane.threadId ?? boundThreadId
2609
+ });
2610
+ const branchBindings = {
2611
+ [defaultBranch]: canonicalBinding
2612
+ };
2613
+ if (branchName && branchName !== defaultBranch) {
2614
+ branchBindings[branchName] = {
2615
+ projectId: boundProjectId,
2616
+ currentAppId: boundCurrentAppId,
2617
+ upstreamAppId: boundUpstreamAppId,
2618
+ threadId: boundThreadId,
2619
+ laneId: boundLaneId,
2620
+ bindingMode: "lane"
2621
+ };
2622
+ }
2623
+ bindingPath = await writeCollabBindingSnapshot({
2624
+ repoRoot,
2625
+ repoFingerprint,
2626
+ remoteUrl,
2627
+ defaultBranch,
2628
+ branchBindings,
2629
+ explicitRootBinding: canonicalBinding
2630
+ });
2631
+ } else {
2632
+ if (canonicalLane && defaultBranch && branchName && branchName !== defaultBranch) {
2633
+ await writeCollabBinding(repoRoot, {
2634
+ projectId: canonicalLane.projectId ?? null,
2635
+ currentAppId: canonicalLane.currentAppId ?? boundCurrentAppId,
2636
+ upstreamAppId: canonicalLane.upstreamAppId ?? canonicalLane.currentAppId ?? boundCurrentAppId,
2637
+ threadId: canonicalLane.threadId ?? null,
2638
+ repoFingerprint: canonicalLane.repoFingerprint ?? repoFingerprint,
2639
+ remoteUrl: canonicalLane.remoteUrl ?? remoteUrl,
2640
+ defaultBranch: canonicalLane.defaultBranch ?? defaultBranch,
2641
+ laneId: canonicalLane.laneId ?? null,
2642
+ branchName: defaultBranch,
2643
+ bindingMode: params.forceNew ? "explicit_root" : "lane"
2644
+ });
2645
+ }
2646
+ bindingPath = await writeCollabBinding(repoRoot, {
2647
+ projectId: boundProjectId,
2648
+ currentAppId: boundCurrentAppId,
2649
+ upstreamAppId: boundUpstreamAppId,
2650
+ threadId: boundThreadId,
2651
+ repoFingerprint,
2652
+ remoteUrl,
2653
+ defaultBranch: defaultBranch ?? null,
2654
+ laneId: boundLaneId,
2655
+ branchName,
2656
+ bindingMode
2657
+ });
2658
+ }
1841
2659
  return {
1842
2660
  reused: false,
1843
- projectId: String(app.projectId),
1844
- appId: String(app.id),
1845
- dashboardUrl: buildDashboardAppUrl(String(app.id)),
1846
- upstreamAppId: String(app.id),
2661
+ projectId: boundProjectId,
2662
+ appId: boundCurrentAppId,
2663
+ dashboardUrl: buildDashboardAppUrl(boundCurrentAppId),
2664
+ upstreamAppId: boundUpstreamAppId,
1847
2665
  bindingPath,
1848
2666
  repoRoot,
2667
+ bindingMode,
2668
+ createdCanonicalFamily: Boolean(params.forceNew),
1849
2669
  remoteUrl,
1850
2670
  defaultBranch,
1851
- preferredBranch,
1852
2671
  ...warnings.length > 0 ? { warnings } : {}
1853
2672
  };
1854
2673
  }
@@ -1903,7 +2722,11 @@ import os4 from "os";
1903
2722
  import path6 from "path";
1904
2723
  async function collabReconcile(params) {
1905
2724
  const repoRoot = await findGitRoot(params.cwd);
1906
- const binding = await readCollabBinding(repoRoot);
2725
+ const binding = await ensureActiveLaneBinding({
2726
+ repoRoot,
2727
+ api: params.api,
2728
+ operation: "`remix collab reconcile`"
2729
+ });
1907
2730
  if (!binding) {
1908
2731
  throw new RemixError("Repository is not bound to Remix.", {
1909
2732
  exitCode: 2,
@@ -1912,9 +2735,9 @@ async function collabReconcile(params) {
1912
2735
  }
1913
2736
  await ensureCleanWorktree(repoRoot, "`remix collab reconcile`");
1914
2737
  const branch = await requireCurrentBranch(repoRoot);
1915
- assertPreferredBranchMatch({
2738
+ assertBoundBranchMatch({
1916
2739
  currentBranch: branch,
1917
- preferredBranch: binding.preferredBranch,
2740
+ branchName: binding.branchName,
1918
2741
  allowBranchMismatch: params.allowBranchMismatch,
1919
2742
  operation: "`remix collab reconcile`"
1920
2743
  });
@@ -2023,9 +2846,9 @@ async function collabReconcile(params) {
2023
2846
  });
2024
2847
  await ensureCleanWorktree(lockedRepoRoot, "`remix collab reconcile`");
2025
2848
  const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
2026
- assertPreferredBranchMatch({
2849
+ assertBoundBranchMatch({
2027
2850
  currentBranch: lockedBranch,
2028
- preferredBranch: binding.preferredBranch,
2851
+ branchName: binding.branchName,
2029
2852
  allowBranchMismatch: params.allowBranchMismatch,
2030
2853
  operation: "`remix collab reconcile`"
2031
2854
  });
@@ -2084,31 +2907,65 @@ async function collabRemix(params) {
2084
2907
  hint: "Pass the source app id to remix."
2085
2908
  });
2086
2909
  }
2087
- const forkResp = await params.api.forkApp(sourceAppId, { name: params.name?.trim() || void 0, platform: "generic" });
2910
+ const sourceApp = await pollAppReady(params.api, sourceAppId);
2911
+ const sourceCollab = sourceApp.collab && typeof sourceApp.collab === "object" ? sourceApp.collab : null;
2912
+ const sourceSource = sourceApp.source && typeof sourceApp.source === "object" ? sourceApp.source : null;
2913
+ const sourceBranchName = typeof sourceCollab?.branchName === "string" && sourceCollab.branchName.trim() || typeof sourceSource?.branch === "string" && sourceSource.branch.trim() || typeof sourceSource?.defaultBranch === "string" && sourceSource.defaultBranch.trim() || void 0;
2914
+ const forkResp = await params.api.forkApp(sourceAppId, {
2915
+ name: params.name?.trim() || void 0,
2916
+ platform: "generic",
2917
+ branchName: sourceBranchName
2918
+ });
2088
2919
  const forked = unwrapResponseObject(forkResp, "fork");
2089
2920
  const app = await pollAppReady(params.api, String(forked.id));
2921
+ const collab = app.collab && typeof app.collab === "object" ? app.collab : null;
2922
+ const source = app.source && typeof app.source === "object" ? app.source : null;
2923
+ const authoritativeBranchName = typeof collab?.branchName === "string" && collab.branchName.trim() || typeof source?.branch === "string" && source.branch.trim() || typeof source?.defaultBranch === "string" && source.defaultBranch.trim() || sourceBranchName || null;
2924
+ const authoritativeRemoteUrl = typeof source?.remoteUrl === "string" && source.remoteUrl.trim() || null;
2925
+ const authoritativeDefaultBranch = typeof source?.defaultBranch === "string" && source.defaultBranch.trim() || null;
2090
2926
  const checkout = await materializeAppCheckout({
2091
2927
  api: params.api,
2092
2928
  cwd: params.cwd,
2093
2929
  appId: String(app.id),
2094
2930
  outputDir: params.outputDir ?? null,
2095
- defaultDirName: sanitizeCheckoutDirName(String(params.name?.trim() || app.name || app.id))
2931
+ defaultDirName: sanitizeCheckoutDirName(String(params.name?.trim() || app.name || app.id)),
2932
+ expectedBranchName: authoritativeBranchName,
2933
+ expectedRemoteUrl: authoritativeRemoteUrl,
2934
+ expectedDefaultBranch: authoritativeDefaultBranch
2096
2935
  });
2097
- const bindingPath = await writeCollabBinding(checkout.repoRoot, {
2936
+ const laneId = typeof collab?.laneId === "string" ? collab.laneId : null;
2937
+ const repoFingerprint = typeof source?.repoFingerprint === "string" ? source.repoFingerprint : checkout.repoFingerprint;
2938
+ const remoteUrl = typeof source?.remoteUrl === "string" ? source.remoteUrl : checkout.remoteUrl;
2939
+ const defaultBranch = typeof source?.defaultBranch === "string" ? source.defaultBranch : checkout.defaultBranch;
2940
+ const branchName = authoritativeBranchName ?? checkout.branchName;
2941
+ const authoritativeLane = await resolveProjectLaneIfAuthoritative(params.api, {
2098
2942
  projectId: String(app.projectId),
2943
+ repoFingerprint,
2944
+ remoteUrl,
2945
+ defaultBranch,
2946
+ branchName,
2099
2947
  currentAppId: String(app.id),
2100
- upstreamAppId: String(app.forkedFromAppId ?? sourceAppId),
2101
- threadId: app.threadId ? String(app.threadId) : null,
2102
- repoFingerprint: checkout.repoFingerprint,
2103
- remoteUrl: checkout.remoteUrl,
2104
- defaultBranch: checkout.defaultBranch,
2105
- preferredBranch: checkout.preferredBranch
2948
+ expectedUpstreamAppId: String(app.forkedFromAppId ?? sourceAppId),
2949
+ createIfMissing: true,
2950
+ seedAppId: String(app.id)
2951
+ });
2952
+ const bindingPath = await writeCollabBinding(checkout.repoRoot, {
2953
+ projectId: authoritativeLane?.projectId ?? String(app.projectId),
2954
+ currentAppId: authoritativeLane?.currentAppId ?? String(app.id),
2955
+ upstreamAppId: authoritativeLane?.upstreamAppId ?? String(app.forkedFromAppId ?? sourceAppId),
2956
+ threadId: authoritativeLane?.threadId ?? (app.threadId ? String(app.threadId) : null),
2957
+ repoFingerprint: authoritativeLane?.repoFingerprint ?? repoFingerprint,
2958
+ remoteUrl: authoritativeLane?.remoteUrl ?? remoteUrl,
2959
+ defaultBranch: authoritativeLane?.defaultBranch ?? defaultBranch,
2960
+ laneId: authoritativeLane?.laneId ?? laneId,
2961
+ branchName: authoritativeLane?.branchName ?? branchName,
2962
+ bindingMode: "lane"
2106
2963
  });
2107
2964
  return {
2108
- appId: String(app.id),
2109
- dashboardUrl: buildDashboardAppUrl(String(app.id)),
2110
- projectId: String(app.projectId),
2111
- upstreamAppId: String(app.forkedFromAppId ?? sourceAppId),
2965
+ appId: authoritativeLane?.currentAppId ?? String(app.id),
2966
+ dashboardUrl: buildDashboardAppUrl(authoritativeLane?.currentAppId ?? String(app.id)),
2967
+ projectId: authoritativeLane?.projectId ?? String(app.projectId),
2968
+ upstreamAppId: authoritativeLane?.upstreamAppId ?? String(app.forkedFromAppId ?? sourceAppId),
2112
2969
  bindingPath,
2113
2970
  repoRoot: checkout.repoRoot
2114
2971
  };
@@ -2117,7 +2974,11 @@ async function collabRemix(params) {
2117
2974
  // src/application/collab/collabRequestMerge.ts
2118
2975
  async function collabRequestMerge(params) {
2119
2976
  const repoRoot = await findGitRoot(params.cwd);
2120
- const binding = await readCollabBinding(repoRoot);
2977
+ const binding = await ensureActiveLaneBinding({
2978
+ repoRoot,
2979
+ api: params.api,
2980
+ operation: "`remix collab request-merge`"
2981
+ });
2121
2982
  if (!binding) throw new RemixError("Repository is not bound to Remix.", { exitCode: 2 });
2122
2983
  const resp = await params.api.openMergeRequest(binding.currentAppId);
2123
2984
  return unwrapResponseObject(resp, "merge request");
@@ -2152,7 +3013,9 @@ function createBaseStatus() {
2152
3013
  repoFingerprint: null,
2153
3014
  remoteUrl: null,
2154
3015
  defaultBranch: null,
2155
- preferredBranch: null
3016
+ laneId: null,
3017
+ branchName: null,
3018
+ bindingMode: null
2156
3019
  },
2157
3020
  remote: {
2158
3021
  checked: false,
@@ -2216,12 +3079,13 @@ async function collabStatus(params) {
2216
3079
  }
2217
3080
  status.repo.isGitRepo = true;
2218
3081
  status.repo.repoRoot = repoRoot;
2219
- const [branch, headCommitHash, worktreeStatus, binding] = await Promise.all([
3082
+ const [branch, initialHeadCommitHash, worktreeStatus, bindingResolution] = await Promise.all([
2220
3083
  getCurrentBranch(repoRoot),
2221
3084
  getHeadCommitHash(repoRoot),
2222
3085
  getWorktreeStatus(repoRoot),
2223
- readCollabBinding(repoRoot)
3086
+ resolveActiveLaneBinding({ repoRoot, api: params.api ?? void 0 })
2224
3087
  ]);
3088
+ let headCommitHash = initialHeadCommitHash;
2225
3089
  status.repo.branch = branch;
2226
3090
  status.repo.branchMismatch = false;
2227
3091
  status.repo.headCommitHash = headCommitHash;
@@ -2235,13 +3099,61 @@ async function collabStatus(params) {
2235
3099
  if (!status.repo.worktree.isClean) addWarning(status, "Working tree has local changes.");
2236
3100
  if (!branch) addWarning(status, "Repository is in a detached HEAD state.");
2237
3101
  if (!headCommitHash) addWarning(status, "Failed to resolve local HEAD commit.");
2238
- if (!binding) {
3102
+ if (bindingResolution.status === "not_bound") {
2239
3103
  status.binding.path = null;
2240
3104
  addBlockedReason(status.sync, "not_bound");
2241
3105
  addBlockedReason(status.reconcile, "not_bound");
2242
3106
  status.recommendedAction = "init";
2243
3107
  return status;
2244
3108
  }
3109
+ if (bindingResolution.status === "missing_branch_binding") {
3110
+ status.binding = {
3111
+ isBound: true,
3112
+ path: getCollabBindingPath(repoRoot),
3113
+ projectId: bindingResolution.projectId,
3114
+ currentAppId: null,
3115
+ upstreamAppId: bindingResolution.upstreamAppId,
3116
+ isRemix: null,
3117
+ threadId: bindingResolution.threadId,
3118
+ repoFingerprint: bindingResolution.repoFingerprint,
3119
+ remoteUrl: bindingResolution.remoteUrl,
3120
+ defaultBranch: bindingResolution.defaultBranch,
3121
+ laneId: null,
3122
+ branchName: bindingResolution.currentBranch,
3123
+ bindingMode: "lane"
3124
+ };
3125
+ addBlockedReason(status.sync, "branch_binding_missing");
3126
+ addBlockedReason(status.reconcile, "branch_binding_missing");
3127
+ addWarning(status, `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`);
3128
+ status.recommendedAction = "init";
3129
+ return status;
3130
+ }
3131
+ if (bindingResolution.status === "ambiguous_family_selection") {
3132
+ status.binding = {
3133
+ isBound: true,
3134
+ path: getCollabBindingPath(repoRoot),
3135
+ projectId: null,
3136
+ currentAppId: null,
3137
+ upstreamAppId: null,
3138
+ isRemix: null,
3139
+ threadId: null,
3140
+ repoFingerprint: bindingResolution.repoFingerprint,
3141
+ remoteUrl: bindingResolution.remoteUrl,
3142
+ defaultBranch: bindingResolution.defaultBranch,
3143
+ laneId: null,
3144
+ branchName: bindingResolution.currentBranch,
3145
+ bindingMode: null
3146
+ };
3147
+ addBlockedReason(status.sync, "family_ambiguous");
3148
+ addBlockedReason(status.reconcile, "family_ambiguous");
3149
+ addWarning(
3150
+ status,
3151
+ `Multiple canonical Remix families match ${bindingResolution.currentBranch ?? "the current branch"}. Switch to a checkout already bound to the intended family or run \`remix collab init --force-new\`.`
3152
+ );
3153
+ status.recommendedAction = "choose_family";
3154
+ return status;
3155
+ }
3156
+ const binding = bindingResolution.binding;
2245
3157
  status.binding = {
2246
3158
  isBound: true,
2247
3159
  path: getCollabBindingPath(repoRoot),
@@ -2253,13 +3165,21 @@ async function collabStatus(params) {
2253
3165
  repoFingerprint: binding.repoFingerprint,
2254
3166
  remoteUrl: binding.remoteUrl,
2255
3167
  defaultBranch: binding.defaultBranch,
2256
- preferredBranch: binding.preferredBranch
3168
+ laneId: binding.laneId,
3169
+ branchName: binding.branchName,
3170
+ bindingMode: binding.bindingMode
2257
3171
  };
2258
- status.repo.branchMismatch = !isPreferredBranchMatch(branch, binding.preferredBranch);
3172
+ status.repo.branchMismatch = !isBoundBranchMatch(branch, binding.branchName);
2259
3173
  if (status.repo.branchMismatch) {
2260
3174
  addWarning(
2261
3175
  status,
2262
- `Current branch ${branch ?? "(detached)"} does not match preferred branch ${binding.preferredBranch ?? "(unset)"}.`
3176
+ `Current branch ${branch ?? "(detached)"} does not match bound branch ${binding.branchName ?? "(unset)"}.`
3177
+ );
3178
+ }
3179
+ if (bindingResolution.status === "binding_conflict") {
3180
+ addWarning(
3181
+ status,
3182
+ `Local binding app ${binding.currentAppId} conflicts with server-resolved app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"} for branch ${bindingResolution.currentBranch ?? "(detached)"}.`
2263
3183
  );
2264
3184
  }
2265
3185
  if (!params.api) {
@@ -2307,8 +3227,14 @@ async function collabStatus(params) {
2307
3227
  const syncResp = syncResult.value;
2308
3228
  if (syncResp) {
2309
3229
  const sync = unwrapResponseObject(syncResp, "sync result");
3230
+ const latestHeadCommitHash = sync.targetCommitHash && sync.status !== "up_to_date" ? await getHeadCommitHash(repoRoot) : headCommitHash;
3231
+ if (latestHeadCommitHash) {
3232
+ headCommitHash = latestHeadCommitHash;
3233
+ status.repo.headCommitHash = latestHeadCommitHash;
3234
+ }
3235
+ const normalizedSyncStatus = headCommitHash && sync.targetCommitHash && headCommitHash === sync.targetCommitHash ? "up_to_date" : sync.status;
2310
3236
  status.sync.checked = true;
2311
- status.sync.status = sync.status;
3237
+ status.sync.status = normalizedSyncStatus;
2312
3238
  status.sync.warnings = sync.warnings;
2313
3239
  status.sync.targetCommitHash = sync.targetCommitHash;
2314
3240
  status.sync.targetCommitId = sync.targetCommitId;
@@ -2316,9 +3242,9 @@ async function collabStatus(params) {
2316
3242
  if (!status.repo.worktree.isClean) addBlockedReason(status.sync, "dirty_worktree");
2317
3243
  if (!branch) addBlockedReason(status.sync, "detached_head");
2318
3244
  if (status.repo.branchMismatch) addBlockedReason(status.sync, "branch_mismatch");
2319
- if (sync.status === "conflict_risk") addBlockedReason(status.sync, "metadata_conflict");
3245
+ if (normalizedSyncStatus === "conflict_risk") addBlockedReason(status.sync, "metadata_conflict");
2320
3246
  status.sync.canApply = status.sync.status === "ready_to_fast_forward" && status.repo.worktree.isClean && Boolean(branch) && !status.sync.blockedReasons.includes("metadata_conflict");
2321
- if (sync.status === "conflict_risk") {
3247
+ if (normalizedSyncStatus === "conflict_risk") {
2322
3248
  status.reconcile.checked = true;
2323
3249
  status.reconcile.status = "metadata_conflict";
2324
3250
  status.reconcile.warnings = sync.warnings;
@@ -2328,7 +3254,7 @@ async function collabStatus(params) {
2328
3254
  if (!status.repo.worktree.isClean) addBlockedReason(status.reconcile, "dirty_worktree");
2329
3255
  if (!branch) addBlockedReason(status.reconcile, "detached_head");
2330
3256
  if (status.repo.branchMismatch) addBlockedReason(status.reconcile, "branch_mismatch");
2331
- } else if (sync.status === "base_unknown") {
3257
+ } else if (normalizedSyncStatus === "base_unknown") {
2332
3258
  try {
2333
3259
  const preflightResp = await params.api.preflightAppReconcile(binding.currentAppId, {
2334
3260
  localHeadCommitHash: headCommitHash,
@@ -2376,6 +3302,8 @@ async function collabStatus(params) {
2376
3302
  status.recommendedAction = "reconcile";
2377
3303
  } else if ((status.remote.incomingOpenMergeRequestCount ?? 0) > 0) {
2378
3304
  status.recommendedAction = "review_queue";
3305
+ } else if (status.sync.blockedReasons.includes("family_ambiguous") || status.reconcile.blockedReasons.includes("family_ambiguous")) {
3306
+ status.recommendedAction = "choose_family";
2379
3307
  } else {
2380
3308
  status.recommendedAction = "no_action";
2381
3309
  }
@@ -2385,7 +3313,11 @@ async function collabStatus(params) {
2385
3313
  // src/application/collab/collabSyncUpstream.ts
2386
3314
  async function collabSyncUpstream(params) {
2387
3315
  const repoRoot = await findGitRoot(params.cwd);
2388
- const binding = await readCollabBinding(repoRoot);
3316
+ const binding = await ensureActiveLaneBinding({
3317
+ repoRoot,
3318
+ api: params.api,
3319
+ operation: "`remix collab sync-upstream`"
3320
+ });
2389
3321
  if (!binding) {
2390
3322
  throw new RemixError("Repository is not bound to Remix.", {
2391
3323
  exitCode: 2,