@remixhq/core 0.1.11 → 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/binding.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- type BranchBindingMode = "legacy" | "lane";
1
+ type BranchBindingMode = "legacy" | "lane" | "explicit_root";
2
2
  type CollabBinding = {
3
3
  schemaVersion: 3;
4
4
  projectId: string | null;
package/dist/binding.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  getCollabBindingPath,
3
3
  readCollabBinding,
4
4
  writeCollabBinding
5
- } from "./chunk-4L3ZBZUQ.js";
5
+ } from "./chunk-IXWQWFYT.js";
6
6
  import "./chunk-RREREIGW.js";
7
7
  import "./chunk-GC2MOT3U.js";
8
8
  import "./chunk-YZ34ICNN.js";
@@ -0,0 +1,342 @@
1
+ import {
2
+ getCurrentBranch
3
+ } from "./chunk-RREREIGW.js";
4
+ import {
5
+ RemixError
6
+ } from "./chunk-YZ34ICNN.js";
7
+
8
+ // src/infrastructure/binding/collabBindingStore.ts
9
+ import fs2 from "fs/promises";
10
+ import path2 from "path";
11
+
12
+ // src/shared/fs.ts
13
+ import fs from "fs/promises";
14
+ import path from "path";
15
+ async function reserveDirectory(targetDir) {
16
+ try {
17
+ await fs.mkdir(targetDir);
18
+ return targetDir;
19
+ } catch (error) {
20
+ if (error?.code === "EEXIST") {
21
+ throw new RemixError("Output directory already exists.", {
22
+ exitCode: 2,
23
+ hint: `Choose an empty destination path: ${targetDir}`
24
+ });
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+ async function reserveAvailableDirPath(preferredDir) {
30
+ const parent = path.dirname(preferredDir);
31
+ const base = path.basename(preferredDir);
32
+ for (let i = 1; i <= 1e3; i += 1) {
33
+ const candidate = i === 1 ? preferredDir : path.join(parent, `${base}-${i}`);
34
+ try {
35
+ await fs.mkdir(candidate);
36
+ return candidate;
37
+ } catch (error) {
38
+ if (error?.code === "EEXIST") continue;
39
+ throw error;
40
+ }
41
+ }
42
+ throw new RemixError("No available output directory name.", {
43
+ exitCode: 2,
44
+ hint: `Tried ${base} through ${base}-1000 under ${parent}.`
45
+ });
46
+ }
47
+ async function writeJsonAtomic(filePath, value) {
48
+ const dir = path.dirname(filePath);
49
+ await fs.mkdir(dir, { recursive: true });
50
+ const tmp = `${filePath}.tmp-${Date.now()}`;
51
+ await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}
52
+ `, "utf8");
53
+ await fs.rename(tmp, filePath);
54
+ }
55
+
56
+ // src/infrastructure/binding/collabBindingStore.ts
57
+ function getCollabBindingPath(repoRoot) {
58
+ return path2.join(repoRoot, ".remix", "config.json");
59
+ }
60
+ function buildBindingFileV3(params) {
61
+ return {
62
+ schemaVersion: 3,
63
+ repoFingerprint: params.repoFingerprint,
64
+ remoteUrl: params.remoteUrl,
65
+ defaultBranch: params.defaultBranch,
66
+ branchBindings: params.branchBindings,
67
+ ...params.explicitRootBinding ? { explicitRootBinding: params.explicitRootBinding } : {}
68
+ };
69
+ }
70
+ function normalizeBranchName(value) {
71
+ const normalized = String(value ?? "").trim();
72
+ return normalized || null;
73
+ }
74
+ function normalizeProjectId(value) {
75
+ const normalized = String(value ?? "").trim();
76
+ return normalized || null;
77
+ }
78
+ function normalizeBranchBinding(value) {
79
+ if (!value?.currentAppId || !value?.upstreamAppId) return null;
80
+ return {
81
+ projectId: normalizeProjectId(value.projectId),
82
+ currentAppId: value.currentAppId,
83
+ upstreamAppId: value.upstreamAppId,
84
+ threadId: value.threadId ?? null,
85
+ laneId: value.laneId ?? null,
86
+ bindingMode: value.bindingMode === "legacy" ? "legacy" : value.bindingMode === "explicit_root" ? "explicit_root" : "lane"
87
+ };
88
+ }
89
+ function buildResolvedBinding(params) {
90
+ if (!params.binding) return null;
91
+ return {
92
+ schemaVersion: 3,
93
+ projectId: params.binding.projectId ?? params.fallbackProjectId,
94
+ currentAppId: params.binding.currentAppId,
95
+ upstreamAppId: params.binding.upstreamAppId,
96
+ threadId: params.binding.threadId,
97
+ repoFingerprint: params.repoFingerprint,
98
+ remoteUrl: params.remoteUrl,
99
+ defaultBranch: params.defaultBranch,
100
+ laneId: params.binding.laneId,
101
+ branchName: params.branchName,
102
+ bindingMode: params.binding.bindingMode
103
+ };
104
+ }
105
+ function deriveFallbackProjectId(params) {
106
+ const candidates = [
107
+ params.currentBranch ? params.branchBindings[params.currentBranch]?.projectId ?? null : null,
108
+ params.defaultBranch ? params.branchBindings[params.defaultBranch]?.projectId ?? null : null,
109
+ ...Object.values(params.branchBindings).map((binding) => binding.projectId),
110
+ params.legacyProjectId
111
+ ];
112
+ for (const candidate of candidates) {
113
+ if (candidate) return candidate;
114
+ }
115
+ return null;
116
+ }
117
+ async function readCollabBindingState(repoRoot, options) {
118
+ try {
119
+ const persist = options?.persist === true;
120
+ const filePath = getCollabBindingPath(repoRoot);
121
+ const raw = await fs2.readFile(filePath, "utf8");
122
+ const parsed = JSON.parse(raw);
123
+ if (!parsed || typeof parsed !== "object") return null;
124
+ const currentBranch = normalizeBranchName(await getCurrentBranch(repoRoot).catch(() => null));
125
+ if (parsed.schemaVersion === 1) {
126
+ if (!parsed.currentAppId || !parsed.upstreamAppId) return null;
127
+ const projectId = normalizeProjectId(parsed.projectId);
128
+ const preferredBranch = normalizeBranchName(parsed.preferredBranch ?? parsed.defaultBranch ?? null);
129
+ const branchKey = preferredBranch ?? currentBranch ?? null;
130
+ const branchBindings2 = branchKey ? {
131
+ [branchKey]: {
132
+ projectId,
133
+ currentAppId: parsed.currentAppId,
134
+ upstreamAppId: parsed.upstreamAppId,
135
+ threadId: parsed.threadId ?? null,
136
+ laneId: null,
137
+ bindingMode: "legacy"
138
+ }
139
+ } : {};
140
+ const migratedFile = buildBindingFileV3({
141
+ repoFingerprint: parsed.repoFingerprint ?? null,
142
+ remoteUrl: parsed.remoteUrl ?? null,
143
+ defaultBranch: parsed.defaultBranch ?? null,
144
+ branchBindings: branchBindings2
145
+ });
146
+ if (persist) {
147
+ try {
148
+ await writeJsonAtomic(filePath, migratedFile);
149
+ } catch {
150
+ }
151
+ }
152
+ return {
153
+ schemaVersion: 3,
154
+ projectId,
155
+ repoFingerprint: migratedFile.repoFingerprint,
156
+ remoteUrl: migratedFile.remoteUrl,
157
+ defaultBranch: migratedFile.defaultBranch,
158
+ currentBranch,
159
+ branchBindings: migratedFile.branchBindings,
160
+ explicitRootBinding: null,
161
+ binding: buildResolvedBinding({
162
+ fallbackProjectId: projectId,
163
+ repoFingerprint: migratedFile.repoFingerprint,
164
+ remoteUrl: migratedFile.remoteUrl,
165
+ defaultBranch: migratedFile.defaultBranch,
166
+ branchName: branchKey,
167
+ binding: branchKey ? migratedFile.branchBindings[branchKey] : null
168
+ })
169
+ };
170
+ }
171
+ if (parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3) return null;
172
+ const file = parsed;
173
+ let shouldPersistNormalizedBranchBindings = false;
174
+ const legacyProjectId = normalizeProjectId(file.projectId);
175
+ const branchBindings = Object.fromEntries(
176
+ Object.entries(file.branchBindings ?? {}).map(([branchName, branchBinding]) => {
177
+ const normalized = normalizeBranchBinding(branchBinding);
178
+ const rawProjectId = branchBinding && typeof branchBinding === "object" && "projectId" in branchBinding ? normalizeProjectId(branchBinding.projectId) : null;
179
+ const rawBindingMode = branchBinding && typeof branchBinding === "object" && "bindingMode" in branchBinding ? branchBinding.bindingMode : null;
180
+ const hasLegacyPreferredBranch = Boolean(branchBinding) && typeof branchBinding === "object" && "preferredBranch" in branchBinding;
181
+ let normalizedWithProject = normalized;
182
+ if (normalizedWithProject && !normalizedWithProject.projectId && legacyProjectId) {
183
+ normalizedWithProject = {
184
+ ...normalizedWithProject,
185
+ projectId: legacyProjectId
186
+ };
187
+ }
188
+ if (normalizedWithProject && (rawBindingMode !== normalizedWithProject.bindingMode || hasLegacyPreferredBranch || rawProjectId !== normalizedWithProject.projectId)) {
189
+ shouldPersistNormalizedBranchBindings = true;
190
+ }
191
+ return [branchName, normalizedWithProject];
192
+ }).filter((entry) => Boolean(entry[1]))
193
+ );
194
+ const legacyExplicitBinding = normalizeBranchBinding(file.explicitBinding ?? null);
195
+ const legacyExplicitBranch = currentBranch ?? normalizeBranchName(file.defaultBranch);
196
+ if (legacyExplicitBinding && legacyExplicitBranch) {
197
+ branchBindings[legacyExplicitBranch] = {
198
+ ...legacyExplicitBinding,
199
+ projectId: legacyExplicitBinding.projectId ?? branchBindings[legacyExplicitBranch]?.projectId ?? legacyProjectId,
200
+ bindingMode: "lane"
201
+ };
202
+ shouldPersistNormalizedBranchBindings = true;
203
+ }
204
+ let explicitRootBinding = normalizeBranchBinding(file.explicitRootBinding ?? null);
205
+ if (explicitRootBinding && !explicitRootBinding.projectId && legacyProjectId) {
206
+ explicitRootBinding = {
207
+ ...explicitRootBinding,
208
+ projectId: legacyProjectId
209
+ };
210
+ shouldPersistNormalizedBranchBindings = true;
211
+ }
212
+ if (explicitRootBinding && explicitRootBinding.bindingMode !== "explicit_root") {
213
+ explicitRootBinding = {
214
+ ...explicitRootBinding,
215
+ bindingMode: "explicit_root"
216
+ };
217
+ shouldPersistNormalizedBranchBindings = true;
218
+ }
219
+ if (persist && ("explicitBinding" in file || "explicitRootBinding" in file || shouldPersistNormalizedBranchBindings || parsed.schemaVersion === 2)) {
220
+ try {
221
+ await writeJsonAtomic(
222
+ filePath,
223
+ buildBindingFileV3({
224
+ repoFingerprint: file.repoFingerprint ?? null,
225
+ remoteUrl: file.remoteUrl ?? null,
226
+ defaultBranch: file.defaultBranch ?? null,
227
+ branchBindings,
228
+ explicitRootBinding
229
+ })
230
+ );
231
+ } catch {
232
+ }
233
+ }
234
+ const resolvedBranch = currentBranch ?? normalizeBranchName(file.defaultBranch);
235
+ const fallbackProjectId = deriveFallbackProjectId({
236
+ branchBindings,
237
+ currentBranch: resolvedBranch,
238
+ defaultBranch: normalizeBranchName(file.defaultBranch),
239
+ legacyProjectId: explicitRootBinding?.projectId ?? legacyProjectId
240
+ });
241
+ const resolvedBinding = buildResolvedBinding({
242
+ fallbackProjectId,
243
+ repoFingerprint: file.repoFingerprint ?? null,
244
+ remoteUrl: file.remoteUrl ?? null,
245
+ defaultBranch: file.defaultBranch ?? null,
246
+ branchName: resolvedBranch,
247
+ binding: resolvedBranch ? branchBindings[resolvedBranch] ?? null : null
248
+ }) ?? (resolvedBranch && resolvedBranch === normalizeBranchName(file.defaultBranch) && explicitRootBinding ? buildResolvedBinding({
249
+ fallbackProjectId,
250
+ repoFingerprint: file.repoFingerprint ?? null,
251
+ remoteUrl: file.remoteUrl ?? null,
252
+ defaultBranch: file.defaultBranch ?? null,
253
+ branchName: normalizeBranchName(file.defaultBranch),
254
+ binding: explicitRootBinding
255
+ }) : null);
256
+ return {
257
+ schemaVersion: parsed.schemaVersion,
258
+ projectId: fallbackProjectId,
259
+ repoFingerprint: file.repoFingerprint ?? null,
260
+ remoteUrl: file.remoteUrl ?? null,
261
+ defaultBranch: file.defaultBranch ?? null,
262
+ currentBranch,
263
+ branchBindings,
264
+ explicitRootBinding: buildResolvedBinding({
265
+ fallbackProjectId,
266
+ repoFingerprint: file.repoFingerprint ?? null,
267
+ remoteUrl: file.remoteUrl ?? null,
268
+ defaultBranch: file.defaultBranch ?? null,
269
+ branchName: normalizeBranchName(file.defaultBranch),
270
+ binding: explicitRootBinding
271
+ }),
272
+ binding: resolvedBinding
273
+ };
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+ async function readCollabBinding(repoRoot) {
279
+ const state = await readCollabBindingState(repoRoot);
280
+ return state?.binding ?? null;
281
+ }
282
+ async function writeCollabBinding(repoRoot, binding) {
283
+ const filePath = getCollabBindingPath(repoRoot);
284
+ const currentBranch = normalizeBranchName(await getCurrentBranch(repoRoot).catch(() => null));
285
+ const branchName = normalizeBranchName(binding.branchName) ?? currentBranch ?? binding.defaultBranch ?? "main";
286
+ const existing = await readCollabBindingState(repoRoot, { persist: true });
287
+ const branchBindings = { ...existing?.branchBindings ?? {} };
288
+ branchBindings[branchName] = {
289
+ projectId: normalizeProjectId(binding.projectId) ?? branchBindings[branchName]?.projectId ?? existing?.projectId ?? null,
290
+ currentAppId: binding.currentAppId,
291
+ upstreamAppId: binding.upstreamAppId,
292
+ threadId: binding.threadId ?? null,
293
+ laneId: binding.laneId ?? null,
294
+ bindingMode: binding.bindingMode ?? "lane"
295
+ };
296
+ const explicitRootBinding = binding.bindingMode === "explicit_root" ? {
297
+ ...branchBindings[branchName],
298
+ bindingMode: "explicit_root"
299
+ } : existing?.explicitRootBinding ? {
300
+ projectId: existing.explicitRootBinding.projectId,
301
+ currentAppId: existing.explicitRootBinding.currentAppId,
302
+ upstreamAppId: existing.explicitRootBinding.upstreamAppId,
303
+ threadId: existing.explicitRootBinding.threadId,
304
+ laneId: existing.explicitRootBinding.laneId,
305
+ bindingMode: "explicit_root"
306
+ } : null;
307
+ await writeJsonAtomic(
308
+ filePath,
309
+ buildBindingFileV3({
310
+ repoFingerprint: binding.repoFingerprint ?? null,
311
+ remoteUrl: binding.remoteUrl ?? null,
312
+ defaultBranch: binding.defaultBranch ?? null,
313
+ branchBindings,
314
+ explicitRootBinding
315
+ })
316
+ );
317
+ return filePath;
318
+ }
319
+ async function writeCollabBindingSnapshot(params) {
320
+ const filePath = getCollabBindingPath(params.repoRoot);
321
+ await writeJsonAtomic(
322
+ filePath,
323
+ buildBindingFileV3({
324
+ repoFingerprint: params.repoFingerprint,
325
+ remoteUrl: params.remoteUrl,
326
+ defaultBranch: params.defaultBranch,
327
+ branchBindings: params.branchBindings,
328
+ explicitRootBinding: params.explicitRootBinding ?? null
329
+ })
330
+ );
331
+ return filePath;
332
+ }
333
+
334
+ export {
335
+ reserveDirectory,
336
+ reserveAvailableDirPath,
337
+ getCollabBindingPath,
338
+ readCollabBindingState,
339
+ readCollabBinding,
340
+ writeCollabBinding,
341
+ writeCollabBindingSnapshot
342
+ };
package/dist/collab.d.ts CHANGED
@@ -64,8 +64,8 @@ type MergeRequestReview = {
64
64
  };
65
65
  };
66
66
  type CollabApproveMode = "remote-only" | "sync-target-repo";
67
- type CollabStatusBlockedReason = "not_git_repo" | "not_bound" | "branch_binding_missing" | "missing_head" | "detached_head" | "branch_mismatch" | "dirty_worktree" | "metadata_conflict" | "remote_error";
68
- type CollabStatusRecommendedAction = "init" | "sync" | "reconcile" | "review_queue" | "no_action";
67
+ type CollabStatusBlockedReason = "not_git_repo" | "not_bound" | "branch_binding_missing" | "family_ambiguous" | "missing_head" | "detached_head" | "branch_mismatch" | "dirty_worktree" | "metadata_conflict" | "remote_error";
68
+ type CollabStatusRecommendedAction = "init" | "sync" | "reconcile" | "review_queue" | "choose_family" | "no_action";
69
69
  type CollabStatus = {
70
70
  schemaVersion: 1;
71
71
  repo: {
@@ -95,7 +95,7 @@ type CollabStatus = {
95
95
  defaultBranch: string | null;
96
96
  laneId: string | null;
97
97
  branchName: string | null;
98
- bindingMode: "legacy" | "lane" | null;
98
+ bindingMode: "legacy" | "lane" | "explicit_root" | null;
99
99
  };
100
100
  remote: {
101
101
  checked: boolean;
@@ -148,7 +148,7 @@ type SyncUpstreamResponse = {
148
148
  status: "up-to-date" | "queued";
149
149
  mergeRequestId?: string;
150
150
  };
151
- type CollabRecordingPreflightStatus = "not_git_repo" | "not_bound" | "branch_binding_missing" | "missing_head" | "branch_mismatch" | "metadata_conflict" | "up_to_date" | "ready_to_fast_forward" | "reconcile_required";
151
+ type CollabRecordingPreflightStatus = "not_git_repo" | "not_bound" | "branch_binding_missing" | "family_ambiguous" | "missing_head" | "branch_mismatch" | "metadata_conflict" | "up_to_date" | "ready_to_fast_forward" | "reconcile_required";
152
152
  type CollabRecordingPreflight = {
153
153
  status: CollabRecordingPreflightStatus;
154
154
  repoRoot: string | null;
@@ -686,6 +686,8 @@ declare function collabInit(params: {
686
686
  upstreamAppId: string;
687
687
  bindingPath: string;
688
688
  repoRoot: string;
689
+ bindingMode: string;
690
+ createdCanonicalFamily: boolean;
689
691
  } | {
690
692
  warnings?: string[] | undefined;
691
693
  reused: boolean;
@@ -695,6 +697,8 @@ declare function collabInit(params: {
695
697
  upstreamAppId: string;
696
698
  bindingPath: string;
697
699
  repoRoot: string;
700
+ bindingMode: "lane" | "explicit_root";
701
+ createdCanonicalFamily: boolean;
698
702
  remoteUrl: string | null;
699
703
  defaultBranch: string | null;
700
704
  }>;
package/dist/collab.js CHANGED
@@ -4,8 +4,9 @@ import {
4
4
  readCollabBindingState,
5
5
  reserveAvailableDirPath,
6
6
  reserveDirectory,
7
- writeCollabBinding
8
- } from "./chunk-4L3ZBZUQ.js";
7
+ writeCollabBinding,
8
+ writeCollabBindingSnapshot
9
+ } from "./chunk-IXWQWFYT.js";
9
10
  import "./chunk-HZNEDSRS.js";
10
11
  import {
11
12
  assertRepoSnapshotUnchanged,
@@ -340,6 +341,10 @@ function normalizeBranchName(value) {
340
341
  }
341
342
  function buildBindingFromLane(state, lane) {
342
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";
343
348
  return {
344
349
  schemaVersion: 3,
345
350
  projectId: lane.projectId ?? state.projectId,
@@ -350,8 +355,8 @@ function buildBindingFromLane(state, lane) {
350
355
  remoteUrl: lane.remoteUrl ?? state.remoteUrl ?? null,
351
356
  defaultBranch: lane.defaultBranch ?? state.defaultBranch ?? null,
352
357
  laneId: lane.laneId ?? null,
353
- branchName: lane.branchName ?? state.currentBranch ?? null,
354
- bindingMode: "lane"
358
+ branchName: resolvedBranch,
359
+ bindingMode
355
360
  };
356
361
  }
357
362
  function shouldPersistRemoteLaneMetadata(localBinding, lane) {
@@ -380,6 +385,16 @@ async function persistResolvedLane(repoRoot, binding) {
380
385
  });
381
386
  return readCollabBinding(repoRoot);
382
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
+ }
383
398
  async function resolveActiveLaneBinding(params) {
384
399
  const state = await readCollabBindingState(params.repoRoot);
385
400
  if (!state) {
@@ -402,13 +417,16 @@ async function resolveActiveLaneBinding(params) {
402
417
  };
403
418
  }
404
419
  const laneResp2 = await params.api.resolveProjectLaneBinding({
405
- projectId: localBinding.projectId ?? state.projectId ?? void 0,
420
+ projectId: state.explicitRootBinding?.projectId ?? (requireRemoteLane ? void 0 : localBinding.projectId ?? state.projectId ?? void 0),
406
421
  repoFingerprint: state.repoFingerprint ?? void 0,
407
422
  remoteUrl: state.remoteUrl ?? void 0,
408
423
  defaultBranch: state.defaultBranch ?? void 0,
409
424
  branchName: currentBranch
410
425
  });
411
426
  const lane2 = unwrapResponseObject(laneResp2, "project lane binding");
427
+ if (lane2.status === "ambiguous_family_selection") {
428
+ return buildAmbiguousResolution({ state, currentBranch, lane: lane2 });
429
+ }
412
430
  if (lane2.status === "resolved") {
413
431
  const resolvedBranch = normalizeBranchName(lane2.branchName);
414
432
  const resolvedProjectId = lane2.projectId ?? state.projectId;
@@ -451,12 +469,12 @@ async function resolveActiveLaneBinding(params) {
451
469
  return {
452
470
  status: "missing_branch_binding",
453
471
  currentBranch,
454
- projectId: state.projectId,
455
- repoFingerprint: state.repoFingerprint,
456
- remoteUrl: state.remoteUrl,
457
- defaultBranch: state.defaultBranch,
458
- upstreamAppId: localBinding.upstreamAppId ?? null,
459
- threadId: localBinding.threadId ?? null
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
460
478
  };
461
479
  }
462
480
  return {
@@ -479,13 +497,16 @@ async function resolveActiveLaneBinding(params) {
479
497
  };
480
498
  }
481
499
  const laneResp = await params.api.resolveProjectLaneBinding({
482
- projectId: state.projectId ?? void 0,
500
+ projectId: state.explicitRootBinding?.projectId ?? state.projectId ?? void 0,
483
501
  repoFingerprint: state.repoFingerprint ?? void 0,
484
502
  remoteUrl: state.remoteUrl ?? void 0,
485
503
  defaultBranch: state.defaultBranch ?? void 0,
486
504
  branchName: currentBranch
487
505
  });
488
506
  const lane = unwrapResponseObject(laneResp, "project lane binding");
507
+ if (lane.status === "ambiguous_family_selection") {
508
+ return buildAmbiguousResolution({ state, currentBranch, lane });
509
+ }
489
510
  if (lane.status === "resolved") {
490
511
  const binding = buildBindingFromLane(state, lane);
491
512
  if (binding) {
@@ -497,18 +518,15 @@ async function resolveActiveLaneBinding(params) {
497
518
  };
498
519
  }
499
520
  }
500
- if (lane.status === "binding_not_found") {
501
- return { status: "not_bound", currentBranch };
502
- }
503
521
  return {
504
522
  status: "missing_branch_binding",
505
523
  currentBranch,
506
- projectId: lane.projectId ?? state.projectId,
524
+ projectId: lane.projectId ?? state.explicitRootBinding?.projectId ?? state.projectId,
507
525
  repoFingerprint: lane.repoFingerprint ?? state.repoFingerprint,
508
526
  remoteUrl: lane.remoteUrl ?? state.remoteUrl,
509
527
  defaultBranch: lane.defaultBranch ?? state.defaultBranch,
510
- upstreamAppId: lane.upstreamAppId ?? null,
511
- threadId: lane.threadId ?? null
528
+ upstreamAppId: lane.upstreamAppId ?? state.explicitRootBinding?.upstreamAppId ?? null,
529
+ threadId: lane.threadId ?? state.explicitRootBinding?.threadId ?? null
512
530
  };
513
531
  }
514
532
  async function ensureActiveLaneBinding(params) {
@@ -528,6 +546,12 @@ async function ensureActiveLaneBinding(params) {
528
546
  hint: `Local app ${resolved.binding.currentAppId}; server app ${resolved.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before running ${params.operation ?? "this command"}.`
529
547
  });
530
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
+ }
531
555
  if (resolved.status === "not_bound") {
532
556
  return null;
533
557
  }
@@ -542,65 +566,6 @@ async function ensureActiveLaneBinding(params) {
542
566
  hint: `Run \`remix collab init\` on branch ${resolved.currentBranch} before running ${params.operation ?? "this command"}.`
543
567
  });
544
568
  }
545
- async function provisionActiveLaneBinding(params) {
546
- const resolved = await resolveActiveLaneBinding(params);
547
- if (resolved.status === "resolved") {
548
- if (resolved.source === "local") {
549
- return { binding: resolved.binding, warnings: [] };
550
- }
551
- const persisted2 = await persistResolvedLane(params.repoRoot, resolved.binding);
552
- return { binding: persisted2, warnings: [] };
553
- }
554
- if (resolved.status === "binding_conflict") {
555
- throw new RemixError("Current branch binding conflicts with the server-resolved Remix lane.", {
556
- exitCode: 2,
557
- hint: `Local app ${resolved.binding.currentAppId}; server app ${resolved.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before running ${params.operation}.`
558
- });
559
- }
560
- if (resolved.status === "not_bound") {
561
- return { binding: null, warnings: [] };
562
- }
563
- if (!resolved.currentBranch) {
564
- throw new RemixError("Current branch is not yet bound to a Remix lane.", {
565
- exitCode: 2,
566
- hint: `Switch to a named branch before running ${params.operation}.`
567
- });
568
- }
569
- let lane;
570
- try {
571
- const laneResp = await params.api.ensureProjectLaneBinding({
572
- projectId: resolved.projectId ?? void 0,
573
- repoFingerprint: resolved.repoFingerprint ?? void 0,
574
- remoteUrl: resolved.remoteUrl ?? void 0,
575
- defaultBranch: resolved.defaultBranch ?? void 0,
576
- branchName: resolved.currentBranch
577
- });
578
- lane = unwrapResponseObject(laneResp, "project lane binding");
579
- } catch (error) {
580
- throw new RemixError(`Failed to provision a Remix lane for branch ${resolved.currentBranch}.`, {
581
- exitCode: error instanceof RemixError ? error.exitCode : 1,
582
- hint: formatCliErrorDetail(error) || `Remix could not create or recover the branch lane required before ${params.operation}.`
583
- });
584
- }
585
- const state = await readCollabBindingState(params.repoRoot);
586
- if (!state) {
587
- return { binding: null, warnings: [] };
588
- }
589
- const binding = buildBindingFromLane(state, lane);
590
- if (!binding) {
591
- throw new RemixError(`Failed to provision a Remix lane for branch ${resolved.currentBranch}.`, {
592
- exitCode: 1,
593
- hint: "The server returned incomplete lane binding metadata."
594
- });
595
- }
596
- const persisted = await persistResolvedLane(params.repoRoot, binding);
597
- return {
598
- binding: persisted,
599
- warnings: [
600
- lane.created ? `Provisioned Remix lane for branch ${resolved.currentBranch}.` : `Recovered existing Remix lane binding for branch ${resolved.currentBranch}.`
601
- ]
602
- };
603
- }
604
569
 
605
570
  // src/application/collab/recordingPreflight.ts
606
571
  async function collabRecordingPreflight(params) {
@@ -663,6 +628,24 @@ async function collabRecordingPreflight(params) {
663
628
  hint: `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`
664
629
  };
665
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
+ }
666
649
  if (bindingResolution.status === "binding_conflict") {
667
650
  return {
668
651
  status: "metadata_conflict",
@@ -1184,6 +1167,12 @@ function assertSupportedRecordingPreflight(preflight) {
1184
1167
  hint: preflight.hint
1185
1168
  });
1186
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
+ }
1187
1176
  if (preflight.status === "not_git_repo") {
1188
1177
  throw new RemixError(preflight.hint || "Not inside a git repository.", {
1189
1178
  exitCode: 2,
@@ -1501,6 +1490,12 @@ function assertSupportedRecordingPreflight2(preflight) {
1501
1490
  hint: preflight.hint
1502
1491
  });
1503
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
+ }
1504
1499
  if (preflight.status === "not_git_repo") {
1505
1500
  throw new RemixError(preflight.hint || "Not inside a git repository.", {
1506
1501
  exitCode: 2,
@@ -2009,6 +2004,12 @@ async function resolveQueueAppId(params) {
2009
2004
  hint: `Local app ${bindingResolution.binding.currentAppId}; server app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}.`
2010
2005
  });
2011
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
+ }
2012
2013
  return bindingResolution.binding.currentAppId;
2013
2014
  }
2014
2015
  async function collabListMergeRequests(params) {
@@ -2055,6 +2056,12 @@ async function resolveScopeTarget(params) {
2055
2056
  hint: `Local app ${bindingResolution.binding.currentAppId}; server app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}.`
2056
2057
  });
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
+ }
2058
2065
  const binding = bindingResolution.binding;
2059
2066
  if (params.scope === "project") {
2060
2067
  if (!binding.projectId) {
@@ -2185,6 +2192,85 @@ ${text}`.trim() || null
2185
2192
  }
2186
2193
 
2187
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
+ }
2188
2274
  async function collabInit(params) {
2189
2275
  return withRepoMutationLock(
2190
2276
  {
@@ -2206,72 +2292,207 @@ async function collabInit(params) {
2206
2292
  const branchName = currentBranch ?? defaultBranch ?? null;
2207
2293
  const repoFingerprint = await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch });
2208
2294
  const repoSnapshot = await captureRepoSnapshot(repoRoot);
2209
- if (params.forceNew) {
2210
- const bindingResp = await params.api.resolveProjectBinding({
2211
- repoFingerprint,
2212
- remoteUrl: remoteUrl ?? void 0,
2213
- branchName: branchName ?? void 0
2214
- });
2215
- const existing = bindingResp?.responseObject;
2216
- if (existing?.projectId && existing?.appId) {
2217
- throw new RemixError("`remix collab init --force-new` is not allowed for repositories already known to Remix.", {
2218
- exitCode: 2,
2219
- hint: "This repository already resolves to an existing Remix lineage. Run `remix collab init` without `--force-new`, or use an explicit checkout/remix flow instead of creating a duplicate imported app."
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`"
2220
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;
2221
2344
  }
2222
- }
2223
- if (!params.forceNew) {
2224
- const bindingResp = await params.api.resolveProjectBinding({
2225
- repoFingerprint,
2226
- remoteUrl: remoteUrl ?? void 0,
2227
- branchName: branchName ?? void 0
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`."
2228
2351
  });
2229
- const existing = bindingResp?.responseObject;
2230
- if (existing?.projectId && existing?.appId) {
2231
- await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
2232
- operation: "`remix collab init`",
2233
- recoveryHint: "The repository changed while the local binding was being initialized. Review the local changes and rerun `remix collab init`."
2234
- });
2235
- const initialProjectId = String(existing.projectId);
2236
- const initialCurrentAppId = String(existing.appId);
2237
- const initialUpstreamAppId = String(existing.upstreamAppId ?? existing.appId);
2238
- const initialThreadId = existing.threadId ? String(existing.threadId) : null;
2239
- const bindingPath2 = await writeCollabBinding(repoRoot, {
2240
- projectId: initialProjectId,
2241
- currentAppId: initialCurrentAppId,
2242
- upstreamAppId: initialUpstreamAppId,
2243
- threadId: initialThreadId,
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"
2363
+ });
2364
+ if (defaultBranch && branchName !== defaultBranch) {
2365
+ await writeCollabBinding(repoRoot, {
2366
+ projectId: boundProjectId2,
2367
+ currentAppId: boundCurrentAppId2,
2368
+ upstreamAppId: boundUpstreamAppId2,
2369
+ threadId: boundThreadId2,
2244
2370
  repoFingerprint,
2245
2371
  remoteUrl,
2246
2372
  defaultBranch: defaultBranch ?? null,
2247
- laneId: null,
2373
+ laneId: boundLaneId2,
2248
2374
  branchName,
2249
2375
  bindingMode: "lane"
2250
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;
2251
2412
  let boundProjectId2 = initialProjectId;
2252
2413
  let boundCurrentAppId2 = initialCurrentAppId;
2253
2414
  let boundUpstreamAppId2 = initialUpstreamAppId;
2254
2415
  let boundThreadId2 = initialThreadId;
2255
- let finalWarnings = [...warnings];
2416
+ let boundLaneId2 = null;
2417
+ let canonicalLane2 = null;
2256
2418
  if (branchName) {
2257
- const provisioned = await provisionActiveLaneBinding({
2258
- repoRoot,
2259
- api: params.api,
2260
- operation: "`remix collab init`"
2261
- });
2262
- if (provisioned.binding) {
2263
- boundProjectId2 = provisioned.binding.projectId ?? boundProjectId2;
2264
- boundCurrentAppId2 = provisioned.binding.currentAppId;
2265
- boundUpstreamAppId2 = provisioned.binding.upstreamAppId;
2266
- boundThreadId2 = provisioned.binding.threadId;
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;
2267
2459
  }
2268
- finalWarnings = [...finalWarnings, ...provisioned.warnings];
2269
2460
  }
2270
2461
  if (boundCurrentAppId2) {
2271
2462
  const readyApp = await pollAppReady(params.api, boundCurrentAppId2);
2272
2463
  boundProjectId2 = String(readyApp.projectId ?? boundProjectId2);
2273
2464
  boundThreadId2 = readyApp.threadId ? String(readyApp.threadId) : boundThreadId2;
2274
2465
  }
2466
+ await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
2467
+ operation: "`remix collab init`",
2468
+ recoveryHint: "The repository changed while the local binding was being initialized. Review the local changes and rerun `remix collab init`."
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
+ }
2484
+ const bindingPath2 = await writeCollabBinding(repoRoot, {
2485
+ projectId: boundProjectId2,
2486
+ currentAppId: boundCurrentAppId2,
2487
+ upstreamAppId: boundUpstreamAppId2,
2488
+ threadId: boundThreadId2,
2489
+ repoFingerprint,
2490
+ remoteUrl,
2491
+ defaultBranch: defaultBranch ?? null,
2492
+ laneId: boundLaneId2,
2493
+ branchName,
2494
+ bindingMode: "lane"
2495
+ });
2275
2496
  return {
2276
2497
  reused: true,
2277
2498
  projectId: boundProjectId2,
@@ -2280,7 +2501,9 @@ async function collabInit(params) {
2280
2501
  upstreamAppId: boundUpstreamAppId2,
2281
2502
  bindingPath: bindingPath2,
2282
2503
  repoRoot,
2283
- ...finalWarnings.length > 0 ? { warnings: finalWarnings } : {}
2504
+ bindingMode: "lane",
2505
+ createdCanonicalFamily: false,
2506
+ ...warnings.length > 0 ? { warnings } : {}
2284
2507
  };
2285
2508
  }
2286
2509
  }
@@ -2307,7 +2530,7 @@ async function collabInit(params) {
2307
2530
  path: params.path?.trim() || void 0,
2308
2531
  platform: "generic",
2309
2532
  isPublic: false,
2310
- branch: currentBranch ?? void 0,
2533
+ branch: defaultBranch && branchName && branchName !== defaultBranch ? defaultBranch : currentBranch ?? void 0,
2311
2534
  remoteUrl: remoteUrl ?? void 0,
2312
2535
  defaultBranch: defaultBranch ?? void 0,
2313
2536
  repoFingerprint,
@@ -2320,28 +2543,51 @@ async function collabInit(params) {
2320
2543
  let boundUpstreamAppId = String(app.id);
2321
2544
  let boundThreadId = app.threadId ? String(app.threadId) : null;
2322
2545
  let boundLaneId = null;
2546
+ let canonicalLane = null;
2323
2547
  if (branchName) {
2324
- const laneResp = defaultBranch && branchName !== defaultBranch ? await params.api.bootstrapFreshProjectLane({
2325
- projectId: boundProjectId,
2326
- repoFingerprint,
2327
- remoteUrl: remoteUrl ?? void 0,
2328
- defaultBranch: defaultBranch ?? void 0,
2329
- branchName,
2330
- seedAppId: String(app.id)
2331
- }) : await params.api.ensureProjectLaneBinding({
2332
- projectId: boundProjectId,
2333
- repoFingerprint,
2334
- remoteUrl: remoteUrl ?? void 0,
2335
- defaultBranch: defaultBranch ?? void 0,
2336
- branchName,
2337
- seedAppId: String(app.id)
2338
- });
2339
- const lane = unwrapResponseObject(laneResp, "project lane binding");
2340
- boundProjectId = typeof lane.projectId === "string" && lane.projectId ? lane.projectId : boundProjectId;
2341
- boundCurrentAppId = typeof lane.currentAppId === "string" && lane.currentAppId ? lane.currentAppId : boundCurrentAppId;
2342
- boundUpstreamAppId = typeof lane.upstreamAppId === "string" && lane.upstreamAppId ? lane.upstreamAppId : boundUpstreamAppId;
2343
- boundThreadId = typeof lane.threadId === "string" && lane.threadId ? lane.threadId : boundThreadId;
2344
- boundLaneId = typeof lane.laneId === "string" && lane.laneId ? lane.laneId : null;
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
+ }
2345
2591
  }
2346
2592
  if (boundCurrentAppId) {
2347
2593
  const readyApp = await pollAppReady(params.api, boundCurrentAppId);
@@ -2352,18 +2598,64 @@ async function collabInit(params) {
2352
2598
  operation: "`remix collab init`",
2353
2599
  recoveryHint: "The repository changed before the Remix binding was written. Review the local changes and rerun `remix collab init`."
2354
2600
  });
2355
- const bindingPath = await writeCollabBinding(repoRoot, {
2356
- projectId: boundProjectId,
2357
- currentAppId: boundCurrentAppId,
2358
- upstreamAppId: boundUpstreamAppId,
2359
- threadId: boundThreadId,
2360
- repoFingerprint,
2361
- remoteUrl,
2362
- defaultBranch: defaultBranch ?? null,
2363
- laneId: boundLaneId,
2364
- branchName,
2365
- bindingMode: "lane"
2366
- });
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
+ }
2367
2659
  return {
2368
2660
  reused: false,
2369
2661
  projectId: boundProjectId,
@@ -2372,6 +2664,8 @@ async function collabInit(params) {
2372
2664
  upstreamAppId: boundUpstreamAppId,
2373
2665
  bindingPath,
2374
2666
  repoRoot,
2667
+ bindingMode,
2668
+ createdCanonicalFamily: Boolean(params.forceNew),
2375
2669
  remoteUrl,
2376
2670
  defaultBranch,
2377
2671
  ...warnings.length > 0 ? { warnings } : {}
@@ -2831,7 +3125,32 @@ async function collabStatus(params) {
2831
3125
  addBlockedReason(status.sync, "branch_binding_missing");
2832
3126
  addBlockedReason(status.reconcile, "branch_binding_missing");
2833
3127
  addWarning(status, `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`);
2834
- status.recommendedAction = "no_action";
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";
2835
3154
  return status;
2836
3155
  }
2837
3156
  const binding = bindingResolution.binding;
@@ -2983,6 +3302,8 @@ async function collabStatus(params) {
2983
3302
  status.recommendedAction = "reconcile";
2984
3303
  } else if ((status.remote.incomingOpenMergeRequestCount ?? 0) > 0) {
2985
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";
2986
3307
  } else {
2987
3308
  status.recommendedAction = "no_action";
2988
3309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixhq/core",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Remix core library",
5
5
  "homepage": "https://github.com/RemixDotOne/remix-core",
6
6
  "license": "MIT",