@remixhq/core 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/dist/api.d.ts +494 -0
- package/dist/api.js +7 -0
- package/dist/auth.d.ts +27 -0
- package/dist/auth.js +15 -0
- package/dist/binding.d.ts +16 -0
- package/dist/binding.js +11 -0
- package/dist/chunk-2WGZS7CD.js +0 -0
- package/dist/chunk-34WDQCPF.js +242 -0
- package/dist/chunk-4OCNZHHR.js +0 -0
- package/dist/chunk-54CBEP2W.js +570 -0
- package/dist/chunk-55K5GHAZ.js +252 -0
- package/dist/chunk-5H5CZKGN.js +691 -0
- package/dist/chunk-5NTOJXEZ.js +223 -0
- package/dist/chunk-7WUKH3ZD.js +221 -0
- package/dist/chunk-AE2HPMUZ.js +80 -0
- package/dist/chunk-AEAOYVIL.js +200 -0
- package/dist/chunk-BJFCN2C3.js +46 -0
- package/dist/chunk-DCU3646I.js +12 -0
- package/dist/chunk-DEWAIK5X.js +11 -0
- package/dist/chunk-DRD6EVTT.js +447 -0
- package/dist/chunk-E4KAGBU7.js +134 -0
- package/dist/chunk-EF3677RE.js +93 -0
- package/dist/chunk-EVWDYCBL.js +223 -0
- package/dist/chunk-FAZUMWBS.js +93 -0
- package/dist/chunk-GC2MOT3U.js +12 -0
- package/dist/chunk-GFOBGYW4.js +252 -0
- package/dist/chunk-INDDXWAH.js +92 -0
- package/dist/chunk-K57ZFDGC.js +15 -0
- package/dist/chunk-NDA7EJJA.js +286 -0
- package/dist/chunk-NK2DA4X6.js +357 -0
- package/dist/chunk-OJMTW22J.js +286 -0
- package/dist/chunk-OMUDRPUI.js +195 -0
- package/dist/chunk-ONKKRS2C.js +239 -0
- package/dist/chunk-OWFBBWU7.js +196 -0
- package/dist/chunk-P7EM3N73.js +46 -0
- package/dist/chunk-PR5QKMHM.js +46 -0
- package/dist/chunk-RIP2MIZL.js +710 -0
- package/dist/chunk-TQHLFQY4.js +448 -0
- package/dist/chunk-TY3SSQQK.js +688 -0
- package/dist/chunk-UGKPOCN5.js +710 -0
- package/dist/chunk-VM3CGCNX.js +46 -0
- package/dist/chunk-XOQIADCH.js +223 -0
- package/dist/chunk-YZ34ICNN.js +17 -0
- package/dist/chunk-ZBMOGUSJ.js +17 -0
- package/dist/collab.d.ts +680 -0
- package/dist/collab.js +1917 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +9 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +12 -0
- package/dist/index.cjs +1269 -0
- package/dist/index.d.cts +482 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +34 -0
- package/dist/repo.d.ts +66 -0
- package/dist/repo.js +62 -0
- package/dist/tokenProvider-BWTusyj4.d.ts +63 -0
- package/package.json +72 -0
package/dist/collab.js
ADDED
|
@@ -0,0 +1,1917 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCollabBindingPath,
|
|
3
|
+
readCollabBinding,
|
|
4
|
+
reserveAvailableDirPath,
|
|
5
|
+
reserveDirectory,
|
|
6
|
+
writeCollabBinding
|
|
7
|
+
} from "./chunk-FAZUMWBS.js";
|
|
8
|
+
import {
|
|
9
|
+
assertRepoSnapshotUnchanged,
|
|
10
|
+
buildRepoFingerprint,
|
|
11
|
+
captureRepoSnapshot,
|
|
12
|
+
checkoutLocalBranch,
|
|
13
|
+
cloneGitBundleToDirectory,
|
|
14
|
+
createBackupBranch,
|
|
15
|
+
createGitBundle,
|
|
16
|
+
discardCapturedUntrackedChanges,
|
|
17
|
+
discardTrackedChanges,
|
|
18
|
+
ensureCleanWorktree,
|
|
19
|
+
ensureCommitExists,
|
|
20
|
+
ensureGitInfoExcludeEntries,
|
|
21
|
+
fastForwardToCommit,
|
|
22
|
+
findGitRoot,
|
|
23
|
+
getCurrentBranch,
|
|
24
|
+
getDefaultBranch,
|
|
25
|
+
getGitCommonDir,
|
|
26
|
+
getHeadCommitHash,
|
|
27
|
+
getRemoteOriginUrl,
|
|
28
|
+
getWorkspaceSnapshot,
|
|
29
|
+
getWorktreeStatus,
|
|
30
|
+
hardResetToCommit,
|
|
31
|
+
importGitBundle,
|
|
32
|
+
normalizeGitRemote,
|
|
33
|
+
preserveWorkspaceChanges,
|
|
34
|
+
reapplyPreservedWorkspaceChanges,
|
|
35
|
+
requireCurrentBranch,
|
|
36
|
+
summarizeUnifiedDiff,
|
|
37
|
+
validateUnifiedDiff,
|
|
38
|
+
writeTempUnifiedDiffBackup
|
|
39
|
+
} from "./chunk-UGKPOCN5.js";
|
|
40
|
+
import {
|
|
41
|
+
REMIX_ERROR_CODES
|
|
42
|
+
} from "./chunk-GC2MOT3U.js";
|
|
43
|
+
import {
|
|
44
|
+
RemixError
|
|
45
|
+
} from "./chunk-YZ34ICNN.js";
|
|
46
|
+
|
|
47
|
+
// src/application/collab/collabAdd.ts
|
|
48
|
+
import fs3 from "fs/promises";
|
|
49
|
+
import path3 from "path";
|
|
50
|
+
|
|
51
|
+
// src/application/collab/branchPolicy.ts
|
|
52
|
+
function describeBranch(value) {
|
|
53
|
+
const normalized = String(value ?? "").trim();
|
|
54
|
+
return normalized || "(detached)";
|
|
55
|
+
}
|
|
56
|
+
function isPreferredBranchMatch(currentBranch, preferredBranch) {
|
|
57
|
+
const current = String(currentBranch ?? "").trim();
|
|
58
|
+
const preferred = String(preferredBranch ?? "").trim();
|
|
59
|
+
if (!preferred || !current) return true;
|
|
60
|
+
return current === preferred;
|
|
61
|
+
}
|
|
62
|
+
function buildPreferredBranchMismatchHint(params) {
|
|
63
|
+
const overrideFlag = params.overrideFlag?.trim() || "--allow-branch-mismatch";
|
|
64
|
+
return [
|
|
65
|
+
`Current branch: ${describeBranch(params.currentBranch)}`,
|
|
66
|
+
`Preferred branch: ${describeBranch(params.preferredBranch)}`,
|
|
67
|
+
`Switch to ${describeBranch(params.preferredBranch)} or rerun with ${overrideFlag} if this is intentional.`
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
70
|
+
function assertPreferredBranchMatch(params) {
|
|
71
|
+
if (params.allowBranchMismatch) return;
|
|
72
|
+
if (isPreferredBranchMatch(params.currentBranch, params.preferredBranch)) return;
|
|
73
|
+
throw new RemixError(`Current branch does not match this checkout's Remix preferred branch while running ${params.operation}.`, {
|
|
74
|
+
code: REMIX_ERROR_CODES.PREFERRED_BRANCH_MISMATCH,
|
|
75
|
+
exitCode: 2,
|
|
76
|
+
hint: buildPreferredBranchMismatchHint({
|
|
77
|
+
currentBranch: params.currentBranch,
|
|
78
|
+
preferredBranch: params.preferredBranch,
|
|
79
|
+
overrideFlag: params.overrideFlag
|
|
80
|
+
})
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/application/collab/shared.ts
|
|
85
|
+
import { createHash } from "crypto";
|
|
86
|
+
function unwrapResponseObject(resp, label) {
|
|
87
|
+
const obj = resp?.responseObject;
|
|
88
|
+
if (obj === void 0 || obj === null) {
|
|
89
|
+
const message = typeof resp?.message === "string" && resp.message.trim().length > 0 ? resp.message : `Missing ${label} response`;
|
|
90
|
+
throw new RemixError(message, { exitCode: 1, hint: resp ? JSON.stringify(resp, null, 2) : null });
|
|
91
|
+
}
|
|
92
|
+
return obj;
|
|
93
|
+
}
|
|
94
|
+
function unwrapMergeRequest(resp) {
|
|
95
|
+
return unwrapResponseObject(resp, "merge request");
|
|
96
|
+
}
|
|
97
|
+
function unwrapMergeRequestReview(resp) {
|
|
98
|
+
return unwrapResponseObject(resp, "merge request review");
|
|
99
|
+
}
|
|
100
|
+
function sleep(ms) {
|
|
101
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
102
|
+
}
|
|
103
|
+
function buildDeterministicIdempotencyKey(parts) {
|
|
104
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
105
|
+
}
|
|
106
|
+
function formatCliErrorDetail(err) {
|
|
107
|
+
if (err instanceof RemixError) {
|
|
108
|
+
return [err.message, err.hint].filter(Boolean).join("\n\n") || null;
|
|
109
|
+
}
|
|
110
|
+
if (err instanceof Error) {
|
|
111
|
+
return err.message || null;
|
|
112
|
+
}
|
|
113
|
+
return typeof err === "string" && err.trim() ? err.trim() : null;
|
|
114
|
+
}
|
|
115
|
+
function sanitizeCheckoutDirName(value) {
|
|
116
|
+
const sanitized = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
117
|
+
return sanitized || "remix-remix";
|
|
118
|
+
}
|
|
119
|
+
async function pollAppReady(api, appId) {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
let delay = 2e3;
|
|
122
|
+
while (Date.now() - started < 20 * 60 * 1e3) {
|
|
123
|
+
const appResp = await api.getApp(appId);
|
|
124
|
+
const app = unwrapResponseObject(appResp, "app");
|
|
125
|
+
const status = typeof app.status === "string" ? app.status : "";
|
|
126
|
+
if (status === "ready") return app;
|
|
127
|
+
if (status === "error") {
|
|
128
|
+
throw new RemixError("App is in error state.", {
|
|
129
|
+
exitCode: 1,
|
|
130
|
+
hint: typeof app.statusError === "string" ? app.statusError : null
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
await sleep(delay);
|
|
134
|
+
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
135
|
+
}
|
|
136
|
+
throw new RemixError("Timed out waiting for app to become ready.", { exitCode: 1 });
|
|
137
|
+
}
|
|
138
|
+
async function pollChangeStep(api, appId, changeStepId) {
|
|
139
|
+
const started = Date.now();
|
|
140
|
+
let delay = 1500;
|
|
141
|
+
while (Date.now() - started < 20 * 60 * 1e3) {
|
|
142
|
+
const resp = await api.getChangeStep(appId, changeStepId);
|
|
143
|
+
const step = unwrapResponseObject(resp, "change step");
|
|
144
|
+
const status = typeof step.status === "string" ? step.status : "";
|
|
145
|
+
if (status === "succeeded") return step;
|
|
146
|
+
if (status === "failed") {
|
|
147
|
+
throw new RemixError("Change step failed.", {
|
|
148
|
+
exitCode: 1,
|
|
149
|
+
hint: typeof step.statusError === "string" ? step.statusError : null
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
await sleep(delay);
|
|
153
|
+
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
154
|
+
}
|
|
155
|
+
throw new RemixError("Timed out waiting for change step.", { exitCode: 1 });
|
|
156
|
+
}
|
|
157
|
+
async function pollReconcile(api, appId, reconcileId) {
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
let delay = 1500;
|
|
160
|
+
while (Date.now() - started < 30 * 60 * 1e3) {
|
|
161
|
+
const resp = await api.getAppReconcile(appId, reconcileId);
|
|
162
|
+
const reconcile = unwrapResponseObject(resp, "reconcile");
|
|
163
|
+
if (reconcile.status === "succeeded") return reconcile;
|
|
164
|
+
if (reconcile.status === "manual_reconcile_required") {
|
|
165
|
+
throw new RemixError("Reconciliation requires manual intervention.", {
|
|
166
|
+
exitCode: 2,
|
|
167
|
+
hint: reconcile.statusError || "The server could not safely replay the local-only commits."
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (reconcile.status === "failed" || reconcile.status === "cancelled") {
|
|
171
|
+
throw new RemixError("Reconciliation failed.", {
|
|
172
|
+
exitCode: 1,
|
|
173
|
+
hint: reconcile.statusError || null
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
await sleep(delay);
|
|
177
|
+
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
178
|
+
}
|
|
179
|
+
throw new RemixError("Timed out waiting for reconcile job.", { exitCode: 1 });
|
|
180
|
+
}
|
|
181
|
+
async function pollChangeStepReplay(api, appId, replayId) {
|
|
182
|
+
const started = Date.now();
|
|
183
|
+
let delay = 1500;
|
|
184
|
+
while (Date.now() - started < 30 * 60 * 1e3) {
|
|
185
|
+
const resp = await api.getChangeStepReplay(appId, replayId);
|
|
186
|
+
const replay = unwrapResponseObject(resp, "change step replay");
|
|
187
|
+
if (replay.status === "succeeded") return replay;
|
|
188
|
+
if (replay.status === "manual_resolution_required") {
|
|
189
|
+
throw new RemixError("AI-assisted replay requires manual intervention.", {
|
|
190
|
+
exitCode: 2,
|
|
191
|
+
hint: replay.statusError || "The server could not safely adapt the preserved local diff."
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (replay.status === "failed" || replay.status === "cancelled") {
|
|
195
|
+
throw new RemixError("AI-assisted replay failed.", {
|
|
196
|
+
exitCode: 1,
|
|
197
|
+
hint: replay.statusError || null
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
await sleep(delay);
|
|
201
|
+
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
202
|
+
}
|
|
203
|
+
throw new RemixError("Timed out waiting for AI-assisted replay.", { exitCode: 1 });
|
|
204
|
+
}
|
|
205
|
+
async function pollUpstreamSyncCompletion(api, appId, params) {
|
|
206
|
+
const started = Date.now();
|
|
207
|
+
let delay = 1500;
|
|
208
|
+
let sawNonReady = params.initialStatus !== "ready";
|
|
209
|
+
while (Date.now() - started < 30 * 60 * 1e3) {
|
|
210
|
+
const appResp = await api.getApp(appId);
|
|
211
|
+
const app = unwrapResponseObject(appResp, "app");
|
|
212
|
+
const status = typeof app.status === "string" ? app.status : "";
|
|
213
|
+
const headCommitId = typeof app.headCommitId === "string" ? app.headCommitId : null;
|
|
214
|
+
if (status === "error") {
|
|
215
|
+
throw new RemixError("App upstream sync failed.", {
|
|
216
|
+
exitCode: 1,
|
|
217
|
+
hint: typeof app.statusError === "string" ? app.statusError : null
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (status !== "ready") sawNonReady = true;
|
|
221
|
+
if (status === "ready" && headCommitId && headCommitId !== params.initialHeadCommitId) {
|
|
222
|
+
return app;
|
|
223
|
+
}
|
|
224
|
+
if (status === "ready" && sawNonReady) {
|
|
225
|
+
return app;
|
|
226
|
+
}
|
|
227
|
+
await sleep(delay);
|
|
228
|
+
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
229
|
+
}
|
|
230
|
+
throw new RemixError("Timed out waiting for upstream sync.", { exitCode: 1 });
|
|
231
|
+
}
|
|
232
|
+
async function pollMergeRequestCompletion(api, mrId, params) {
|
|
233
|
+
const started = Date.now();
|
|
234
|
+
let delay = 1500;
|
|
235
|
+
while (Date.now() - started < 30 * 60 * 1e3) {
|
|
236
|
+
const reviewResp = await api.getMergeRequestReview(mrId);
|
|
237
|
+
const review = unwrapMergeRequestReview(reviewResp);
|
|
238
|
+
const mergeRequest = review.mergeRequest;
|
|
239
|
+
if (mergeRequest.status === "merged") return mergeRequest;
|
|
240
|
+
if (mergeRequest.status === "rejected" || mergeRequest.status === "closed") {
|
|
241
|
+
throw new RemixError("Merge approval did not complete successfully.", {
|
|
242
|
+
exitCode: 1,
|
|
243
|
+
hint: `Merge request ended in status=${mergeRequest.status}.`
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (params?.targetAppId) {
|
|
247
|
+
const appResp = await api.getApp(params.targetAppId);
|
|
248
|
+
const app = unwrapResponseObject(appResp, "app");
|
|
249
|
+
const status = typeof app.status === "string" ? app.status : "";
|
|
250
|
+
if (status === "error") {
|
|
251
|
+
throw new RemixError("Merge job failed.", {
|
|
252
|
+
exitCode: 1,
|
|
253
|
+
hint: typeof app.statusError === "string" ? app.statusError : null
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
await sleep(delay);
|
|
258
|
+
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
259
|
+
}
|
|
260
|
+
throw new RemixError("Timed out waiting for merge approval to complete.", { exitCode: 1 });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/infrastructure/locking/repoMutationLock.ts
|
|
264
|
+
import fs from "fs/promises";
|
|
265
|
+
import os from "os";
|
|
266
|
+
import path from "path";
|
|
267
|
+
var DEFAULT_ACQUIRE_TIMEOUT_MS = 15e3;
|
|
268
|
+
var DEFAULT_STALE_MS = 45e3;
|
|
269
|
+
var DEFAULT_HEARTBEAT_MS = 5e3;
|
|
270
|
+
var RETRY_DELAY_MS = 250;
|
|
271
|
+
var heldLocks = /* @__PURE__ */ new Map();
|
|
272
|
+
function sleep2(ms) {
|
|
273
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
274
|
+
}
|
|
275
|
+
function createOwner(params) {
|
|
276
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
277
|
+
return {
|
|
278
|
+
operation: params.operation,
|
|
279
|
+
repoRoot: params.repoRoot,
|
|
280
|
+
pid: process.pid,
|
|
281
|
+
hostname: os.hostname(),
|
|
282
|
+
startedAt: now,
|
|
283
|
+
heartbeatAt: now,
|
|
284
|
+
version: process.version,
|
|
285
|
+
requestId: params.requestId?.trim() || null
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
async function writeOwnerMetadata(ownerPath, owner) {
|
|
289
|
+
await fs.writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}
|
|
290
|
+
`, "utf8");
|
|
291
|
+
}
|
|
292
|
+
async function readOwnerMetadata(ownerPath) {
|
|
293
|
+
try {
|
|
294
|
+
const raw = await fs.readFile(ownerPath, "utf8");
|
|
295
|
+
const parsed = JSON.parse(raw);
|
|
296
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
297
|
+
if (!parsed.operation || !parsed.repoRoot || typeof parsed.pid !== "number" || !parsed.startedAt || !parsed.heartbeatAt) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
operation: parsed.operation,
|
|
302
|
+
repoRoot: parsed.repoRoot,
|
|
303
|
+
pid: parsed.pid,
|
|
304
|
+
hostname: typeof parsed.hostname === "string" ? parsed.hostname : "unknown",
|
|
305
|
+
startedAt: parsed.startedAt,
|
|
306
|
+
heartbeatAt: parsed.heartbeatAt,
|
|
307
|
+
version: typeof parsed.version === "string" ? parsed.version : "unknown",
|
|
308
|
+
requestId: typeof parsed.requestId === "string" ? parsed.requestId : null
|
|
309
|
+
};
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async function isProcessAlive(owner) {
|
|
315
|
+
if (!owner) return null;
|
|
316
|
+
if (owner.hostname !== os.hostname()) return null;
|
|
317
|
+
try {
|
|
318
|
+
process.kill(owner.pid, 0);
|
|
319
|
+
return true;
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (error?.code === "EPERM") return true;
|
|
322
|
+
if (error?.code === "ESRCH") return false;
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function getLastKnownUpdateMs(lockDir, ownerPath, owner) {
|
|
327
|
+
const heartbeatMs = owner ? Date.parse(owner.heartbeatAt) : Number.NaN;
|
|
328
|
+
if (Number.isFinite(heartbeatMs)) return heartbeatMs;
|
|
329
|
+
const startedMs = owner ? Date.parse(owner.startedAt) : Number.NaN;
|
|
330
|
+
if (Number.isFinite(startedMs)) return startedMs;
|
|
331
|
+
const stat = await fs.stat(ownerPath).catch(() => null);
|
|
332
|
+
if (stat) return stat.mtimeMs;
|
|
333
|
+
const dirStat = await fs.stat(lockDir).catch(() => null);
|
|
334
|
+
if (dirStat) return dirStat.mtimeMs;
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
async function ensureLockDir(lockDir) {
|
|
338
|
+
await fs.mkdir(path.dirname(lockDir), { recursive: true });
|
|
339
|
+
}
|
|
340
|
+
async function tryAcquireLock(lockDir, ownerPath, owner) {
|
|
341
|
+
try {
|
|
342
|
+
await ensureLockDir(lockDir);
|
|
343
|
+
await fs.mkdir(lockDir);
|
|
344
|
+
try {
|
|
345
|
+
await writeOwnerMetadata(ownerPath, owner);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error?.code === "EEXIST") return false;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function formatLockHint(params) {
|
|
357
|
+
const lines = [
|
|
358
|
+
params.observedHeldLock ? `Observed lock state: ${REMIX_ERROR_CODES.REPO_LOCK_HELD}.` : null,
|
|
359
|
+
params.owner ? `Active operation: ${params.owner.operation}` : "Active operation: unknown",
|
|
360
|
+
params.owner ? `Repo root: ${params.owner.repoRoot}` : null,
|
|
361
|
+
params.owner ? `Owner: pid=${params.owner.pid} host=${params.owner.hostname}` : null,
|
|
362
|
+
params.owner ? `Started at: ${params.owner.startedAt}` : null,
|
|
363
|
+
params.owner ? `Heartbeat at: ${params.owner.heartbeatAt}` : null,
|
|
364
|
+
`Waited ${params.waitedMs}ms for the repo mutation lock.`,
|
|
365
|
+
`Stale lock threshold: ${params.staleMs}ms.`,
|
|
366
|
+
"Retry after the active operation finishes. If the process crashed, wait for stale lock recovery or remove the stale lock manually if necessary."
|
|
367
|
+
];
|
|
368
|
+
return lines.filter(Boolean).join("\n");
|
|
369
|
+
}
|
|
370
|
+
function formatOwnerSummary(owner) {
|
|
371
|
+
if (!owner) {
|
|
372
|
+
return "unknown owner";
|
|
373
|
+
}
|
|
374
|
+
return `operation=${owner.operation} pid=${owner.pid} host=${owner.hostname} startedAt=${owner.startedAt} heartbeatAt=${owner.heartbeatAt}`;
|
|
375
|
+
}
|
|
376
|
+
function buildStaleRecoveryNotice(owner) {
|
|
377
|
+
return {
|
|
378
|
+
code: REMIX_ERROR_CODES.REPO_LOCK_STALE_RECOVERED,
|
|
379
|
+
owner,
|
|
380
|
+
message: `[${REMIX_ERROR_CODES.REPO_LOCK_STALE_RECOVERED}] Recovered a stale Remix repo mutation lock (${formatOwnerSummary(owner)}).`
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
async function acquirePhysicalLock(lockDir, ownerPath, owner, options) {
|
|
384
|
+
const startedAt = Date.now();
|
|
385
|
+
const notices = [];
|
|
386
|
+
let observedHeldLock = false;
|
|
387
|
+
while (Date.now() - startedAt < options.acquireTimeoutMs) {
|
|
388
|
+
if (await tryAcquireLock(lockDir, ownerPath, owner)) return notices;
|
|
389
|
+
const currentOwner2 = await readOwnerMetadata(ownerPath);
|
|
390
|
+
observedHeldLock = true;
|
|
391
|
+
const lastUpdateMs = await getLastKnownUpdateMs(lockDir, ownerPath, currentOwner2);
|
|
392
|
+
const ageMs = Math.max(0, Date.now() - lastUpdateMs);
|
|
393
|
+
const alive = await isProcessAlive(currentOwner2);
|
|
394
|
+
if (ageMs >= options.staleMs && alive !== true) {
|
|
395
|
+
notices.push(buildStaleRecoveryNotice(currentOwner2));
|
|
396
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
await sleep2(RETRY_DELAY_MS);
|
|
400
|
+
}
|
|
401
|
+
const currentOwner = await readOwnerMetadata(ownerPath);
|
|
402
|
+
throw new RemixError("Repository is busy with another Remix mutation.", {
|
|
403
|
+
code: REMIX_ERROR_CODES.REPO_LOCK_TIMEOUT,
|
|
404
|
+
exitCode: 2,
|
|
405
|
+
hint: formatLockHint({
|
|
406
|
+
owner: currentOwner,
|
|
407
|
+
waitedMs: Date.now() - startedAt,
|
|
408
|
+
staleMs: options.staleMs,
|
|
409
|
+
observedHeldLock
|
|
410
|
+
})
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
function startHeartbeat(lockDir, ownerPath, owner, heartbeatMs) {
|
|
414
|
+
return setInterval(() => {
|
|
415
|
+
const nextOwner = {
|
|
416
|
+
...owner,
|
|
417
|
+
heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
418
|
+
};
|
|
419
|
+
owner.heartbeatAt = nextOwner.heartbeatAt;
|
|
420
|
+
void writeOwnerMetadata(ownerPath, nextOwner).catch(() => void 0);
|
|
421
|
+
void fs.utimes(lockDir, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
422
|
+
}, heartbeatMs);
|
|
423
|
+
}
|
|
424
|
+
async function releaseReentrantLock(lockDir) {
|
|
425
|
+
const held = heldLocks.get(lockDir);
|
|
426
|
+
if (!held) return;
|
|
427
|
+
held.count -= 1;
|
|
428
|
+
if (held.count > 0) return;
|
|
429
|
+
clearInterval(held.heartbeatTimer);
|
|
430
|
+
heldLocks.delete(lockDir);
|
|
431
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
432
|
+
}
|
|
433
|
+
async function withRepoMutationLock(options, fn) {
|
|
434
|
+
const repoRoot = await findGitRoot(options.cwd);
|
|
435
|
+
const gitCommonDir = await getGitCommonDir(repoRoot);
|
|
436
|
+
const lockDir = path.join(gitCommonDir, "remix", "locks", "repo-mutation.lock");
|
|
437
|
+
const owner = createOwner({
|
|
438
|
+
operation: options.operation,
|
|
439
|
+
repoRoot,
|
|
440
|
+
requestId: options.requestId
|
|
441
|
+
});
|
|
442
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
443
|
+
const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
444
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
445
|
+
const existing = heldLocks.get(lockDir);
|
|
446
|
+
let notices = [];
|
|
447
|
+
if (!existing) {
|
|
448
|
+
notices = await acquirePhysicalLock(lockDir, path.join(lockDir, "owner.json"), owner, {
|
|
449
|
+
acquireTimeoutMs,
|
|
450
|
+
staleMs
|
|
451
|
+
});
|
|
452
|
+
const ownerPath = path.join(lockDir, "owner.json");
|
|
453
|
+
heldLocks.set(lockDir, {
|
|
454
|
+
count: 1,
|
|
455
|
+
lockDir,
|
|
456
|
+
ownerPath,
|
|
457
|
+
owner,
|
|
458
|
+
heartbeatTimer: startHeartbeat(lockDir, ownerPath, owner, heartbeatMs)
|
|
459
|
+
});
|
|
460
|
+
} else {
|
|
461
|
+
existing.count += 1;
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
return await fn({
|
|
465
|
+
repoRoot,
|
|
466
|
+
gitCommonDir,
|
|
467
|
+
lockDir,
|
|
468
|
+
notices,
|
|
469
|
+
warnings: notices.map((notice) => notice.message)
|
|
470
|
+
});
|
|
471
|
+
} finally {
|
|
472
|
+
await releaseReentrantLock(lockDir);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/application/collab/collabSync.ts
|
|
477
|
+
import fs2 from "fs/promises";
|
|
478
|
+
import os2 from "os";
|
|
479
|
+
import path2 from "path";
|
|
480
|
+
async function collabSync(params) {
|
|
481
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
482
|
+
const binding = await readCollabBinding(repoRoot);
|
|
483
|
+
if (!binding) {
|
|
484
|
+
throw new RemixError("Repository is not bound to Remix.", {
|
|
485
|
+
exitCode: 2,
|
|
486
|
+
hint: "Run `remix collab init` first."
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
await ensureCleanWorktree(repoRoot);
|
|
490
|
+
const branch = await requireCurrentBranch(repoRoot);
|
|
491
|
+
assertPreferredBranchMatch({
|
|
492
|
+
currentBranch: branch,
|
|
493
|
+
preferredBranch: binding.preferredBranch,
|
|
494
|
+
allowBranchMismatch: params.allowBranchMismatch,
|
|
495
|
+
operation: "`remix collab sync`"
|
|
496
|
+
});
|
|
497
|
+
const headCommitHash = await getHeadCommitHash(repoRoot);
|
|
498
|
+
const repoSnapshot = await captureRepoSnapshot(repoRoot);
|
|
499
|
+
if (!headCommitHash) {
|
|
500
|
+
throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
|
|
501
|
+
}
|
|
502
|
+
const resp = await params.api.syncLocalApp(binding.currentAppId, {
|
|
503
|
+
baseCommitHash: headCommitHash,
|
|
504
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
505
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
506
|
+
defaultBranch: binding.defaultBranch ?? void 0,
|
|
507
|
+
dryRun: params.dryRun
|
|
508
|
+
});
|
|
509
|
+
const sync = unwrapResponseObject(resp, "sync result");
|
|
510
|
+
if (sync.status === "conflict_risk") {
|
|
511
|
+
throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
|
|
512
|
+
exitCode: 2,
|
|
513
|
+
hint: sync.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
if (sync.status === "base_unknown") {
|
|
517
|
+
throw new RemixError("Local repository cannot be fast-forward synced.", {
|
|
518
|
+
exitCode: 2,
|
|
519
|
+
hint: "Your local HEAD is not on the app sandbox history. Reconcile the repository manually before syncing."
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
if (sync.status === "up_to_date") {
|
|
523
|
+
return {
|
|
524
|
+
status: sync.status,
|
|
525
|
+
branch,
|
|
526
|
+
repoRoot,
|
|
527
|
+
baseCommitHash: sync.baseCommitHash,
|
|
528
|
+
targetCommitHash: sync.targetCommitHash,
|
|
529
|
+
targetCommitId: sync.targetCommitId,
|
|
530
|
+
stats: sync.stats,
|
|
531
|
+
localCommitHash: headCommitHash,
|
|
532
|
+
applied: false,
|
|
533
|
+
dryRun: params.dryRun
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
const previewResult = {
|
|
537
|
+
status: sync.status,
|
|
538
|
+
branch,
|
|
539
|
+
repoRoot,
|
|
540
|
+
baseCommitHash: sync.baseCommitHash,
|
|
541
|
+
targetCommitHash: sync.targetCommitHash,
|
|
542
|
+
targetCommitId: sync.targetCommitId,
|
|
543
|
+
stats: sync.stats,
|
|
544
|
+
bundleRef: sync.bundleRef,
|
|
545
|
+
bundleSizeBytes: sync.bundleSizeBytes,
|
|
546
|
+
localCommitHash: headCommitHash,
|
|
547
|
+
applied: false,
|
|
548
|
+
dryRun: params.dryRun
|
|
549
|
+
};
|
|
550
|
+
if (params.dryRun) {
|
|
551
|
+
return previewResult;
|
|
552
|
+
}
|
|
553
|
+
if (!sync.bundleBase64 || !sync.bundleRef) {
|
|
554
|
+
throw new RemixError("Sync bundle payload is missing.", { exitCode: 1 });
|
|
555
|
+
}
|
|
556
|
+
const bundleBase64 = sync.bundleBase64;
|
|
557
|
+
const bundleRef = sync.bundleRef;
|
|
558
|
+
return withRepoMutationLock(
|
|
559
|
+
{
|
|
560
|
+
cwd: repoRoot,
|
|
561
|
+
operation: "collabSync"
|
|
562
|
+
},
|
|
563
|
+
async ({ repoRoot: lockedRepoRoot, warnings }) => {
|
|
564
|
+
await assertRepoSnapshotUnchanged(lockedRepoRoot, repoSnapshot, {
|
|
565
|
+
operation: "`remix collab sync`",
|
|
566
|
+
recoveryHint: "The repository changed after sync was prepared. Review the local changes and rerun `remix collab sync`."
|
|
567
|
+
});
|
|
568
|
+
await ensureCleanWorktree(lockedRepoRoot);
|
|
569
|
+
const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
|
|
570
|
+
assertPreferredBranchMatch({
|
|
571
|
+
currentBranch: lockedBranch,
|
|
572
|
+
preferredBranch: binding.preferredBranch,
|
|
573
|
+
allowBranchMismatch: params.allowBranchMismatch,
|
|
574
|
+
operation: "`remix collab sync`"
|
|
575
|
+
});
|
|
576
|
+
const tempDir = await fs2.mkdtemp(path2.join(os2.tmpdir(), "remix-sync-"));
|
|
577
|
+
const bundlePath = path2.join(tempDir, "sync-local.bundle");
|
|
578
|
+
try {
|
|
579
|
+
await fs2.writeFile(bundlePath, Buffer.from(bundleBase64, "base64"));
|
|
580
|
+
await importGitBundle(lockedRepoRoot, bundlePath, bundleRef);
|
|
581
|
+
await ensureCommitExists(lockedRepoRoot, sync.targetCommitHash);
|
|
582
|
+
const localCommitHash = await fastForwardToCommit(lockedRepoRoot, sync.targetCommitHash);
|
|
583
|
+
return {
|
|
584
|
+
...previewResult,
|
|
585
|
+
localCommitHash,
|
|
586
|
+
applied: true,
|
|
587
|
+
dryRun: false,
|
|
588
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
589
|
+
};
|
|
590
|
+
} finally {
|
|
591
|
+
await fs2.rm(tempDir, { recursive: true, force: true });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/application/collab/collabAdd.ts
|
|
598
|
+
async function preflightSyncStatus(params) {
|
|
599
|
+
const resp = await params.api.syncLocalApp(params.appId, {
|
|
600
|
+
baseCommitHash: params.headCommitHash,
|
|
601
|
+
repoFingerprint: params.repoFingerprint ?? void 0,
|
|
602
|
+
remoteUrl: params.remoteUrl ?? void 0,
|
|
603
|
+
defaultBranch: params.defaultBranch ?? void 0,
|
|
604
|
+
dryRun: true
|
|
605
|
+
});
|
|
606
|
+
return unwrapResponseObject(resp, "sync result");
|
|
607
|
+
}
|
|
608
|
+
function assertSupportedSyncStatus(sync) {
|
|
609
|
+
if (sync.status === "conflict_risk") {
|
|
610
|
+
throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
|
|
611
|
+
exitCode: 2,
|
|
612
|
+
hint: sync.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
if (sync.status === "base_unknown") {
|
|
616
|
+
throw new RemixError("Local repository cannot be fast-forward synced.", {
|
|
617
|
+
exitCode: 2,
|
|
618
|
+
hint: "Run `remix collab reconcile` first because the local history is no longer fast-forward compatible with the app."
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async function collabAdd(params) {
|
|
623
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
624
|
+
const binding = await readCollabBinding(repoRoot);
|
|
625
|
+
if (!binding) {
|
|
626
|
+
throw new RemixError("Repository is not bound to Remix.", {
|
|
627
|
+
exitCode: 2,
|
|
628
|
+
hint: "Run `remix collab init` first."
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
const prompt = params.prompt.trim();
|
|
632
|
+
if (!prompt) throw new RemixError("Prompt is required.", { exitCode: 2 });
|
|
633
|
+
const assistantResponse = params.assistantResponse?.trim() || null;
|
|
634
|
+
const attachWarnings = (value, warnings) => warnings.length > 0 ? { ...value, warnings } : value;
|
|
635
|
+
const diffSource = params.diffSource ?? (params.diff ? "external" : "worktree");
|
|
636
|
+
const autoSyncEnabled = params.sync !== false;
|
|
637
|
+
const run = async (lockWarnings = []) => {
|
|
638
|
+
const branch = await getCurrentBranch(repoRoot);
|
|
639
|
+
assertPreferredBranchMatch({
|
|
640
|
+
currentBranch: branch,
|
|
641
|
+
preferredBranch: binding.preferredBranch,
|
|
642
|
+
allowBranchMismatch: params.allowBranchMismatch,
|
|
643
|
+
operation: "`remix collab add`"
|
|
644
|
+
});
|
|
645
|
+
let headCommitHash = await getHeadCommitHash(repoRoot);
|
|
646
|
+
if (!headCommitHash) {
|
|
647
|
+
throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
|
|
648
|
+
}
|
|
649
|
+
const worktreeStatus = await getWorktreeStatus(repoRoot);
|
|
650
|
+
const syncPreview = await preflightSyncStatus({
|
|
651
|
+
api: params.api,
|
|
652
|
+
appId: binding.currentAppId,
|
|
653
|
+
headCommitHash,
|
|
654
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
655
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
656
|
+
defaultBranch: binding.defaultBranch ?? void 0
|
|
657
|
+
});
|
|
658
|
+
assertSupportedSyncStatus(syncPreview);
|
|
659
|
+
if (syncPreview.status === "ready_to_fast_forward") {
|
|
660
|
+
if (!autoSyncEnabled) {
|
|
661
|
+
throw new RemixError("Local repository is stale and `collab add` sync automation is disabled.", {
|
|
662
|
+
exitCode: 2,
|
|
663
|
+
hint: "Run `remix collab sync` first, or rerun without disabling sync automation."
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
if (!worktreeStatus.isClean && diffSource !== "worktree") {
|
|
667
|
+
throw new RemixError("Automatic stale-work replay requires the current worktree diff.", {
|
|
668
|
+
exitCode: 2,
|
|
669
|
+
hint: "Use `remix collab add` without an external diff while the local repo is dirty, or clean the repo before submitting an external diff."
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (worktreeStatus.isClean) {
|
|
673
|
+
await collabSync({
|
|
674
|
+
api: params.api,
|
|
675
|
+
cwd: repoRoot,
|
|
676
|
+
dryRun: false,
|
|
677
|
+
allowBranchMismatch: params.allowBranchMismatch
|
|
678
|
+
});
|
|
679
|
+
headCommitHash = await getHeadCommitHash(repoRoot);
|
|
680
|
+
if (!headCommitHash) {
|
|
681
|
+
throw new RemixError("Failed to resolve local HEAD after syncing.", { exitCode: 1 });
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
const staleWorkSnapshot = await captureRepoSnapshot(repoRoot, { includeWorkspaceDiffHash: true });
|
|
685
|
+
const preserved = await preserveWorkspaceChanges(repoRoot, "remix-add-preserve");
|
|
686
|
+
try {
|
|
687
|
+
await assertRepoSnapshotUnchanged(repoRoot, staleWorkSnapshot, {
|
|
688
|
+
operation: "`remix collab add` stale-work pre-sync",
|
|
689
|
+
recoveryHint: "The worktree changed while local changes were being preserved. Review the local changes and rerun `remix collab add`."
|
|
690
|
+
});
|
|
691
|
+
await discardTrackedChanges(repoRoot, "`remix collab add`");
|
|
692
|
+
await discardCapturedUntrackedChanges(repoRoot, preserved.includedUntrackedPaths);
|
|
693
|
+
await collabSync({
|
|
694
|
+
api: params.api,
|
|
695
|
+
cwd: repoRoot,
|
|
696
|
+
dryRun: false,
|
|
697
|
+
allowBranchMismatch: params.allowBranchMismatch
|
|
698
|
+
});
|
|
699
|
+
} catch (err) {
|
|
700
|
+
const detail = formatCliErrorDetail(err);
|
|
701
|
+
const hint = [
|
|
702
|
+
detail,
|
|
703
|
+
`The preserved local diff is available at: ${preserved.preservedDiffPath}`
|
|
704
|
+
].filter(Boolean).join("\n\n");
|
|
705
|
+
throw new RemixError("Failed to sync the stale repository before submitting the change step.", {
|
|
706
|
+
exitCode: err instanceof RemixError ? err.exitCode : 1,
|
|
707
|
+
hint
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
headCommitHash = await getHeadCommitHash(repoRoot);
|
|
711
|
+
if (!headCommitHash) {
|
|
712
|
+
throw new RemixError("Failed to resolve local HEAD after syncing.", { exitCode: 1 });
|
|
713
|
+
}
|
|
714
|
+
const deterministicReapply = await reapplyPreservedWorkspaceChanges(repoRoot, preserved);
|
|
715
|
+
if (deterministicReapply.status === "failed") {
|
|
716
|
+
const hint = [
|
|
717
|
+
deterministicReapply.detail,
|
|
718
|
+
`The preserved local diff is available at: ${preserved.preservedDiffPath}`
|
|
719
|
+
].filter(Boolean).join("\n\n");
|
|
720
|
+
throw new RemixError("Failed to restore preserved local changes after syncing.", {
|
|
721
|
+
exitCode: 1,
|
|
722
|
+
hint
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
if (deterministicReapply.status === "conflict") {
|
|
726
|
+
try {
|
|
727
|
+
const replayResp = await params.api.startChangeStepReplay(binding.currentAppId, {
|
|
728
|
+
prompt,
|
|
729
|
+
assistantResponse: assistantResponse ?? void 0,
|
|
730
|
+
diff: await fs3.readFile(preserved.preservedDiffPath, "utf8"),
|
|
731
|
+
baseCommitHash: preserved.baseHeadCommitHash,
|
|
732
|
+
targetHeadCommitHash: headCommitHash,
|
|
733
|
+
expectedPaths: preserved.stagePlan.expectedPaths,
|
|
734
|
+
actor: params.actor,
|
|
735
|
+
workspaceMetadata: {
|
|
736
|
+
branch,
|
|
737
|
+
repoRoot,
|
|
738
|
+
remoteUrl: binding.remoteUrl,
|
|
739
|
+
defaultBranch: binding.defaultBranch
|
|
740
|
+
},
|
|
741
|
+
idempotencyKey: buildDeterministicIdempotencyKey({
|
|
742
|
+
appId: binding.currentAppId,
|
|
743
|
+
baseCommitHash: preserved.baseHeadCommitHash,
|
|
744
|
+
targetHeadCommitHash: headCommitHash,
|
|
745
|
+
prompt,
|
|
746
|
+
assistantResponse,
|
|
747
|
+
preservedDiffSha256: preserved.preservedDiffSha256
|
|
748
|
+
})
|
|
749
|
+
});
|
|
750
|
+
const startedReplay = unwrapResponseObject(replayResp, "change step replay");
|
|
751
|
+
const replay = await pollChangeStepReplay(params.api, binding.currentAppId, String(startedReplay.id));
|
|
752
|
+
const replayDiffResp = await params.api.getChangeStepReplayDiff(binding.currentAppId, String(replay.id));
|
|
753
|
+
const replayDiff = unwrapResponseObject(replayDiffResp, "change step replay diff");
|
|
754
|
+
const { backupPath: backupPath2, diffSha256 } = await writeTempUnifiedDiffBackup(replayDiff.diff, "remix-add-ai-replay");
|
|
755
|
+
const replayApply = await reapplyPreservedWorkspaceChanges(repoRoot, {
|
|
756
|
+
baseHeadCommitHash: headCommitHash,
|
|
757
|
+
preservedDiffPath: backupPath2,
|
|
758
|
+
preservedDiffSha256: diffSha256,
|
|
759
|
+
includedUntrackedPaths: [],
|
|
760
|
+
stagePlan: preserved.stagePlan
|
|
761
|
+
});
|
|
762
|
+
if (replayApply.status !== "clean") {
|
|
763
|
+
const hint = [
|
|
764
|
+
replayApply.detail,
|
|
765
|
+
`The preserved local diff is available at: ${preserved.preservedDiffPath}`,
|
|
766
|
+
`The AI-replayed diff is available at: ${backupPath2}`
|
|
767
|
+
].filter(Boolean).join("\n\n");
|
|
768
|
+
throw new RemixError("AI-assisted stale-work replay produced a diff that could not be applied locally.", {
|
|
769
|
+
exitCode: 1,
|
|
770
|
+
hint
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
} catch (err) {
|
|
774
|
+
const detail = formatCliErrorDetail(err);
|
|
775
|
+
const hint = [
|
|
776
|
+
detail,
|
|
777
|
+
`The preserved local diff is available at: ${preserved.preservedDiffPath}`,
|
|
778
|
+
"Resolve the local conflict manually if needed, then rerun `remix collab add`."
|
|
779
|
+
].filter(Boolean).join("\n\n");
|
|
780
|
+
throw new RemixError("AI-assisted stale-work replay could not complete safely.", {
|
|
781
|
+
exitCode: err instanceof RemixError ? err.exitCode : 1,
|
|
782
|
+
hint
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const workspaceSnapshot = diffSource === "external" ? null : await getWorkspaceSnapshot(repoRoot);
|
|
789
|
+
const submissionSnapshot = diffSource === "worktree" ? await captureRepoSnapshot(repoRoot, { includeWorkspaceDiffHash: true }) : null;
|
|
790
|
+
const diff = params.diff ?? workspaceSnapshot?.diff ?? "";
|
|
791
|
+
if (!diff.trim()) {
|
|
792
|
+
throw new RemixError("Diff is empty.", {
|
|
793
|
+
exitCode: 2,
|
|
794
|
+
hint: "Make changes first, or pass `--diff-file`/`--diff-stdin`."
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
if (diffSource === "external") {
|
|
798
|
+
const validation = await validateUnifiedDiff(repoRoot, diff);
|
|
799
|
+
if (!validation.ok) {
|
|
800
|
+
const actionHint = validation.kind === "malformed_patch" ? "The provided external diff is malformed. Recreate it with `git diff --binary --no-ext-diff`, avoid hand-editing patch hunks, and ensure the patch ends with a trailing newline." : validation.kind === "apply_conflict" ? "The external diff is valid patch syntax, but it does not apply cleanly to the current local HEAD. Sync or update the repo and regenerate the diff against the latest base." : "Git could not validate the provided external diff against the current repository state.";
|
|
801
|
+
const hint = [validation.detail, actionHint].filter(Boolean).join("\n\n");
|
|
802
|
+
throw new RemixError("External diff validation failed.", {
|
|
803
|
+
exitCode: validation.kind === "malformed_patch" ? 2 : 1,
|
|
804
|
+
hint
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
headCommitHash = await getHeadCommitHash(repoRoot);
|
|
809
|
+
if (!headCommitHash) {
|
|
810
|
+
throw new RemixError("Failed to resolve local HEAD before creating the change step.", { exitCode: 1 });
|
|
811
|
+
}
|
|
812
|
+
const stats = summarizeUnifiedDiff(diff);
|
|
813
|
+
const idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
|
|
814
|
+
appId: binding.currentAppId,
|
|
815
|
+
upstreamAppId: binding.upstreamAppId,
|
|
816
|
+
headCommitHash,
|
|
817
|
+
prompt,
|
|
818
|
+
assistantResponse,
|
|
819
|
+
diff
|
|
820
|
+
});
|
|
821
|
+
const resp = await params.api.createChangeStep(binding.currentAppId, {
|
|
822
|
+
threadId: binding.threadId ?? void 0,
|
|
823
|
+
prompt,
|
|
824
|
+
assistantResponse: assistantResponse ?? void 0,
|
|
825
|
+
diff,
|
|
826
|
+
baseCommitHash: headCommitHash,
|
|
827
|
+
headCommitHash,
|
|
828
|
+
changedFilesCount: stats.changedFilesCount,
|
|
829
|
+
insertions: stats.insertions,
|
|
830
|
+
deletions: stats.deletions,
|
|
831
|
+
actor: params.actor,
|
|
832
|
+
workspaceMetadata: {
|
|
833
|
+
branch,
|
|
834
|
+
repoRoot,
|
|
835
|
+
remoteUrl: binding.remoteUrl,
|
|
836
|
+
defaultBranch: binding.defaultBranch
|
|
837
|
+
},
|
|
838
|
+
idempotencyKey
|
|
839
|
+
});
|
|
840
|
+
const created = unwrapResponseObject(resp, "change step");
|
|
841
|
+
const step = await pollChangeStep(params.api, binding.currentAppId, String(created.id));
|
|
842
|
+
const canAutoSyncLocally = autoSyncEnabled && diffSource === "worktree";
|
|
843
|
+
if (!autoSyncEnabled || !canAutoSyncLocally) {
|
|
844
|
+
return attachWarnings(step, lockWarnings);
|
|
845
|
+
}
|
|
846
|
+
const { backupPath } = await writeTempUnifiedDiffBackup(diff, "remix-add");
|
|
847
|
+
try {
|
|
848
|
+
if (submissionSnapshot) {
|
|
849
|
+
await assertRepoSnapshotUnchanged(repoRoot, submissionSnapshot, {
|
|
850
|
+
operation: "`remix collab add` auto-sync",
|
|
851
|
+
recoveryHint: "The repository changed after the change step was submitted. Review the local changes, inspect the preserved diff if needed, and rerun `remix collab sync` manually."
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
await discardTrackedChanges(repoRoot, "`remix collab add`");
|
|
855
|
+
await discardCapturedUntrackedChanges(repoRoot, workspaceSnapshot?.includedUntrackedPaths ?? []);
|
|
856
|
+
await collabSync({
|
|
857
|
+
api: params.api,
|
|
858
|
+
cwd: repoRoot,
|
|
859
|
+
dryRun: false,
|
|
860
|
+
allowBranchMismatch: params.allowBranchMismatch
|
|
861
|
+
});
|
|
862
|
+
await fs3.rm(path3.dirname(backupPath), { recursive: true, force: true }).catch(() => void 0);
|
|
863
|
+
} catch (err) {
|
|
864
|
+
const detail = formatCliErrorDetail(err);
|
|
865
|
+
const hint = [
|
|
866
|
+
detail,
|
|
867
|
+
`The submitted diff backup was preserved at: ${backupPath}`,
|
|
868
|
+
"The change step already succeeded remotely. Inspect or reapply that diff manually if needed, then run `remix collab sync`."
|
|
869
|
+
].filter(Boolean).join("\n\n");
|
|
870
|
+
throw new RemixError("Change step succeeded remotely, but automatic local sync failed.", {
|
|
871
|
+
exitCode: err instanceof RemixError ? err.exitCode : 1,
|
|
872
|
+
hint
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return attachWarnings(step, lockWarnings);
|
|
876
|
+
};
|
|
877
|
+
if (diffSource === "worktree") {
|
|
878
|
+
return withRepoMutationLock(
|
|
879
|
+
{
|
|
880
|
+
cwd: repoRoot,
|
|
881
|
+
operation: "collabAdd"
|
|
882
|
+
},
|
|
883
|
+
async ({ warnings }) => run(warnings)
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
return run();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/application/collab/collabRecordTurn.ts
|
|
890
|
+
async function collabRecordTurn(params) {
|
|
891
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
892
|
+
const binding = await readCollabBinding(repoRoot);
|
|
893
|
+
if (!binding) {
|
|
894
|
+
throw new RemixError("Repository is not bound to Remix.", {
|
|
895
|
+
exitCode: 2,
|
|
896
|
+
hint: "Run `remix collab init` first."
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
const prompt = params.prompt.trim();
|
|
900
|
+
const assistantResponse = params.assistantResponse.trim();
|
|
901
|
+
if (!prompt) throw new RemixError("Prompt is required.", { exitCode: 2 });
|
|
902
|
+
if (!assistantResponse) throw new RemixError("Assistant response is required.", { exitCode: 2 });
|
|
903
|
+
const branch = await getCurrentBranch(repoRoot);
|
|
904
|
+
assertPreferredBranchMatch({
|
|
905
|
+
currentBranch: branch,
|
|
906
|
+
preferredBranch: binding.preferredBranch,
|
|
907
|
+
allowBranchMismatch: params.allowBranchMismatch,
|
|
908
|
+
operation: "`remix collab record-turn`"
|
|
909
|
+
});
|
|
910
|
+
const headCommitHash = await getHeadCommitHash(repoRoot);
|
|
911
|
+
const idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
|
|
912
|
+
appId: binding.currentAppId,
|
|
913
|
+
upstreamAppId: binding.upstreamAppId,
|
|
914
|
+
headCommitHash,
|
|
915
|
+
prompt,
|
|
916
|
+
assistantResponse
|
|
917
|
+
});
|
|
918
|
+
const resp = await params.api.createCollabTurn(binding.currentAppId, {
|
|
919
|
+
threadId: binding.threadId ?? void 0,
|
|
920
|
+
prompt,
|
|
921
|
+
assistantResponse,
|
|
922
|
+
actor: params.actor,
|
|
923
|
+
workspaceMetadata: {
|
|
924
|
+
branch,
|
|
925
|
+
repoRoot,
|
|
926
|
+
remoteUrl: binding.remoteUrl,
|
|
927
|
+
defaultBranch: binding.defaultBranch,
|
|
928
|
+
headCommitHash
|
|
929
|
+
},
|
|
930
|
+
idempotencyKey
|
|
931
|
+
});
|
|
932
|
+
return unwrapResponseObject(resp, "collab turn");
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/application/collab/collabApprove.ts
|
|
936
|
+
async function collabApprove(params) {
|
|
937
|
+
if (params.mode === "sync-target-repo") {
|
|
938
|
+
if (!params.cwd?.trim()) {
|
|
939
|
+
throw new RemixError("Working directory is required for `--sync-target-repo`.", {
|
|
940
|
+
exitCode: 2
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
return withRepoMutationLock(
|
|
944
|
+
{
|
|
945
|
+
cwd: params.cwd,
|
|
946
|
+
operation: "collabApproveSyncTarget"
|
|
947
|
+
},
|
|
948
|
+
async ({ repoRoot, warnings }) => {
|
|
949
|
+
const binding = await readCollabBinding(repoRoot);
|
|
950
|
+
if (!binding) {
|
|
951
|
+
throw new RemixError("Repository is not bound to Remix.", {
|
|
952
|
+
exitCode: 2,
|
|
953
|
+
hint: "Run `remix collab init` first, or use `--remote-only`."
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
await ensureCleanWorktree(repoRoot, "`remix collab approve --sync-target-repo`");
|
|
957
|
+
const branch = await requireCurrentBranch(repoRoot);
|
|
958
|
+
assertPreferredBranchMatch({
|
|
959
|
+
currentBranch: branch,
|
|
960
|
+
preferredBranch: binding.preferredBranch,
|
|
961
|
+
allowBranchMismatch: params.allowBranchMismatch,
|
|
962
|
+
operation: "`remix collab approve --sync-target-repo`"
|
|
963
|
+
});
|
|
964
|
+
const mrResp = await params.api.getMergeRequest(params.mrId);
|
|
965
|
+
const mergeRequest = unwrapMergeRequest(mrResp);
|
|
966
|
+
const targetAppId = mergeRequest.targetAppId;
|
|
967
|
+
if (binding.currentAppId !== targetAppId) {
|
|
968
|
+
throw new RemixError("Current repository is not the merge target for this merge request.", {
|
|
969
|
+
exitCode: 2,
|
|
970
|
+
hint: `Run the command from the repository bound to app ${targetAppId}, or use --remote-only.`
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
const resp2 = await params.api.updateMergeRequest(params.mrId, { status: "approved" });
|
|
974
|
+
const approvedMergeRequest2 = unwrapMergeRequest(resp2);
|
|
975
|
+
const completedMergeRequest2 = await pollMergeRequestCompletion(params.api, params.mrId, {
|
|
976
|
+
targetAppId: targetAppId ?? approvedMergeRequest2.targetAppId
|
|
977
|
+
});
|
|
978
|
+
const localSync = await collabSync({
|
|
979
|
+
api: params.api,
|
|
980
|
+
cwd: repoRoot,
|
|
981
|
+
dryRun: false,
|
|
982
|
+
allowBranchMismatch: params.allowBranchMismatch
|
|
983
|
+
});
|
|
984
|
+
const localSyncWarnings = Array.isArray(localSync.warnings) ? localSync.warnings ?? [] : [];
|
|
985
|
+
return {
|
|
986
|
+
mode: params.mode,
|
|
987
|
+
mergeRequestId: completedMergeRequest2.id,
|
|
988
|
+
terminalStatus: completedMergeRequest2.status,
|
|
989
|
+
targetAppId: completedMergeRequest2.targetAppId,
|
|
990
|
+
mergeRequest: completedMergeRequest2,
|
|
991
|
+
repoRoot,
|
|
992
|
+
localSync,
|
|
993
|
+
...warnings.length > 0 || localSyncWarnings.length > 0 ? {
|
|
994
|
+
warnings: Array.from(/* @__PURE__ */ new Set([...warnings, ...localSyncWarnings]))
|
|
995
|
+
} : {}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
const resp = await params.api.updateMergeRequest(params.mrId, { status: "approved" });
|
|
1001
|
+
const approvedMergeRequest = unwrapMergeRequest(resp);
|
|
1002
|
+
const completedMergeRequest = await pollMergeRequestCompletion(params.api, params.mrId, {
|
|
1003
|
+
targetAppId: approvedMergeRequest.targetAppId
|
|
1004
|
+
});
|
|
1005
|
+
return {
|
|
1006
|
+
mode: params.mode,
|
|
1007
|
+
mergeRequestId: completedMergeRequest.id,
|
|
1008
|
+
terminalStatus: completedMergeRequest.status,
|
|
1009
|
+
targetAppId: completedMergeRequest.targetAppId,
|
|
1010
|
+
mergeRequest: completedMergeRequest
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// src/application/collab/collabInbox.ts
|
|
1015
|
+
async function collabInbox(params) {
|
|
1016
|
+
const resp = await params.api.listMergeRequests({ status: "open", kind: "merge" });
|
|
1017
|
+
const mergeRequests = unwrapResponseObject(resp, "merge requests");
|
|
1018
|
+
return { mergeRequests };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/application/collab/collabInit.ts
|
|
1022
|
+
import fs6 from "fs/promises";
|
|
1023
|
+
import path4 from "path";
|
|
1024
|
+
|
|
1025
|
+
// src/shared/hash.ts
|
|
1026
|
+
import crypto from "crypto";
|
|
1027
|
+
import fs4 from "fs";
|
|
1028
|
+
async function sha256FileHex(filePath) {
|
|
1029
|
+
const hash = crypto.createHash("sha256");
|
|
1030
|
+
await new Promise((resolve, reject) => {
|
|
1031
|
+
const stream = fs4.createReadStream(filePath);
|
|
1032
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
1033
|
+
stream.on("error", reject);
|
|
1034
|
+
stream.on("end", () => resolve());
|
|
1035
|
+
});
|
|
1036
|
+
return hash.digest("hex");
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/shared/upload.ts
|
|
1040
|
+
import fs5 from "fs";
|
|
1041
|
+
import { PassThrough } from "stream";
|
|
1042
|
+
async function uploadPresigned(params) {
|
|
1043
|
+
const stats = await fs5.promises.stat(params.filePath).catch(() => null);
|
|
1044
|
+
if (!stats || !stats.isFile()) {
|
|
1045
|
+
throw new RemixError("Upload file not found.", { exitCode: 2 });
|
|
1046
|
+
}
|
|
1047
|
+
const totalBytes = stats.size;
|
|
1048
|
+
const fileStream = fs5.createReadStream(params.filePath);
|
|
1049
|
+
const pass = new PassThrough();
|
|
1050
|
+
let sentBytes = 0;
|
|
1051
|
+
fileStream.on("data", (chunk) => {
|
|
1052
|
+
sentBytes += chunk.length;
|
|
1053
|
+
params.onProgress?.({ sentBytes, totalBytes });
|
|
1054
|
+
});
|
|
1055
|
+
fileStream.on("error", (err) => pass.destroy(err));
|
|
1056
|
+
fileStream.pipe(pass);
|
|
1057
|
+
const response = await fetch(params.uploadUrl, {
|
|
1058
|
+
method: "PUT",
|
|
1059
|
+
headers: params.headers,
|
|
1060
|
+
body: pass,
|
|
1061
|
+
duplex: "half"
|
|
1062
|
+
});
|
|
1063
|
+
if (!response.ok) {
|
|
1064
|
+
const text = await response.text().catch(() => "");
|
|
1065
|
+
throw new RemixError("Upload failed.", {
|
|
1066
|
+
exitCode: 1,
|
|
1067
|
+
hint: `Status: ${response.status}
|
|
1068
|
+
${text}`.trim() || null
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// src/application/collab/collabInit.ts
|
|
1074
|
+
async function collabInit(params) {
|
|
1075
|
+
return withRepoMutationLock(
|
|
1076
|
+
{
|
|
1077
|
+
cwd: params.cwd,
|
|
1078
|
+
operation: "collabInit"
|
|
1079
|
+
},
|
|
1080
|
+
async ({ repoRoot, warnings }) => {
|
|
1081
|
+
await ensureGitInfoExcludeEntries(repoRoot, [".remix/"]);
|
|
1082
|
+
await ensureCleanWorktree(repoRoot, "`remix collab init`");
|
|
1083
|
+
if (params.path?.trim()) {
|
|
1084
|
+
throw new RemixError("`remix collab init --path` is not supported.", {
|
|
1085
|
+
exitCode: 2,
|
|
1086
|
+
hint: "History-preserving init imports the full git repository. Run the command from the repository root without --path."
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
const remoteUrl = normalizeGitRemote(await getRemoteOriginUrl(repoRoot));
|
|
1090
|
+
const currentBranch = await getCurrentBranch(repoRoot);
|
|
1091
|
+
const defaultBranch = await getDefaultBranch(repoRoot) ?? currentBranch;
|
|
1092
|
+
const preferredBranch = currentBranch ?? defaultBranch ?? null;
|
|
1093
|
+
const repoFingerprint = await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch });
|
|
1094
|
+
const repoSnapshot = await captureRepoSnapshot(repoRoot);
|
|
1095
|
+
if (!params.forceNew) {
|
|
1096
|
+
const bindingResp = await params.api.resolveProjectBinding({
|
|
1097
|
+
repoFingerprint,
|
|
1098
|
+
remoteUrl: remoteUrl ?? void 0
|
|
1099
|
+
});
|
|
1100
|
+
const existing = bindingResp?.responseObject;
|
|
1101
|
+
if (existing?.projectId && existing?.appId) {
|
|
1102
|
+
await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
|
|
1103
|
+
operation: "`remix collab init`",
|
|
1104
|
+
recoveryHint: "The repository changed while the local binding was being initialized. Review the local changes and rerun `remix collab init`."
|
|
1105
|
+
});
|
|
1106
|
+
const bindingPath2 = await writeCollabBinding(repoRoot, {
|
|
1107
|
+
projectId: String(existing.projectId),
|
|
1108
|
+
currentAppId: String(existing.appId),
|
|
1109
|
+
upstreamAppId: String(existing.upstreamAppId ?? existing.appId),
|
|
1110
|
+
threadId: existing.threadId ? String(existing.threadId) : null,
|
|
1111
|
+
repoFingerprint,
|
|
1112
|
+
remoteUrl,
|
|
1113
|
+
defaultBranch: defaultBranch ?? null,
|
|
1114
|
+
preferredBranch
|
|
1115
|
+
});
|
|
1116
|
+
return {
|
|
1117
|
+
reused: true,
|
|
1118
|
+
projectId: String(existing.projectId),
|
|
1119
|
+
appId: String(existing.appId),
|
|
1120
|
+
upstreamAppId: String(existing.upstreamAppId ?? existing.appId),
|
|
1121
|
+
bindingPath: bindingPath2,
|
|
1122
|
+
repoRoot,
|
|
1123
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const { bundlePath, headCommitHash } = await createGitBundle(repoRoot, "repository.bundle");
|
|
1128
|
+
const bundleSha = await sha256FileHex(bundlePath);
|
|
1129
|
+
const bundleSize = (await fs6.stat(bundlePath)).size;
|
|
1130
|
+
const presignResp = await params.api.presignImportUploadFirstParty({
|
|
1131
|
+
file: {
|
|
1132
|
+
name: "repository.bundle",
|
|
1133
|
+
mimeType: "application/x-git-bundle",
|
|
1134
|
+
size: bundleSize,
|
|
1135
|
+
checksumSha256: bundleSha
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
const presign = unwrapResponseObject(presignResp, "upload");
|
|
1139
|
+
await uploadPresigned({
|
|
1140
|
+
uploadUrl: String(presign.uploadUrl),
|
|
1141
|
+
headers: presign.headers ?? {},
|
|
1142
|
+
filePath: bundlePath
|
|
1143
|
+
});
|
|
1144
|
+
const importResp = await params.api.importFromUploadFirstParty({
|
|
1145
|
+
uploadId: String(presign.uploadId),
|
|
1146
|
+
appName: params.appName?.trim() || path4.basename(repoRoot),
|
|
1147
|
+
path: params.path?.trim() || void 0,
|
|
1148
|
+
platform: "generic",
|
|
1149
|
+
isPublic: false,
|
|
1150
|
+
remoteUrl: remoteUrl ?? void 0,
|
|
1151
|
+
defaultBranch: defaultBranch ?? void 0,
|
|
1152
|
+
repoFingerprint,
|
|
1153
|
+
headCommitHash
|
|
1154
|
+
});
|
|
1155
|
+
const imported = unwrapResponseObject(importResp, "import");
|
|
1156
|
+
const app = await pollAppReady(params.api, String(imported.appId));
|
|
1157
|
+
await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
|
|
1158
|
+
operation: "`remix collab init`",
|
|
1159
|
+
recoveryHint: "The repository changed before the Remix binding was written. Review the local changes and rerun `remix collab init`."
|
|
1160
|
+
});
|
|
1161
|
+
const bindingPath = await writeCollabBinding(repoRoot, {
|
|
1162
|
+
projectId: String(app.projectId),
|
|
1163
|
+
currentAppId: String(app.id),
|
|
1164
|
+
upstreamAppId: String(app.id),
|
|
1165
|
+
threadId: app.threadId ? String(app.threadId) : null,
|
|
1166
|
+
repoFingerprint,
|
|
1167
|
+
remoteUrl,
|
|
1168
|
+
defaultBranch: defaultBranch ?? null,
|
|
1169
|
+
preferredBranch
|
|
1170
|
+
});
|
|
1171
|
+
return {
|
|
1172
|
+
reused: false,
|
|
1173
|
+
projectId: String(app.projectId),
|
|
1174
|
+
appId: String(app.id),
|
|
1175
|
+
upstreamAppId: String(app.id),
|
|
1176
|
+
bindingPath,
|
|
1177
|
+
repoRoot,
|
|
1178
|
+
remoteUrl,
|
|
1179
|
+
defaultBranch,
|
|
1180
|
+
preferredBranch,
|
|
1181
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// src/application/collab/collabInvite.ts
|
|
1188
|
+
async function resolveScopeTarget(params) {
|
|
1189
|
+
if (params.targetId?.trim()) return params.targetId.trim();
|
|
1190
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
1191
|
+
const binding = await readCollabBinding(repoRoot);
|
|
1192
|
+
if (!binding) {
|
|
1193
|
+
throw new RemixError("Repository is not bound to Remix and no explicit target id was provided.", { exitCode: 2 });
|
|
1194
|
+
}
|
|
1195
|
+
if (params.scope === "project") return binding.projectId;
|
|
1196
|
+
if (params.scope === "app") return binding.currentAppId;
|
|
1197
|
+
const project = unwrapResponseObject(await params.api.getProject(binding.projectId), "project");
|
|
1198
|
+
const organizationId = typeof project.organizationId === "string" ? project.organizationId : null;
|
|
1199
|
+
if (!organizationId) {
|
|
1200
|
+
throw new RemixError("Could not resolve the organization for the current repository binding.", { exitCode: 2 });
|
|
1201
|
+
}
|
|
1202
|
+
return organizationId;
|
|
1203
|
+
}
|
|
1204
|
+
async function collabInvite(params) {
|
|
1205
|
+
const scope = params.scope ?? "project";
|
|
1206
|
+
const targetId = await resolveScopeTarget({
|
|
1207
|
+
api: params.api,
|
|
1208
|
+
cwd: params.cwd,
|
|
1209
|
+
scope,
|
|
1210
|
+
targetId: params.targetId
|
|
1211
|
+
});
|
|
1212
|
+
const payload = {
|
|
1213
|
+
email: params.email,
|
|
1214
|
+
role: params.role?.trim() || (scope === "organization" ? "member" : "viewer"),
|
|
1215
|
+
ttlDays: params.ttlDays
|
|
1216
|
+
};
|
|
1217
|
+
const resp = scope === "organization" ? await params.api.createOrganizationInvite(targetId, payload) : scope === "project" ? await params.api.createProjectInvite(targetId, payload) : await params.api.createAppInvite(targetId, payload);
|
|
1218
|
+
const invite = unwrapResponseObject(resp, "invite");
|
|
1219
|
+
return {
|
|
1220
|
+
...invite,
|
|
1221
|
+
scopeType: scope,
|
|
1222
|
+
targetId
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// src/application/collab/collabList.ts
|
|
1227
|
+
async function collabList(params) {
|
|
1228
|
+
const resp = await params.api.listApps({ forked: "all" });
|
|
1229
|
+
const apps = unwrapResponseObject(resp, "apps");
|
|
1230
|
+
return { apps };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// src/application/collab/collabReconcile.ts
|
|
1234
|
+
import fs7 from "fs/promises";
|
|
1235
|
+
import os3 from "os";
|
|
1236
|
+
import path5 from "path";
|
|
1237
|
+
async function collabReconcile(params) {
|
|
1238
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
1239
|
+
const binding = await readCollabBinding(repoRoot);
|
|
1240
|
+
if (!binding) {
|
|
1241
|
+
throw new RemixError("Repository is not bound to Remix.", {
|
|
1242
|
+
exitCode: 2,
|
|
1243
|
+
hint: "Run `remix collab init` first."
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
await ensureCleanWorktree(repoRoot, "`remix collab reconcile`");
|
|
1247
|
+
const branch = await requireCurrentBranch(repoRoot);
|
|
1248
|
+
assertPreferredBranchMatch({
|
|
1249
|
+
currentBranch: branch,
|
|
1250
|
+
preferredBranch: binding.preferredBranch,
|
|
1251
|
+
allowBranchMismatch: params.allowBranchMismatch,
|
|
1252
|
+
operation: "`remix collab reconcile`"
|
|
1253
|
+
});
|
|
1254
|
+
const headCommitHash = await getHeadCommitHash(repoRoot);
|
|
1255
|
+
const repoSnapshot = await captureRepoSnapshot(repoRoot);
|
|
1256
|
+
if (!headCommitHash) {
|
|
1257
|
+
throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
|
|
1258
|
+
}
|
|
1259
|
+
const syncResp = await params.api.syncLocalApp(binding.currentAppId, {
|
|
1260
|
+
baseCommitHash: headCommitHash,
|
|
1261
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
1262
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
1263
|
+
defaultBranch: binding.defaultBranch ?? void 0,
|
|
1264
|
+
dryRun: true
|
|
1265
|
+
});
|
|
1266
|
+
const sync = unwrapResponseObject(syncResp, "sync result");
|
|
1267
|
+
if (sync.status === "conflict_risk") {
|
|
1268
|
+
throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
|
|
1269
|
+
exitCode: 2,
|
|
1270
|
+
hint: sync.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
if (sync.status !== "base_unknown") {
|
|
1274
|
+
return collabSync(params);
|
|
1275
|
+
}
|
|
1276
|
+
const preflightResp = await params.api.preflightAppReconcile(binding.currentAppId, {
|
|
1277
|
+
localHeadCommitHash: headCommitHash,
|
|
1278
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
1279
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
1280
|
+
defaultBranch: binding.defaultBranch ?? void 0
|
|
1281
|
+
});
|
|
1282
|
+
const preflight = unwrapResponseObject(preflightResp, "reconcile preflight");
|
|
1283
|
+
if (preflight.status === "metadata_conflict") {
|
|
1284
|
+
throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
|
|
1285
|
+
exitCode: 2,
|
|
1286
|
+
hint: preflight.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
if (preflight.status === "up_to_date") {
|
|
1290
|
+
return collabSync(params);
|
|
1291
|
+
}
|
|
1292
|
+
const previewResult = {
|
|
1293
|
+
status: preflight.status,
|
|
1294
|
+
branch,
|
|
1295
|
+
repoRoot,
|
|
1296
|
+
localHeadCommitHash: headCommitHash,
|
|
1297
|
+
targetHeadCommitId: preflight.targetHeadCommitId,
|
|
1298
|
+
targetHeadCommitHash: preflight.targetHeadCommitHash,
|
|
1299
|
+
warnings: preflight.warnings,
|
|
1300
|
+
applied: false,
|
|
1301
|
+
dryRun: params.dryRun
|
|
1302
|
+
};
|
|
1303
|
+
if (params.dryRun) {
|
|
1304
|
+
return previewResult;
|
|
1305
|
+
}
|
|
1306
|
+
const { bundlePath, headCommitHash: bundledHeadCommitHash } = await createGitBundle(repoRoot, "reconcile-local.bundle");
|
|
1307
|
+
const bundleTempDir = path5.dirname(bundlePath);
|
|
1308
|
+
try {
|
|
1309
|
+
const bundleStat = await fs7.stat(bundlePath);
|
|
1310
|
+
const checksumSha256 = await sha256FileHex(bundlePath);
|
|
1311
|
+
const presignResp = await params.api.presignImportUploadFirstParty({
|
|
1312
|
+
file: {
|
|
1313
|
+
name: path5.basename(bundlePath),
|
|
1314
|
+
mimeType: "application/x-git-bundle",
|
|
1315
|
+
size: bundleStat.size,
|
|
1316
|
+
checksumSha256
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
const uploadTarget = unwrapResponseObject(presignResp, "reconcile upload target");
|
|
1320
|
+
await uploadPresigned({
|
|
1321
|
+
uploadUrl: String(uploadTarget.uploadUrl),
|
|
1322
|
+
filePath: bundlePath,
|
|
1323
|
+
headers: uploadTarget.headers ?? {}
|
|
1324
|
+
});
|
|
1325
|
+
const startResp = await params.api.startAppReconcile(binding.currentAppId, {
|
|
1326
|
+
uploadId: String(uploadTarget.uploadId),
|
|
1327
|
+
localHeadCommitHash: bundledHeadCommitHash,
|
|
1328
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
1329
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
1330
|
+
defaultBranch: binding.defaultBranch ?? void 0,
|
|
1331
|
+
idempotencyKey: buildDeterministicIdempotencyKey({
|
|
1332
|
+
appId: binding.currentAppId,
|
|
1333
|
+
localHeadCommitHash: bundledHeadCommitHash,
|
|
1334
|
+
targetHeadCommitHash: preflight.targetHeadCommitHash
|
|
1335
|
+
})
|
|
1336
|
+
});
|
|
1337
|
+
const started = unwrapResponseObject(startResp, "reconcile");
|
|
1338
|
+
const reconcile = await pollReconcile(params.api, binding.currentAppId, started.id);
|
|
1339
|
+
if (!reconcile.mergeBaseCommitHash || !reconcile.reconciledHeadCommitHash || !reconcile.resultBundleRef) {
|
|
1340
|
+
throw new RemixError("Reconcile completed without enough result metadata to update the local repository.", {
|
|
1341
|
+
exitCode: 1
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
const mergeBaseCommitHash = reconcile.mergeBaseCommitHash;
|
|
1345
|
+
const reconciledHeadCommitHash = reconcile.reconciledHeadCommitHash;
|
|
1346
|
+
const resultBundleRef = reconcile.resultBundleRef;
|
|
1347
|
+
return withRepoMutationLock(
|
|
1348
|
+
{
|
|
1349
|
+
cwd: repoRoot,
|
|
1350
|
+
operation: "collabReconcile"
|
|
1351
|
+
},
|
|
1352
|
+
async ({ repoRoot: lockedRepoRoot, warnings }) => {
|
|
1353
|
+
await assertRepoSnapshotUnchanged(lockedRepoRoot, repoSnapshot, {
|
|
1354
|
+
operation: "`remix collab reconcile`",
|
|
1355
|
+
recoveryHint: "The repository changed after reconcile was prepared. Review the local changes and rerun `remix collab reconcile`."
|
|
1356
|
+
});
|
|
1357
|
+
await ensureCleanWorktree(lockedRepoRoot, "`remix collab reconcile`");
|
|
1358
|
+
const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
|
|
1359
|
+
assertPreferredBranchMatch({
|
|
1360
|
+
currentBranch: lockedBranch,
|
|
1361
|
+
preferredBranch: binding.preferredBranch,
|
|
1362
|
+
allowBranchMismatch: params.allowBranchMismatch,
|
|
1363
|
+
operation: "`remix collab reconcile`"
|
|
1364
|
+
});
|
|
1365
|
+
const backup = await createBackupBranch(lockedRepoRoot, {
|
|
1366
|
+
branchName: branch,
|
|
1367
|
+
sourceCommitHash: headCommitHash,
|
|
1368
|
+
prefix: "remix/reconcile-backup"
|
|
1369
|
+
});
|
|
1370
|
+
await hardResetToCommit(lockedRepoRoot, mergeBaseCommitHash, "`remix collab reconcile`");
|
|
1371
|
+
const bundleResp = await params.api.downloadAppReconcileBundle(binding.currentAppId, reconcile.id);
|
|
1372
|
+
const resultTempDir = await fs7.mkdtemp(path5.join(os3.tmpdir(), "remix-reconcile-"));
|
|
1373
|
+
const resultBundlePath = path5.join(resultTempDir, bundleResp.fileName ?? "reconcile-result.bundle");
|
|
1374
|
+
try {
|
|
1375
|
+
await fs7.writeFile(resultBundlePath, bundleResp.data);
|
|
1376
|
+
await importGitBundle(lockedRepoRoot, resultBundlePath, resultBundleRef);
|
|
1377
|
+
await ensureCommitExists(lockedRepoRoot, reconciledHeadCommitHash);
|
|
1378
|
+
const localCommitHash = await fastForwardToCommit(lockedRepoRoot, reconciledHeadCommitHash);
|
|
1379
|
+
if (localCommitHash !== reconciledHeadCommitHash) {
|
|
1380
|
+
throw new RemixError("Local reconcile completed but final HEAD does not match the server result.", { exitCode: 1 });
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
...previewResult,
|
|
1384
|
+
status: reconcile.status,
|
|
1385
|
+
reconcileId: reconcile.id,
|
|
1386
|
+
mergeBaseCommitHash: reconcile.mergeBaseCommitHash,
|
|
1387
|
+
reconciledHeadCommitId: reconcile.reconciledHeadCommitId,
|
|
1388
|
+
reconciledHeadCommitHash: reconcile.reconciledHeadCommitHash,
|
|
1389
|
+
backupBranchName: backup.branchName,
|
|
1390
|
+
localCommitHash,
|
|
1391
|
+
applied: true,
|
|
1392
|
+
dryRun: false,
|
|
1393
|
+
warnings: Array.from(/* @__PURE__ */ new Set([...previewResult.warnings, ...warnings]))
|
|
1394
|
+
};
|
|
1395
|
+
} finally {
|
|
1396
|
+
await fs7.rm(resultTempDir, { recursive: true, force: true });
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
);
|
|
1400
|
+
} finally {
|
|
1401
|
+
await fs7.rm(bundleTempDir, { recursive: true, force: true });
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// src/application/collab/collabReject.ts
|
|
1406
|
+
async function collabReject(params) {
|
|
1407
|
+
const resp = await params.api.updateMergeRequest(params.mrId, { status: "rejected" });
|
|
1408
|
+
return unwrapResponseObject(resp, "merge request");
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// src/application/collab/collabRemix.ts
|
|
1412
|
+
import fs8 from "fs/promises";
|
|
1413
|
+
import os4 from "os";
|
|
1414
|
+
import path6 from "path";
|
|
1415
|
+
async function pathExists(targetPath) {
|
|
1416
|
+
try {
|
|
1417
|
+
await fs8.access(targetPath);
|
|
1418
|
+
return true;
|
|
1419
|
+
} catch {
|
|
1420
|
+
return false;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
async function statIsDirectory(targetPath) {
|
|
1424
|
+
const stats = await fs8.stat(targetPath).catch(() => null);
|
|
1425
|
+
return Boolean(stats?.isDirectory());
|
|
1426
|
+
}
|
|
1427
|
+
async function findContainingGitRoot(startPath) {
|
|
1428
|
+
let current = path6.resolve(startPath);
|
|
1429
|
+
while (true) {
|
|
1430
|
+
if (await pathExists(path6.join(current, ".git"))) return current;
|
|
1431
|
+
const parent = path6.dirname(current);
|
|
1432
|
+
if (parent === current) return null;
|
|
1433
|
+
current = parent;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
function isSubpath(parentPath, candidatePath) {
|
|
1437
|
+
const relative = path6.relative(parentPath, candidatePath);
|
|
1438
|
+
return relative === "" || !relative.startsWith("..") && !path6.isAbsolute(relative);
|
|
1439
|
+
}
|
|
1440
|
+
function buildPreferredRemixBranch(appId) {
|
|
1441
|
+
const normalized = appId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1442
|
+
return `remix/remix/${normalized || "app"}`;
|
|
1443
|
+
}
|
|
1444
|
+
async function resolveRemixDestination(params) {
|
|
1445
|
+
if (params.outputDir?.trim()) {
|
|
1446
|
+
const preferredRepoRoot = path6.resolve(params.outputDir.trim());
|
|
1447
|
+
const parentDir2 = path6.dirname(preferredRepoRoot);
|
|
1448
|
+
if (!await statIsDirectory(parentDir2)) {
|
|
1449
|
+
throw new RemixError("Remix output parent directory does not exist.", {
|
|
1450
|
+
exitCode: 2,
|
|
1451
|
+
hint: `Create the directory first: ${parentDir2}`
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
return {
|
|
1455
|
+
preferredRepoRoot,
|
|
1456
|
+
parentDir: parentDir2,
|
|
1457
|
+
explicitOutputDir: true
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
const parentDir = path6.resolve(params.cwd);
|
|
1461
|
+
if (!await statIsDirectory(parentDir)) {
|
|
1462
|
+
throw new RemixError("Remix output parent directory does not exist.", {
|
|
1463
|
+
exitCode: 2,
|
|
1464
|
+
hint: `Create the directory first: ${parentDir}`
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
return {
|
|
1468
|
+
preferredRepoRoot: path6.join(parentDir, params.defaultDirName),
|
|
1469
|
+
parentDir,
|
|
1470
|
+
explicitOutputDir: false
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
async function assertSafeRemixDestination(params) {
|
|
1474
|
+
const callerGitRoot = await findContainingGitRoot(params.cwd);
|
|
1475
|
+
if (callerGitRoot && isSubpath(callerGitRoot, params.repoRoot)) {
|
|
1476
|
+
throw new RemixError("Refusing to create a remix checkout inside an existing git repository.", {
|
|
1477
|
+
exitCode: 2,
|
|
1478
|
+
hint: params.explicitOutputDir ? `Choose a destination outside ${callerGitRoot}.` : `Pass --output-dir outside ${callerGitRoot}.`
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
const parentGitRoot = await findContainingGitRoot(params.parentDir);
|
|
1482
|
+
if (parentGitRoot && isSubpath(parentGitRoot, params.repoRoot)) {
|
|
1483
|
+
throw new RemixError("Refusing to create a remix checkout inside an existing git repository.", {
|
|
1484
|
+
exitCode: 2,
|
|
1485
|
+
hint: params.explicitOutputDir ? `Choose a destination outside ${parentGitRoot}.` : `Pass --output-dir outside ${parentGitRoot}.`
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
async function collabRemix(params) {
|
|
1490
|
+
const sourceAppId = params.appId?.trim() || null;
|
|
1491
|
+
if (!sourceAppId) {
|
|
1492
|
+
throw new RemixError("No source app selected.", {
|
|
1493
|
+
exitCode: 2,
|
|
1494
|
+
hint: "Pass the source app id to remix."
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
const forkResp = await params.api.forkApp(sourceAppId, { name: params.name?.trim() || void 0, platform: "generic" });
|
|
1498
|
+
const forked = unwrapResponseObject(forkResp, "fork");
|
|
1499
|
+
const app = await pollAppReady(params.api, String(forked.id));
|
|
1500
|
+
const baseDirName = sanitizeCheckoutDirName(String(params.name?.trim() || app.name || app.id));
|
|
1501
|
+
const destination = await resolveRemixDestination({
|
|
1502
|
+
cwd: params.cwd,
|
|
1503
|
+
outputDir: params.outputDir ?? null,
|
|
1504
|
+
defaultDirName: baseDirName
|
|
1505
|
+
});
|
|
1506
|
+
await assertSafeRemixDestination({
|
|
1507
|
+
cwd: params.cwd,
|
|
1508
|
+
repoRoot: destination.preferredRepoRoot,
|
|
1509
|
+
parentDir: destination.parentDir,
|
|
1510
|
+
explicitOutputDir: destination.explicitOutputDir
|
|
1511
|
+
});
|
|
1512
|
+
const repoRoot = destination.explicitOutputDir ? await reserveDirectory(destination.preferredRepoRoot) : await reserveAvailableDirPath(destination.preferredRepoRoot);
|
|
1513
|
+
const bundleTempDir = await fs8.mkdtemp(path6.join(os4.tmpdir(), "remix-remix-"));
|
|
1514
|
+
const bundlePath = path6.join(bundleTempDir, "repository.bundle");
|
|
1515
|
+
try {
|
|
1516
|
+
const bundle = await params.api.downloadAppBundle(String(app.id));
|
|
1517
|
+
await fs8.writeFile(bundlePath, bundle.data);
|
|
1518
|
+
await cloneGitBundleToDirectory(bundlePath, repoRoot);
|
|
1519
|
+
await checkoutLocalBranch(repoRoot, buildPreferredRemixBranch(String(app.id)));
|
|
1520
|
+
await ensureGitInfoExcludeEntries(repoRoot, [".remix/"]);
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
await fs8.rm(repoRoot, { recursive: true, force: true }).catch(() => {
|
|
1523
|
+
});
|
|
1524
|
+
throw err;
|
|
1525
|
+
} finally {
|
|
1526
|
+
await fs8.rm(bundleTempDir, { recursive: true, force: true });
|
|
1527
|
+
}
|
|
1528
|
+
const remoteUrl = normalizeGitRemote(await getRemoteOriginUrl(repoRoot));
|
|
1529
|
+
const defaultBranch = await getDefaultBranch(repoRoot) ?? await getCurrentBranch(repoRoot) ?? null;
|
|
1530
|
+
const preferredBranch = await getCurrentBranch(repoRoot) ?? buildPreferredRemixBranch(String(app.id));
|
|
1531
|
+
const repoFingerprint = remoteUrl ? await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch }) : null;
|
|
1532
|
+
const bindingPath = await writeCollabBinding(repoRoot, {
|
|
1533
|
+
projectId: String(app.projectId),
|
|
1534
|
+
currentAppId: String(app.id),
|
|
1535
|
+
upstreamAppId: String(app.forkedFromAppId ?? sourceAppId),
|
|
1536
|
+
threadId: app.threadId ? String(app.threadId) : null,
|
|
1537
|
+
repoFingerprint,
|
|
1538
|
+
remoteUrl,
|
|
1539
|
+
defaultBranch,
|
|
1540
|
+
preferredBranch
|
|
1541
|
+
});
|
|
1542
|
+
return {
|
|
1543
|
+
appId: String(app.id),
|
|
1544
|
+
projectId: String(app.projectId),
|
|
1545
|
+
upstreamAppId: String(app.forkedFromAppId ?? sourceAppId),
|
|
1546
|
+
bindingPath,
|
|
1547
|
+
repoRoot
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// src/application/collab/collabRequestMerge.ts
|
|
1552
|
+
async function collabRequestMerge(params) {
|
|
1553
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
1554
|
+
const binding = await readCollabBinding(repoRoot);
|
|
1555
|
+
if (!binding) throw new RemixError("Repository is not bound to Remix.", { exitCode: 2 });
|
|
1556
|
+
const resp = await params.api.openMergeRequest(binding.currentAppId);
|
|
1557
|
+
return unwrapResponseObject(resp, "merge request");
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// src/application/collab/collabStatus.ts
|
|
1561
|
+
function createBaseStatus() {
|
|
1562
|
+
return {
|
|
1563
|
+
schemaVersion: 1,
|
|
1564
|
+
repo: {
|
|
1565
|
+
isGitRepo: false,
|
|
1566
|
+
repoRoot: null,
|
|
1567
|
+
branch: null,
|
|
1568
|
+
branchMismatch: false,
|
|
1569
|
+
headCommitHash: null,
|
|
1570
|
+
worktree: {
|
|
1571
|
+
isClean: true,
|
|
1572
|
+
entryCount: 0,
|
|
1573
|
+
hasTrackedChanges: false,
|
|
1574
|
+
hasUntrackedFiles: false,
|
|
1575
|
+
preview: []
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
binding: {
|
|
1579
|
+
isBound: false,
|
|
1580
|
+
path: null,
|
|
1581
|
+
projectId: null,
|
|
1582
|
+
currentAppId: null,
|
|
1583
|
+
upstreamAppId: null,
|
|
1584
|
+
isRemix: null,
|
|
1585
|
+
threadId: null,
|
|
1586
|
+
repoFingerprint: null,
|
|
1587
|
+
remoteUrl: null,
|
|
1588
|
+
defaultBranch: null,
|
|
1589
|
+
preferredBranch: null
|
|
1590
|
+
},
|
|
1591
|
+
remote: {
|
|
1592
|
+
checked: false,
|
|
1593
|
+
error: null,
|
|
1594
|
+
appStatus: null,
|
|
1595
|
+
incomingOpenMergeRequestCount: null,
|
|
1596
|
+
outgoingOpenMergeRequestCount: null
|
|
1597
|
+
},
|
|
1598
|
+
sync: {
|
|
1599
|
+
checked: false,
|
|
1600
|
+
error: null,
|
|
1601
|
+
canApply: false,
|
|
1602
|
+
status: "not_available",
|
|
1603
|
+
blockedReasons: [],
|
|
1604
|
+
warnings: [],
|
|
1605
|
+
targetCommitHash: null,
|
|
1606
|
+
targetCommitId: null,
|
|
1607
|
+
stats: null
|
|
1608
|
+
},
|
|
1609
|
+
reconcile: {
|
|
1610
|
+
checked: false,
|
|
1611
|
+
error: null,
|
|
1612
|
+
canApply: false,
|
|
1613
|
+
status: "not_available",
|
|
1614
|
+
blockedReasons: [],
|
|
1615
|
+
warnings: [],
|
|
1616
|
+
targetHeadCommitHash: null,
|
|
1617
|
+
targetHeadCommitId: null
|
|
1618
|
+
},
|
|
1619
|
+
recommendedAction: "no_action",
|
|
1620
|
+
warnings: []
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
function addWarning(status, message) {
|
|
1624
|
+
const value = String(message ?? "").trim();
|
|
1625
|
+
if (!value || status.warnings.includes(value)) return;
|
|
1626
|
+
status.warnings.push(value);
|
|
1627
|
+
}
|
|
1628
|
+
function addBlockedReason(target, reason) {
|
|
1629
|
+
if (!target.blockedReasons.includes(reason)) target.blockedReasons.push(reason);
|
|
1630
|
+
}
|
|
1631
|
+
function countMergeRequests(payload) {
|
|
1632
|
+
if (Array.isArray(payload)) return payload.length;
|
|
1633
|
+
if (!payload || typeof payload !== "object") return 0;
|
|
1634
|
+
let total = 0;
|
|
1635
|
+
for (const value of Object.values(payload)) {
|
|
1636
|
+
if (Array.isArray(value)) total += value.length;
|
|
1637
|
+
}
|
|
1638
|
+
return total;
|
|
1639
|
+
}
|
|
1640
|
+
async function collabStatus(params) {
|
|
1641
|
+
const status = createBaseStatus();
|
|
1642
|
+
let repoRoot;
|
|
1643
|
+
try {
|
|
1644
|
+
repoRoot = await findGitRoot(params.cwd);
|
|
1645
|
+
} catch (err) {
|
|
1646
|
+
addBlockedReason(status.sync, "not_git_repo");
|
|
1647
|
+
addBlockedReason(status.reconcile, "not_git_repo");
|
|
1648
|
+
addWarning(status, formatCliErrorDetail(err));
|
|
1649
|
+
return status;
|
|
1650
|
+
}
|
|
1651
|
+
status.repo.isGitRepo = true;
|
|
1652
|
+
status.repo.repoRoot = repoRoot;
|
|
1653
|
+
const [branch, headCommitHash, worktreeStatus, binding] = await Promise.all([
|
|
1654
|
+
getCurrentBranch(repoRoot),
|
|
1655
|
+
getHeadCommitHash(repoRoot),
|
|
1656
|
+
getWorktreeStatus(repoRoot),
|
|
1657
|
+
readCollabBinding(repoRoot)
|
|
1658
|
+
]);
|
|
1659
|
+
status.repo.branch = branch;
|
|
1660
|
+
status.repo.branchMismatch = false;
|
|
1661
|
+
status.repo.headCommitHash = headCommitHash;
|
|
1662
|
+
status.repo.worktree = {
|
|
1663
|
+
isClean: worktreeStatus.isClean,
|
|
1664
|
+
entryCount: worktreeStatus.entries.length,
|
|
1665
|
+
hasTrackedChanges: worktreeStatus.entries.some((entry) => !entry.startsWith("??")),
|
|
1666
|
+
hasUntrackedFiles: worktreeStatus.entries.some((entry) => entry.startsWith("??")),
|
|
1667
|
+
preview: worktreeStatus.entries.slice(0, 10)
|
|
1668
|
+
};
|
|
1669
|
+
if (!status.repo.worktree.isClean) addWarning(status, "Working tree has local changes.");
|
|
1670
|
+
if (!branch) addWarning(status, "Repository is in a detached HEAD state.");
|
|
1671
|
+
if (!headCommitHash) addWarning(status, "Failed to resolve local HEAD commit.");
|
|
1672
|
+
if (!binding) {
|
|
1673
|
+
status.binding.path = null;
|
|
1674
|
+
addBlockedReason(status.sync, "not_bound");
|
|
1675
|
+
addBlockedReason(status.reconcile, "not_bound");
|
|
1676
|
+
status.recommendedAction = "init";
|
|
1677
|
+
return status;
|
|
1678
|
+
}
|
|
1679
|
+
status.binding = {
|
|
1680
|
+
isBound: true,
|
|
1681
|
+
path: getCollabBindingPath(repoRoot),
|
|
1682
|
+
projectId: binding.projectId,
|
|
1683
|
+
currentAppId: binding.currentAppId,
|
|
1684
|
+
upstreamAppId: binding.upstreamAppId,
|
|
1685
|
+
isRemix: binding.currentAppId !== binding.upstreamAppId,
|
|
1686
|
+
threadId: binding.threadId,
|
|
1687
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1688
|
+
remoteUrl: binding.remoteUrl,
|
|
1689
|
+
defaultBranch: binding.defaultBranch,
|
|
1690
|
+
preferredBranch: binding.preferredBranch
|
|
1691
|
+
};
|
|
1692
|
+
status.repo.branchMismatch = !isPreferredBranchMatch(branch, binding.preferredBranch);
|
|
1693
|
+
if (status.repo.branchMismatch) {
|
|
1694
|
+
addWarning(
|
|
1695
|
+
status,
|
|
1696
|
+
`Current branch ${branch ?? "(detached)"} does not match preferred branch ${binding.preferredBranch ?? "(unset)"}.`
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
if (!params.api) {
|
|
1700
|
+
status.recommendedAction = "no_action";
|
|
1701
|
+
return status;
|
|
1702
|
+
}
|
|
1703
|
+
const [appResult, incomingResult, outgoingResult, syncResult] = await Promise.allSettled([
|
|
1704
|
+
params.api.getApp(binding.currentAppId),
|
|
1705
|
+
params.api.listMergeRequests({ targetAppId: binding.currentAppId, status: "open", kind: "merge" }),
|
|
1706
|
+
params.api.listMergeRequests({ sourceAppId: binding.currentAppId, status: "open", kind: "merge" }),
|
|
1707
|
+
headCommitHash ? params.api.syncLocalApp(binding.currentAppId, {
|
|
1708
|
+
baseCommitHash: headCommitHash,
|
|
1709
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
1710
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
1711
|
+
defaultBranch: binding.defaultBranch ?? void 0,
|
|
1712
|
+
dryRun: true
|
|
1713
|
+
}) : Promise.resolve(null)
|
|
1714
|
+
]);
|
|
1715
|
+
const remoteErrors = [];
|
|
1716
|
+
if (appResult.status === "fulfilled") {
|
|
1717
|
+
const app = unwrapResponseObject(appResult.value, "app");
|
|
1718
|
+
status.remote.appStatus = typeof app.status === "string" ? app.status : null;
|
|
1719
|
+
} else {
|
|
1720
|
+
remoteErrors.push(formatCliErrorDetail(appResult.reason) ?? "Failed to fetch app status.");
|
|
1721
|
+
}
|
|
1722
|
+
if (incomingResult.status === "fulfilled") {
|
|
1723
|
+
const incoming = unwrapResponseObject(incomingResult.value, "merge requests");
|
|
1724
|
+
status.remote.incomingOpenMergeRequestCount = countMergeRequests(incoming);
|
|
1725
|
+
} else {
|
|
1726
|
+
remoteErrors.push(formatCliErrorDetail(incomingResult.reason) ?? "Failed to fetch incoming merge requests.");
|
|
1727
|
+
}
|
|
1728
|
+
if (outgoingResult.status === "fulfilled") {
|
|
1729
|
+
const outgoing = unwrapResponseObject(outgoingResult.value, "merge requests");
|
|
1730
|
+
status.remote.outgoingOpenMergeRequestCount = countMergeRequests(outgoing);
|
|
1731
|
+
} else {
|
|
1732
|
+
remoteErrors.push(formatCliErrorDetail(outgoingResult.reason) ?? "Failed to fetch outgoing merge requests.");
|
|
1733
|
+
}
|
|
1734
|
+
status.remote.checked = remoteErrors.length === 0;
|
|
1735
|
+
status.remote.error = remoteErrors.length > 0 ? remoteErrors.join("\n\n") : null;
|
|
1736
|
+
addWarning(status, status.remote.error);
|
|
1737
|
+
if (!headCommitHash) {
|
|
1738
|
+
addBlockedReason(status.sync, "missing_head");
|
|
1739
|
+
addBlockedReason(status.reconcile, "missing_head");
|
|
1740
|
+
} else if (syncResult.status === "fulfilled") {
|
|
1741
|
+
const syncResp = syncResult.value;
|
|
1742
|
+
if (syncResp) {
|
|
1743
|
+
const sync = unwrapResponseObject(syncResp, "sync result");
|
|
1744
|
+
status.sync.checked = true;
|
|
1745
|
+
status.sync.status = sync.status;
|
|
1746
|
+
status.sync.warnings = sync.warnings;
|
|
1747
|
+
status.sync.targetCommitHash = sync.targetCommitHash;
|
|
1748
|
+
status.sync.targetCommitId = sync.targetCommitId;
|
|
1749
|
+
status.sync.stats = sync.stats;
|
|
1750
|
+
if (!status.repo.worktree.isClean) addBlockedReason(status.sync, "dirty_worktree");
|
|
1751
|
+
if (!branch) addBlockedReason(status.sync, "detached_head");
|
|
1752
|
+
if (status.repo.branchMismatch) addBlockedReason(status.sync, "branch_mismatch");
|
|
1753
|
+
if (sync.status === "conflict_risk") addBlockedReason(status.sync, "metadata_conflict");
|
|
1754
|
+
status.sync.canApply = status.sync.status === "ready_to_fast_forward" && status.repo.worktree.isClean && Boolean(branch) && !status.sync.blockedReasons.includes("metadata_conflict");
|
|
1755
|
+
if (sync.status === "conflict_risk") {
|
|
1756
|
+
status.reconcile.checked = true;
|
|
1757
|
+
status.reconcile.status = "metadata_conflict";
|
|
1758
|
+
status.reconcile.warnings = sync.warnings;
|
|
1759
|
+
status.reconcile.targetHeadCommitHash = sync.targetCommitHash;
|
|
1760
|
+
status.reconcile.targetHeadCommitId = sync.targetCommitId;
|
|
1761
|
+
addBlockedReason(status.reconcile, "metadata_conflict");
|
|
1762
|
+
if (!status.repo.worktree.isClean) addBlockedReason(status.reconcile, "dirty_worktree");
|
|
1763
|
+
if (!branch) addBlockedReason(status.reconcile, "detached_head");
|
|
1764
|
+
if (status.repo.branchMismatch) addBlockedReason(status.reconcile, "branch_mismatch");
|
|
1765
|
+
} else if (sync.status === "base_unknown") {
|
|
1766
|
+
try {
|
|
1767
|
+
const preflightResp = await params.api.preflightAppReconcile(binding.currentAppId, {
|
|
1768
|
+
localHeadCommitHash: headCommitHash,
|
|
1769
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
1770
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
1771
|
+
defaultBranch: binding.defaultBranch ?? void 0
|
|
1772
|
+
});
|
|
1773
|
+
const preflight = unwrapResponseObject(preflightResp, "reconcile preflight");
|
|
1774
|
+
status.reconcile.checked = true;
|
|
1775
|
+
status.reconcile.status = preflight.status;
|
|
1776
|
+
status.reconcile.warnings = preflight.warnings;
|
|
1777
|
+
status.reconcile.targetHeadCommitHash = preflight.targetHeadCommitHash;
|
|
1778
|
+
status.reconcile.targetHeadCommitId = preflight.targetHeadCommitId;
|
|
1779
|
+
if (!status.repo.worktree.isClean) addBlockedReason(status.reconcile, "dirty_worktree");
|
|
1780
|
+
if (!branch) addBlockedReason(status.reconcile, "detached_head");
|
|
1781
|
+
if (status.repo.branchMismatch) addBlockedReason(status.reconcile, "branch_mismatch");
|
|
1782
|
+
if (preflight.status === "metadata_conflict") addBlockedReason(status.reconcile, "metadata_conflict");
|
|
1783
|
+
status.reconcile.canApply = preflight.status === "ready_to_reconcile" && status.repo.worktree.isClean && Boolean(branch) && !status.reconcile.blockedReasons.includes("metadata_conflict");
|
|
1784
|
+
} catch (err) {
|
|
1785
|
+
status.reconcile.error = formatCliErrorDetail(err) ?? "Failed to fetch reconcile preflight.";
|
|
1786
|
+
addBlockedReason(status.reconcile, "remote_error");
|
|
1787
|
+
addWarning(status, status.reconcile.error);
|
|
1788
|
+
}
|
|
1789
|
+
} else {
|
|
1790
|
+
status.reconcile.checked = true;
|
|
1791
|
+
status.reconcile.status = "not_needed";
|
|
1792
|
+
if (!status.repo.worktree.isClean) addBlockedReason(status.reconcile, "dirty_worktree");
|
|
1793
|
+
if (!branch) addBlockedReason(status.reconcile, "detached_head");
|
|
1794
|
+
if (status.repo.branchMismatch) addBlockedReason(status.reconcile, "branch_mismatch");
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
} else {
|
|
1798
|
+
status.sync.error = formatCliErrorDetail(syncResult.reason) ?? "Failed to fetch sync status.";
|
|
1799
|
+
addBlockedReason(status.sync, "remote_error");
|
|
1800
|
+
addBlockedReason(status.reconcile, "remote_error");
|
|
1801
|
+
addWarning(status, status.sync.error);
|
|
1802
|
+
}
|
|
1803
|
+
for (const warning of status.sync.warnings) addWarning(status, warning);
|
|
1804
|
+
for (const warning of status.reconcile.warnings) addWarning(status, warning);
|
|
1805
|
+
if (!status.binding.isBound) {
|
|
1806
|
+
status.recommendedAction = "init";
|
|
1807
|
+
} else if (status.sync.canApply && status.sync.status === "ready_to_fast_forward") {
|
|
1808
|
+
status.recommendedAction = "sync";
|
|
1809
|
+
} else if (status.reconcile.canApply && status.reconcile.status === "ready_to_reconcile") {
|
|
1810
|
+
status.recommendedAction = "reconcile";
|
|
1811
|
+
} else if ((status.remote.incomingOpenMergeRequestCount ?? 0) > 0) {
|
|
1812
|
+
status.recommendedAction = "review_inbox";
|
|
1813
|
+
} else {
|
|
1814
|
+
status.recommendedAction = "no_action";
|
|
1815
|
+
}
|
|
1816
|
+
return status;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// src/application/collab/collabSyncUpstream.ts
|
|
1820
|
+
async function collabSyncUpstream(params) {
|
|
1821
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
1822
|
+
const binding = await readCollabBinding(repoRoot);
|
|
1823
|
+
if (!binding) {
|
|
1824
|
+
throw new RemixError("Repository is not bound to Remix.", {
|
|
1825
|
+
exitCode: 2,
|
|
1826
|
+
hint: "Run `remix collab init` first."
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
await ensureCleanWorktree(repoRoot, "`remix collab sync-upstream`");
|
|
1830
|
+
await requireCurrentBranch(repoRoot);
|
|
1831
|
+
const repoSnapshot = await captureRepoSnapshot(repoRoot);
|
|
1832
|
+
if (binding.currentAppId === binding.upstreamAppId) {
|
|
1833
|
+
throw new RemixError("Current repository is not bound to a remix/fork app.", {
|
|
1834
|
+
exitCode: 2,
|
|
1835
|
+
hint: "`remix collab sync-upstream` only applies to remixes/forks that have an upstream app."
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
const currentAppResp = await params.api.getApp(binding.currentAppId);
|
|
1839
|
+
const currentApp = unwrapResponseObject(currentAppResp, "app");
|
|
1840
|
+
const initialHeadCommitId = typeof currentApp.headCommitId === "string" ? currentApp.headCommitId : null;
|
|
1841
|
+
const initialStatus = typeof currentApp.status === "string" ? currentApp.status : null;
|
|
1842
|
+
const resp = await params.api.syncUpstreamApp(binding.currentAppId);
|
|
1843
|
+
const syncUpstream = unwrapResponseObject(resp, "upstream sync");
|
|
1844
|
+
if (syncUpstream.status === "up-to-date") {
|
|
1845
|
+
return {
|
|
1846
|
+
status: syncUpstream.status,
|
|
1847
|
+
repoRoot,
|
|
1848
|
+
appId: binding.currentAppId,
|
|
1849
|
+
upstreamAppId: binding.upstreamAppId,
|
|
1850
|
+
localUpdated: false
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
const completedApp = await pollUpstreamSyncCompletion(params.api, binding.currentAppId, {
|
|
1854
|
+
initialHeadCommitId,
|
|
1855
|
+
initialStatus
|
|
1856
|
+
});
|
|
1857
|
+
const localSyncResult = await withRepoMutationLock(
|
|
1858
|
+
{
|
|
1859
|
+
cwd: repoRoot,
|
|
1860
|
+
operation: "collabSyncUpstream"
|
|
1861
|
+
},
|
|
1862
|
+
async ({ repoRoot: lockedRepoRoot, warnings }) => {
|
|
1863
|
+
await assertRepoSnapshotUnchanged(lockedRepoRoot, repoSnapshot, {
|
|
1864
|
+
operation: "`remix collab sync-upstream`",
|
|
1865
|
+
recoveryHint: "The repository changed while upstream sync was in progress. Review the local changes and rerun `remix collab sync-upstream`."
|
|
1866
|
+
});
|
|
1867
|
+
await ensureCleanWorktree(lockedRepoRoot, "`remix collab sync-upstream`");
|
|
1868
|
+
await requireCurrentBranch(lockedRepoRoot);
|
|
1869
|
+
const localSync = await collabSync({
|
|
1870
|
+
api: params.api,
|
|
1871
|
+
cwd: lockedRepoRoot,
|
|
1872
|
+
dryRun: false
|
|
1873
|
+
});
|
|
1874
|
+
const localSyncWarnings = Array.isArray(localSync.warnings) ? localSync.warnings ?? [] : [];
|
|
1875
|
+
return {
|
|
1876
|
+
...localSync,
|
|
1877
|
+
...warnings.length > 0 || localSyncWarnings.length > 0 ? {
|
|
1878
|
+
warnings: Array.from(/* @__PURE__ */ new Set([...warnings, ...localSyncWarnings]))
|
|
1879
|
+
} : {}
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
);
|
|
1883
|
+
return {
|
|
1884
|
+
status: syncUpstream.status,
|
|
1885
|
+
mergeRequestId: syncUpstream.mergeRequestId ?? null,
|
|
1886
|
+
repoRoot,
|
|
1887
|
+
appId: binding.currentAppId,
|
|
1888
|
+
upstreamAppId: binding.upstreamAppId,
|
|
1889
|
+
remoteHeadCommitId: typeof completedApp.headCommitId === "string" ? completedApp.headCommitId : null,
|
|
1890
|
+
localSync: localSyncResult,
|
|
1891
|
+
localUpdated: true,
|
|
1892
|
+
...Array.isArray(localSyncResult.warnings) && (localSyncResult.warnings?.length ?? 0) > 0 ? { warnings: localSyncResult.warnings } : {}
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// src/application/collab/collabView.ts
|
|
1897
|
+
async function collabView(params) {
|
|
1898
|
+
const resp = await params.api.getMergeRequestReview(params.mrId);
|
|
1899
|
+
return unwrapMergeRequestReview(resp);
|
|
1900
|
+
}
|
|
1901
|
+
export {
|
|
1902
|
+
collabAdd,
|
|
1903
|
+
collabApprove,
|
|
1904
|
+
collabInbox,
|
|
1905
|
+
collabInit,
|
|
1906
|
+
collabInvite,
|
|
1907
|
+
collabList,
|
|
1908
|
+
collabReconcile,
|
|
1909
|
+
collabRecordTurn,
|
|
1910
|
+
collabReject,
|
|
1911
|
+
collabRemix,
|
|
1912
|
+
collabRequestMerge,
|
|
1913
|
+
collabStatus,
|
|
1914
|
+
collabSync,
|
|
1915
|
+
collabSyncUpstream,
|
|
1916
|
+
collabView
|
|
1917
|
+
};
|