@remixhq/core 0.1.7 → 0.1.9

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.
@@ -0,0 +1,710 @@
1
+ import {
2
+ REMIX_ERROR_CODES
3
+ } from "./chunk-GC2MOT3U.js";
4
+ import {
5
+ RemixError
6
+ } from "./chunk-YZ34ICNN.js";
7
+
8
+ // src/infrastructure/repo/gitRepo.ts
9
+ import fs from "fs/promises";
10
+ import { createHash } from "crypto";
11
+ import os from "os";
12
+ import path from "path";
13
+ import { execa } from "execa";
14
+ var GIT_REMOTE_PROTOCOL_RE = /^(https?|ssh):\/\//i;
15
+ var SCP_LIKE_GIT_REMOTE_RE = /^(?<user>[^@\s]+)@(?<host>[^:\s]+):(?<path>[^\\\s]+)$/;
16
+ var CANONICAL_GIT_REMOTE_RE = /^(?<host>(?:localhost|[a-z0-9.-]+))\/(?<path>[^\\\s]+)$/i;
17
+ async function runGit(args, cwd) {
18
+ const res = await execa("git", args, { cwd, stderr: "ignore" });
19
+ return String(res.stdout || "").trim();
20
+ }
21
+ async function runGitWithEnv(args, cwd, env) {
22
+ const res = await execa("git", args, { cwd, env, stderr: "ignore" });
23
+ return String(res.stdout || "").trim();
24
+ }
25
+ async function runGitRaw(args, cwd) {
26
+ const res = await execa("git", args, { cwd, stderr: "ignore", stripFinalNewline: false });
27
+ return String(res.stdout || "");
28
+ }
29
+ async function runGitRawWithEnv(args, cwd, env) {
30
+ const res = await execa("git", args, { cwd, env, stderr: "ignore", stripFinalNewline: false });
31
+ return String(res.stdout || "");
32
+ }
33
+ async function runGitDetailed(args, cwd) {
34
+ const res = await execa("git", args, { cwd, reject: false });
35
+ return {
36
+ exitCode: res.exitCode ?? 1,
37
+ stdout: String(res.stdout || ""),
38
+ stderr: String(res.stderr || "")
39
+ };
40
+ }
41
+ function cleanRepoPath(value) {
42
+ return value.trim().replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
43
+ }
44
+ function normalizeGitRemote(remote) {
45
+ const raw = String(remote ?? "").trim();
46
+ if (!raw) return null;
47
+ const canonicalMatch = raw.match(CANONICAL_GIT_REMOTE_RE);
48
+ if (canonicalMatch?.groups?.host && canonicalMatch.groups.path) {
49
+ const repoPath = cleanRepoPath(canonicalMatch.groups.path);
50
+ if (!repoPath) return null;
51
+ return `${canonicalMatch.groups.host}/${repoPath}`.toLowerCase();
52
+ }
53
+ if (GIT_REMOTE_PROTOCOL_RE.test(raw)) {
54
+ try {
55
+ const url = new URL(raw);
56
+ const repoPath = cleanRepoPath(url.pathname);
57
+ if (!url.hostname || !repoPath) return null;
58
+ return `${url.hostname}/${repoPath}`.toLowerCase();
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ const scpMatch = raw.match(SCP_LIKE_GIT_REMOTE_RE);
64
+ if (scpMatch?.groups?.host && scpMatch.groups.path) {
65
+ const repoPath = cleanRepoPath(scpMatch.groups.path);
66
+ if (!repoPath) return null;
67
+ return `${scpMatch.groups.host}/${repoPath}`.toLowerCase();
68
+ }
69
+ return null;
70
+ }
71
+ function sanitizeRefFragment(value) {
72
+ return value.trim().replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/\/{2,}/g, "/").replace(/^\/+|\/+$/g, "").replace(/^-+|-+$/g, "").slice(0, 120);
73
+ }
74
+ function normalizeRepoRelativePath(value) {
75
+ const normalized = path.posix.normalize(value.replace(/\\/g, "/").trim());
76
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
77
+ throw new RemixError("Git returned an invalid repository-relative path.", {
78
+ exitCode: 1,
79
+ hint: `Path: ${value}`
80
+ });
81
+ }
82
+ return normalized;
83
+ }
84
+ function resolveRepoRelativePath(repoRoot, relativePath) {
85
+ return path.resolve(repoRoot, ...relativePath.split("/"));
86
+ }
87
+ function isWithinRepoRoot(repoRoot, targetPath) {
88
+ const relative = path.relative(repoRoot, targetPath);
89
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
90
+ }
91
+ function sha256Hex(value) {
92
+ return createHash("sha256").update(value).digest("hex");
93
+ }
94
+ function resolveGitReportedPath(cwd, reportedPath) {
95
+ const value = reportedPath.trim();
96
+ if (!value) {
97
+ throw new RemixError("Git returned an empty internal path.", { exitCode: 1 });
98
+ }
99
+ return path.isAbsolute(value) ? value : path.resolve(cwd, value);
100
+ }
101
+ function parseGitNameStatusZ(stdout) {
102
+ const tokens = stdout.split("\0");
103
+ const entries = [];
104
+ for (let i = 0; i < tokens.length; i += 1) {
105
+ const rawToken = tokens[i];
106
+ if (!rawToken) continue;
107
+ let status = rawToken;
108
+ let inlinePath = null;
109
+ const tabIdx = rawToken.indexOf(" ");
110
+ if (tabIdx >= 0) {
111
+ status = rawToken.slice(0, tabIdx);
112
+ inlinePath = rawToken.slice(tabIdx + 1) || null;
113
+ }
114
+ const kind = status[0]?.toUpperCase() ?? "";
115
+ if (kind === "R" || kind === "C") {
116
+ const oldPath = inlinePath ?? tokens[++i] ?? "";
117
+ const newPath = tokens[++i] ?? "";
118
+ if (!newPath) continue;
119
+ entries.push({
120
+ status,
121
+ path: normalizeRepoRelativePath(newPath),
122
+ oldPath: oldPath ? normalizeRepoRelativePath(oldPath) : null
123
+ });
124
+ continue;
125
+ }
126
+ const nextPath = inlinePath ?? tokens[++i] ?? "";
127
+ if (!nextPath) continue;
128
+ entries.push({
129
+ status,
130
+ path: normalizeRepoRelativePath(nextPath),
131
+ oldPath: null
132
+ });
133
+ }
134
+ return entries;
135
+ }
136
+ function buildStagePlan(entries) {
137
+ const updatePaths = /* @__PURE__ */ new Set();
138
+ const addPaths = /* @__PURE__ */ new Set();
139
+ for (const entry of entries) {
140
+ const kind = entry.status[0]?.toUpperCase() ?? "";
141
+ if (kind === "A" || kind === "C") {
142
+ addPaths.add(entry.path);
143
+ continue;
144
+ }
145
+ if (kind === "R") {
146
+ if (entry.oldPath) updatePaths.add(entry.oldPath);
147
+ addPaths.add(entry.path);
148
+ continue;
149
+ }
150
+ updatePaths.add(entry.path);
151
+ }
152
+ const expectedPaths = /* @__PURE__ */ new Set([...updatePaths, ...addPaths]);
153
+ return {
154
+ updatePaths: Array.from(updatePaths).sort(),
155
+ addPaths: Array.from(addPaths).sort(),
156
+ expectedPaths: Array.from(expectedPaths).sort()
157
+ };
158
+ }
159
+ function classifyGitApplyFailure(detail) {
160
+ const normalized = String(detail ?? "").toLowerCase();
161
+ if (normalized.includes("corrupt patch") || normalized.includes("patch with only garbage") || normalized.includes("unrecognized input") || normalized.includes("malformed patch") || normalized.includes("patch fragment without header")) {
162
+ return "malformed_patch";
163
+ }
164
+ if (normalized.includes("patch failed") || normalized.includes("does not apply") || normalized.includes("merge conflict") || normalized.includes("conflict")) {
165
+ return "apply_conflict";
166
+ }
167
+ return "unknown";
168
+ }
169
+ async function pruneEmptyParentDirectories(repoRoot, filePath) {
170
+ let current = path.dirname(filePath);
171
+ while (current !== repoRoot && isWithinRepoRoot(repoRoot, current)) {
172
+ const entries = await fs.readdir(current).catch(() => null);
173
+ if (!entries || entries.length > 0) return;
174
+ await fs.rmdir(current).catch(() => void 0);
175
+ current = path.dirname(current);
176
+ }
177
+ }
178
+ async function findGitRoot(startDir) {
179
+ try {
180
+ const root = await runGit(["rev-parse", "--show-toplevel"], startDir);
181
+ if (!root) throw new Error("empty");
182
+ return root;
183
+ } catch {
184
+ throw new RemixError("Not inside a git repository.", {
185
+ exitCode: 2,
186
+ hint: "Run this command from the root of the repository or one of its subdirectories."
187
+ });
188
+ }
189
+ }
190
+ async function getAbsoluteGitDir(cwd) {
191
+ try {
192
+ const gitDir = await runGit(["rev-parse", "--absolute-git-dir"], cwd);
193
+ return resolveGitReportedPath(cwd, gitDir);
194
+ } catch {
195
+ throw new RemixError("Failed to resolve the git directory.", {
196
+ exitCode: 1,
197
+ hint: "Ensure the current working directory is inside a valid git repository."
198
+ });
199
+ }
200
+ }
201
+ async function getGitCommonDir(cwd) {
202
+ try {
203
+ const commonDir = await runGit(["rev-parse", "--git-common-dir"], cwd);
204
+ return resolveGitReportedPath(cwd, commonDir);
205
+ } catch {
206
+ throw new RemixError("Failed to resolve the git common directory.", {
207
+ exitCode: 1,
208
+ hint: "Ensure the current working directory is inside a valid git repository."
209
+ });
210
+ }
211
+ }
212
+ async function getGitPath(cwd, relativePath) {
213
+ try {
214
+ const gitPath = await runGit(["rev-parse", "--git-path", relativePath], cwd);
215
+ return resolveGitReportedPath(cwd, gitPath);
216
+ } catch {
217
+ throw new RemixError("Failed to resolve a git-managed path.", {
218
+ exitCode: 1,
219
+ hint: `Path: ${relativePath}`
220
+ });
221
+ }
222
+ }
223
+ async function getCurrentBranch(cwd) {
224
+ try {
225
+ const branch = await runGit(["branch", "--show-current"], cwd);
226
+ return branch || null;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+ async function checkoutLocalBranch(cwd, branchName) {
232
+ const normalized = sanitizeRefFragment(branchName);
233
+ if (!normalized) {
234
+ throw new RemixError("Preferred local branch name is invalid.", {
235
+ exitCode: 2,
236
+ hint: `Branch: ${branchName}`
237
+ });
238
+ }
239
+ const res = await runGitDetailed(["checkout", "-B", normalized], cwd);
240
+ if (res.exitCode !== 0) {
241
+ const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n\n");
242
+ throw new RemixError("Failed to prepare the preferred local branch.", {
243
+ exitCode: 1,
244
+ hint: detail || `Git could not switch the checkout to ${normalized}.`
245
+ });
246
+ }
247
+ return normalized;
248
+ }
249
+ async function getRemoteOriginUrl(cwd) {
250
+ try {
251
+ const url = await runGit(["config", "--get", "remote.origin.url"], cwd);
252
+ return url || null;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+ async function getDefaultBranch(cwd) {
258
+ try {
259
+ const ref = await runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
260
+ if (!ref) return null;
261
+ const suffix = ref.replace(/^refs\/remotes\/origin\//, "").trim();
262
+ return suffix || null;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+ async function listUntrackedFiles(cwd) {
268
+ try {
269
+ const out = await runGit(["ls-files", "--others", "--exclude-standard"], cwd);
270
+ return out.split("\n").map((line) => line.trim()).filter(Boolean);
271
+ } catch {
272
+ return [];
273
+ }
274
+ }
275
+ async function getWorkingTreeDiff(cwd) {
276
+ const untracked = await listUntrackedFiles(cwd);
277
+ if (untracked.length > 0) {
278
+ throw new RemixError("Untracked files are not included in git diff mode.", {
279
+ exitCode: 2,
280
+ hint: "Provide `--diff-file`/`--diff-stdin`, or add the files to git before running `remix collab add`."
281
+ });
282
+ }
283
+ try {
284
+ return await runGitRaw(["diff", "--binary", "--no-ext-diff", "HEAD"], cwd);
285
+ } catch {
286
+ throw new RemixError("Failed to generate git diff.", { exitCode: 1 });
287
+ }
288
+ }
289
+ async function getWorkspaceDiff(cwd) {
290
+ const snapshot = await getWorkspaceSnapshot(cwd);
291
+ return {
292
+ diff: snapshot.diff,
293
+ includedUntrackedPaths: snapshot.includedUntrackedPaths
294
+ };
295
+ }
296
+ async function getWorkspaceSnapshot(cwd) {
297
+ const headCommitHash = await getHeadCommitHash(cwd);
298
+ if (!headCommitHash) {
299
+ throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
300
+ }
301
+ const includedUntrackedPaths = Array.from(new Set((await listUntrackedFiles(cwd)).map((entry) => normalizeRepoRelativePath(entry))));
302
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "remix-index-"));
303
+ const tempIndexPath = path.join(tempDir, "index");
304
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndexPath };
305
+ try {
306
+ try {
307
+ await runGitWithEnv(["read-tree", "HEAD"], cwd, env);
308
+ await runGitWithEnv(["add", "-A", "--", "."], cwd, env);
309
+ const diff = await runGitRawWithEnv(["diff", "--binary", "--no-ext-diff", "--cached", "HEAD"], cwd, env);
310
+ const nameStatus = await runGitRawWithEnv(["diff", "--name-status", "--find-renames", "-z", "--cached", "HEAD"], cwd, env);
311
+ const pathEntries = parseGitNameStatusZ(nameStatus);
312
+ return {
313
+ diff,
314
+ diffSha256: sha256Hex(diff),
315
+ includedUntrackedPaths,
316
+ pathEntries,
317
+ stagePlan: buildStagePlan(pathEntries)
318
+ };
319
+ } catch {
320
+ throw new RemixError("Failed to generate workspace diff.", {
321
+ exitCode: 1,
322
+ hint: "Git could not snapshot the current workspace into an isolated index."
323
+ });
324
+ }
325
+ } finally {
326
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
327
+ }
328
+ }
329
+ async function writeTempUnifiedDiffBackup(diff, prefix = "remix-add-backup") {
330
+ const safePrefix = prefix.replace(/[^a-zA-Z0-9._-]+/g, "-") || "remix-add-backup";
331
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `${safePrefix}-`));
332
+ const backupPath = path.join(tmpDir, "submitted.diff");
333
+ await fs.writeFile(backupPath, diff, "utf8");
334
+ return { backupPath, diffSha256: sha256Hex(diff) };
335
+ }
336
+ async function validateUnifiedDiff(cwd, diff) {
337
+ const { backupPath } = await writeTempUnifiedDiffBackup(diff, "remix-validate-diff");
338
+ try {
339
+ const applyRes = await runGitDetailed(["apply", "--check", "--index", "--3way", backupPath], cwd);
340
+ if (applyRes.exitCode === 0) {
341
+ return { ok: true };
342
+ }
343
+ const detail = [applyRes.stderr.trim(), applyRes.stdout.trim()].filter(Boolean).join("\n\n") || null;
344
+ return {
345
+ ok: false,
346
+ kind: classifyGitApplyFailure(detail),
347
+ detail
348
+ };
349
+ } finally {
350
+ await fs.rm(path.dirname(backupPath), { recursive: true, force: true }).catch(() => void 0);
351
+ }
352
+ }
353
+ async function preserveWorkspaceChanges(cwd, prefix = "remix-add-preserve") {
354
+ const baseHeadCommitHash = await getHeadCommitHash(cwd);
355
+ if (!baseHeadCommitHash) {
356
+ throw new RemixError("Failed to resolve local HEAD before preserving workspace changes.", { exitCode: 1 });
357
+ }
358
+ const snapshot = await getWorkspaceSnapshot(cwd);
359
+ const { backupPath, diffSha256 } = await writeTempUnifiedDiffBackup(snapshot.diff, prefix);
360
+ return {
361
+ baseHeadCommitHash,
362
+ preservedDiffPath: backupPath,
363
+ preservedDiffSha256: diffSha256,
364
+ includedUntrackedPaths: snapshot.includedUntrackedPaths,
365
+ stagePlan: snapshot.stagePlan
366
+ };
367
+ }
368
+ async function reapplyPreservedWorkspaceChanges(cwd, preserved, operation = "`remix collab add`") {
369
+ const patchFilePath = preserved.preservedDiffPath;
370
+ const tempPatchHash = await fs.readFile(patchFilePath, "utf8").then((content) => sha256Hex(content)).catch(() => null);
371
+ if (!tempPatchHash) {
372
+ return { status: "failed", detail: "Preserved diff artifact is missing." };
373
+ }
374
+ if (tempPatchHash !== preserved.preservedDiffSha256) {
375
+ return { status: "failed", detail: "Preserved diff artifact failed integrity verification." };
376
+ }
377
+ const applyRes = await runGitDetailed(["apply", "--index", "--3way", patchFilePath], cwd);
378
+ if (applyRes.exitCode !== 0) {
379
+ await runGitDetailed(["reset", "--hard", "HEAD"], cwd).catch(() => void 0);
380
+ await runGitDetailed(["clean", "-fd"], cwd).catch(() => void 0);
381
+ const detail = [applyRes.stderr.trim(), applyRes.stdout.trim()].filter(Boolean).join("\n\n") || null;
382
+ return { status: "conflict", detail };
383
+ }
384
+ const unstageRes = await runGitDetailed(["reset", "--mixed", "HEAD", "--", "."], cwd);
385
+ if (unstageRes.exitCode !== 0) {
386
+ await runGitDetailed(["reset", "--hard", "HEAD"], cwd).catch(() => void 0);
387
+ await runGitDetailed(["clean", "-fd"], cwd).catch(() => void 0);
388
+ const detail = [unstageRes.stderr.trim(), unstageRes.stdout.trim()].filter(Boolean).join("\n\n") || null;
389
+ return {
390
+ status: "failed",
391
+ detail: detail || `Git could not restore the working tree state while running ${operation}.`
392
+ };
393
+ }
394
+ return { status: "clean" };
395
+ }
396
+ async function getHeadCommitHash(cwd) {
397
+ try {
398
+ const hash = await runGit(["rev-parse", "HEAD"], cwd);
399
+ return hash || null;
400
+ } catch {
401
+ return null;
402
+ }
403
+ }
404
+ async function createGitBundle(cwd, bundleName = "repository.bundle") {
405
+ const headCommitHash = await getHeadCommitHash(cwd);
406
+ if (!headCommitHash) {
407
+ throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
408
+ }
409
+ const safeName = bundleName.replace(/[^a-zA-Z0-9._-]+/g, "_");
410
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "remix-bundle-"));
411
+ const bundlePath = path.join(tmpDir, safeName);
412
+ const res = await runGitDetailed(["bundle", "create", bundlePath, "--all"], cwd);
413
+ if (res.exitCode !== 0) {
414
+ const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n\n");
415
+ throw new RemixError("Failed to create repository bundle.", {
416
+ exitCode: 1,
417
+ hint: detail || "Git could not create the bundle artifact."
418
+ });
419
+ }
420
+ return { bundlePath, headCommitHash };
421
+ }
422
+ async function getWorktreeStatus(cwd) {
423
+ try {
424
+ const out = await runGit(["status", "--porcelain"], cwd);
425
+ const entries = out.split("\n").map((line) => line.trimEnd()).filter(Boolean);
426
+ return { isClean: entries.length === 0, entries };
427
+ } catch {
428
+ return { isClean: false, entries: [] };
429
+ }
430
+ }
431
+ async function captureRepoSnapshot(cwd, options) {
432
+ const [branch, headCommitHash, worktreeStatus] = await Promise.all([
433
+ getCurrentBranch(cwd),
434
+ getHeadCommitHash(cwd),
435
+ getWorktreeStatus(cwd)
436
+ ]);
437
+ const workspaceDiffSha256 = options?.includeWorkspaceDiffHash ? (await getWorkspaceSnapshot(cwd)).diffSha256 : null;
438
+ return {
439
+ branch,
440
+ headCommitHash,
441
+ statusEntries: worktreeStatus.entries,
442
+ statusSha256: sha256Hex(worktreeStatus.entries.join("\n")),
443
+ workspaceDiffSha256
444
+ };
445
+ }
446
+ async function assertRepoSnapshotUnchanged(cwd, snapshot, params) {
447
+ const current = await captureRepoSnapshot(cwd, {
448
+ includeWorkspaceDiffHash: snapshot.workspaceDiffSha256 !== null
449
+ });
450
+ const changes = [];
451
+ if (current.branch !== snapshot.branch) {
452
+ changes.push(`Branch changed: ${snapshot.branch ?? "(detached)"} -> ${current.branch ?? "(detached)"}`);
453
+ }
454
+ if (current.headCommitHash !== snapshot.headCommitHash) {
455
+ changes.push(`HEAD changed: ${snapshot.headCommitHash ?? "(missing)"} -> ${current.headCommitHash ?? "(missing)"}`);
456
+ }
457
+ if (current.statusSha256 !== snapshot.statusSha256) {
458
+ changes.push("Working tree status changed.");
459
+ }
460
+ if (snapshot.workspaceDiffSha256 !== null && current.workspaceDiffSha256 !== snapshot.workspaceDiffSha256) {
461
+ changes.push("Workspace diff changed.");
462
+ }
463
+ if (changes.length === 0) return;
464
+ const operation = params?.operation?.trim() || "this Remix operation";
465
+ const hint = [
466
+ `Operation: ${operation}`,
467
+ ...changes,
468
+ params?.recoveryHint?.trim() || "Review the local changes, then rerun the operation."
469
+ ].filter(Boolean).join("\n");
470
+ throw new RemixError("Repository state changed during the operation.", {
471
+ code: REMIX_ERROR_CODES.REPO_STATE_CHANGED_DURING_OPERATION,
472
+ exitCode: 2,
473
+ hint
474
+ });
475
+ }
476
+ async function ensureCleanWorktree(cwd, operation = "`remix collab sync`") {
477
+ const status = await getWorktreeStatus(cwd);
478
+ if (status.isClean) return;
479
+ const preview = status.entries.slice(0, 10).join("\n");
480
+ const suffix = status.entries.length > 10 ? `
481
+ ...and ${status.entries.length - 10} more` : "";
482
+ throw new RemixError(`Working tree must be clean before running ${operation}.`, {
483
+ exitCode: 2,
484
+ hint: `Commit, stash, or discard local changes first.
485
+
486
+ ${preview}${suffix}`
487
+ });
488
+ }
489
+ async function discardTrackedChanges(cwd, operation = "`remix collab add`") {
490
+ const res = await runGitDetailed(["reset", "--hard", "HEAD"], cwd);
491
+ if (res.exitCode !== 0) {
492
+ const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n\n");
493
+ throw new RemixError(`Failed to discard local tracked changes while running ${operation}.`, {
494
+ exitCode: 1,
495
+ hint: detail || "Git could not reset tracked changes back to HEAD."
496
+ });
497
+ }
498
+ const hash = await getHeadCommitHash(cwd);
499
+ if (!hash) {
500
+ throw new RemixError("Failed to resolve local HEAD after discarding tracked changes.", { exitCode: 1 });
501
+ }
502
+ return hash;
503
+ }
504
+ async function discardCapturedUntrackedChanges(repoRoot, capturedPaths) {
505
+ const normalizedCapturedPaths = Array.from(new Set(capturedPaths.map((entry) => normalizeRepoRelativePath(entry))));
506
+ if (normalizedCapturedPaths.length === 0) {
507
+ return { removedPaths: [] };
508
+ }
509
+ const currentUntracked = new Set((await listUntrackedFiles(repoRoot)).map((entry) => normalizeRepoRelativePath(entry)));
510
+ const removedPaths = [];
511
+ for (const relativePath of normalizedCapturedPaths) {
512
+ if (!currentUntracked.has(relativePath)) continue;
513
+ const absolutePath = resolveRepoRelativePath(repoRoot, relativePath);
514
+ if (!isWithinRepoRoot(repoRoot, absolutePath)) {
515
+ throw new RemixError("Refusing to delete a path outside the repository root.", {
516
+ exitCode: 1,
517
+ hint: `Path: ${relativePath}`
518
+ });
519
+ }
520
+ await fs.rm(absolutePath, { recursive: true, force: true }).catch((err) => {
521
+ throw new RemixError("Failed to remove a captured untracked path before syncing.", {
522
+ exitCode: 1,
523
+ hint: err instanceof Error ? `${relativePath}
524
+
525
+ ${err.message}` : relativePath
526
+ });
527
+ });
528
+ removedPaths.push(relativePath);
529
+ await pruneEmptyParentDirectories(repoRoot, absolutePath);
530
+ }
531
+ return { removedPaths };
532
+ }
533
+ async function requireCurrentBranch(cwd) {
534
+ const branch = await getCurrentBranch(cwd);
535
+ if (!branch) {
536
+ throw new RemixError("`remix collab sync` requires a checked out local branch.", {
537
+ exitCode: 2,
538
+ hint: "Checkout a branch before syncing."
539
+ });
540
+ }
541
+ return branch;
542
+ }
543
+ async function importGitBundle(cwd, bundlePath, bundleRef) {
544
+ const verifyRes = await runGitDetailed(["bundle", "verify", bundlePath], cwd);
545
+ if (verifyRes.exitCode !== 0) {
546
+ const detail = [verifyRes.stderr.trim(), verifyRes.stdout.trim()].filter(Boolean).join("\n\n");
547
+ throw new RemixError("Failed to verify sync bundle.", {
548
+ exitCode: 1,
549
+ hint: detail || "Git bundle verification failed."
550
+ });
551
+ }
552
+ const fetchRes = await runGitDetailed(["fetch", "--quiet", bundlePath, bundleRef], cwd);
553
+ if (fetchRes.exitCode !== 0) {
554
+ const detail = [fetchRes.stderr.trim(), fetchRes.stdout.trim()].filter(Boolean).join("\n\n");
555
+ throw new RemixError("Failed to import sync bundle.", {
556
+ exitCode: 1,
557
+ hint: detail || "Git could not fetch objects from the sync bundle."
558
+ });
559
+ }
560
+ }
561
+ async function cloneGitBundleToDirectory(bundlePath, targetDir) {
562
+ const parentDir = path.dirname(targetDir);
563
+ const cloneRes = await runGitDetailed(["clone", bundlePath, targetDir], parentDir);
564
+ if (cloneRes.exitCode !== 0) {
565
+ const detail = [cloneRes.stderr.trim(), cloneRes.stdout.trim()].filter(Boolean).join("\n\n");
566
+ throw new RemixError("Failed to create local remix checkout.", {
567
+ exitCode: 1,
568
+ hint: detail || "Git could not clone the remix repository bundle."
569
+ });
570
+ }
571
+ const remoteRemoveRes = await runGitDetailed(["remote", "remove", "origin"], targetDir);
572
+ if (remoteRemoveRes.exitCode !== 0) {
573
+ const detail = [remoteRemoveRes.stderr.trim(), remoteRemoveRes.stdout.trim()].filter(Boolean).join("\n\n");
574
+ throw new RemixError("Failed to finalize local remix checkout.", {
575
+ exitCode: 1,
576
+ hint: detail || "Git could not remove the temporary bundle origin."
577
+ });
578
+ }
579
+ }
580
+ async function ensureGitInfoExcludeEntries(cwd, entries) {
581
+ const excludePath = await getGitPath(cwd, "info/exclude");
582
+ await fs.mkdir(path.dirname(excludePath), { recursive: true });
583
+ let current = "";
584
+ try {
585
+ current = await fs.readFile(excludePath, "utf8");
586
+ } catch {
587
+ }
588
+ const lines = new Set(current.split("\n").map((line) => line.trim()).filter(Boolean));
589
+ let changed = false;
590
+ for (const entry of entries) {
591
+ const normalized = entry.trim();
592
+ if (!normalized || lines.has(normalized)) continue;
593
+ lines.add(normalized);
594
+ changed = true;
595
+ }
596
+ if (!changed) return;
597
+ await fs.writeFile(excludePath, `${Array.from(lines).join("\n")}
598
+ `, "utf8");
599
+ }
600
+ async function ensureCommitExists(cwd, commitHash) {
601
+ const res = await runGitDetailed(["cat-file", "-e", `${commitHash}^{commit}`], cwd);
602
+ if (res.exitCode === 0) return;
603
+ throw new RemixError("Expected target commit is missing after bundle import.", {
604
+ exitCode: 1,
605
+ hint: `Commit ${commitHash} is not available in the local repository.`
606
+ });
607
+ }
608
+ async function fastForwardToCommit(cwd, commitHash) {
609
+ const res = await runGitDetailed(["merge", "--ff-only", commitHash], cwd);
610
+ if (res.exitCode !== 0) {
611
+ const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n\n");
612
+ throw new RemixError("Failed to fast-forward local branch.", {
613
+ exitCode: 1,
614
+ hint: detail || "Git could not fast-forward to the target commit."
615
+ });
616
+ }
617
+ const hash = await getHeadCommitHash(cwd);
618
+ if (!hash) throw new RemixError("Failed to resolve local HEAD after fast-forward sync.", { exitCode: 1 });
619
+ return hash;
620
+ }
621
+ async function createBackupBranch(cwd, params) {
622
+ const sourceCommitHash = params?.sourceCommitHash?.trim() || await getHeadCommitHash(cwd);
623
+ if (!sourceCommitHash) {
624
+ throw new RemixError("Failed to resolve local HEAD before creating reconcile backup.", { exitCode: 1 });
625
+ }
626
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
627
+ const branchFragment = sanitizeRefFragment(params?.branchName?.trim() || "current-branch");
628
+ const prefix = sanitizeRefFragment(params?.prefix?.trim() || "remix/reconcile-backup");
629
+ const backupBranchName = `${prefix}/${branchFragment}-${timestamp}`;
630
+ const createRes = await runGitDetailed(["branch", backupBranchName, sourceCommitHash], cwd);
631
+ if (createRes.exitCode !== 0) {
632
+ const detail = [createRes.stderr.trim(), createRes.stdout.trim()].filter(Boolean).join("\n\n");
633
+ throw new RemixError("Failed to create reconcile backup branch.", {
634
+ exitCode: 1,
635
+ hint: detail || "Git could not create the safety backup branch."
636
+ });
637
+ }
638
+ return { branchName: backupBranchName, commitHash: sourceCommitHash };
639
+ }
640
+ async function hardResetToCommit(cwd, commitHash, operation = "`remix collab reconcile`") {
641
+ const res = await runGitDetailed(["reset", "--hard", commitHash], cwd);
642
+ if (res.exitCode !== 0) {
643
+ const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n\n");
644
+ throw new RemixError(`Failed to move local branch while running ${operation}.`, {
645
+ exitCode: 1,
646
+ hint: detail || `Git could not reset the current branch to ${commitHash}.`
647
+ });
648
+ }
649
+ const hash = await getHeadCommitHash(cwd);
650
+ if (!hash) {
651
+ throw new RemixError("Failed to resolve local HEAD after resetting branch.", { exitCode: 1 });
652
+ }
653
+ return hash;
654
+ }
655
+ async function buildRepoFingerprint(params) {
656
+ const remote = normalizeGitRemote(params.remoteUrl);
657
+ const defaultBranch = params.defaultBranch?.trim().toLowerCase() || "";
658
+ const payload = remote ? { remote, defaultBranch } : { local: path.resolve(params.gitRoot).toLowerCase(), defaultBranch };
659
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
660
+ }
661
+ function summarizeUnifiedDiff(diff) {
662
+ const lines = diff.split("\n");
663
+ let changedFilesCount = 0;
664
+ let insertions = 0;
665
+ let deletions = 0;
666
+ for (const line of lines) {
667
+ if (line.startsWith("diff --git ")) changedFilesCount += 1;
668
+ if (line.startsWith("+") && !line.startsWith("+++")) insertions += 1;
669
+ if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
670
+ }
671
+ return { changedFilesCount, insertions, deletions };
672
+ }
673
+
674
+ export {
675
+ normalizeGitRemote,
676
+ findGitRoot,
677
+ getAbsoluteGitDir,
678
+ getGitCommonDir,
679
+ getGitPath,
680
+ getCurrentBranch,
681
+ checkoutLocalBranch,
682
+ getRemoteOriginUrl,
683
+ getDefaultBranch,
684
+ listUntrackedFiles,
685
+ getWorkingTreeDiff,
686
+ getWorkspaceDiff,
687
+ getWorkspaceSnapshot,
688
+ writeTempUnifiedDiffBackup,
689
+ validateUnifiedDiff,
690
+ preserveWorkspaceChanges,
691
+ reapplyPreservedWorkspaceChanges,
692
+ getHeadCommitHash,
693
+ createGitBundle,
694
+ getWorktreeStatus,
695
+ captureRepoSnapshot,
696
+ assertRepoSnapshotUnchanged,
697
+ ensureCleanWorktree,
698
+ discardTrackedChanges,
699
+ discardCapturedUntrackedChanges,
700
+ requireCurrentBranch,
701
+ importGitBundle,
702
+ cloneGitBundleToDirectory,
703
+ ensureGitInfoExcludeEntries,
704
+ ensureCommitExists,
705
+ fastForwardToCommit,
706
+ createBackupBranch,
707
+ hardResetToCommit,
708
+ buildRepoFingerprint,
709
+ summarizeUnifiedDiff
710
+ };