@lizard-build/cli 4.0.74 → 4.0.76

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.
@@ -58,6 +58,32 @@ function parseIntOption(v: string): number {
58
58
  return n;
59
59
  }
60
60
 
61
+ const MAX_FORK_COUNT = 100;
62
+
63
+ /** One entry per requested copy, in order: the new sandbox, or why it could not start. */
64
+ interface ForkResult {
65
+ sandbox?: SandboxRecord & { forkedFrom?: string };
66
+ error?: string;
67
+ }
68
+
69
+ function parseForkCount(value: string): number {
70
+ const count = Number(value);
71
+ if (!/^\d+$/.test(value) || count < 1 || count > MAX_FORK_COUNT) {
72
+ throw new Error(`Count must be an integer from 1 to ${MAX_FORK_COUNT}`);
73
+ }
74
+ return count;
75
+ }
76
+
77
+ // The platform reads 0 (or nothing) as "the source's lifetime", so 0 can't mean "no limit" here
78
+ // the way it does at create: refuse it rather than quietly give the copies something else.
79
+ function parseForkTimeout(value: string): number {
80
+ const timeout = Number(value);
81
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(timeout) || timeout < 1000 || timeout > 2_147_483_647) {
82
+ throw new Error("Timeout must be an integer from 1000 to 2147483647 milliseconds; leave it out to keep the source's lifetime");
83
+ }
84
+ return timeout;
85
+ }
86
+
61
87
  function parseTimeoutOption(value: string): number {
62
88
  const timeout = Number(value);
63
89
  if (!/^\d+$/.test(value) || !Number.isSafeInteger(timeout) || timeout > 2_147_483_647) {
@@ -266,8 +292,10 @@ export function registerSandbox(program: Command) {
266
292
  printSandboxList(sandboxes);
267
293
  });
268
294
 
295
+ // `kill` is what the SDK calls it (`sandbox.kill()`), so agents that know the SDK reach for
296
+ // it first; an unknown-command error there once left 20 sandboxes running.
269
297
  sb.command("rm")
270
- .alias("delete")
298
+ .aliases(["delete", "kill"])
271
299
  .argument("<id>", "Sandbox ID")
272
300
  .description("Delete a sandbox")
273
301
  .option("-y, --yes", "Skip confirmation")
@@ -553,6 +581,40 @@ Examples:
553
581
  else success(`Sandbox ${chalk.bold(sandbox.id)} created from ${snapshot.name}`);
554
582
  });
555
583
 
584
+ sb.command("fork")
585
+ .argument("<id>", "Running sandbox ID")
586
+ .option("-n, --count <n>", `Copies to start (1-${MAX_FORK_COUNT})`, parseForkCount, 1)
587
+ .option("--timeout <ms>", "Lifetime of each copy in milliseconds; default: the source's", parseForkTimeout)
588
+ .description("Start live copies of a running sandbox: memory, processes and files")
589
+ .action(async (id: string, opts) => {
590
+ // The platform pauses the source for a moment, captures it and boots every copy from that
591
+ // one capture. Each copy is a sandbox start and counts toward the account's limits, so a
592
+ // partial result is normal near a limit: one entry per copy, `sandbox` or `error`.
593
+ const spinner = isJSONMode() ? null : ora(`Forking ${id}...`).start();
594
+ let results: ForkResult[];
595
+ try {
596
+ results = await api.post<ForkResult[]>(`/api/sandboxes/${id}/fork`, { count: opts.count, timeoutMs: opts.timeout });
597
+ } finally {
598
+ spinner?.stop();
599
+ }
600
+ if (!Array.isArray(results)) results = [];
601
+ const started = results.flatMap((r) => (r.sandbox ? [r.sandbox] : []));
602
+ const failed = results.length - started.length;
603
+ // Copies that did start are running and billed: print them, but don't exit 0 on a short count.
604
+ if (failed > 0 || started.length === 0) process.exitCode = 1;
605
+ if (isJSONMode()) {
606
+ printJSON(results);
607
+ return;
608
+ }
609
+ if (started.length) {
610
+ const copies = (n: number) => `${n} ${n === 1 ? "copy" : "copies"}`;
611
+ success(`Forked ${chalk.bold(id)}: ${failed ? `${started.length} of ${copies(results.length)}` : copies(started.length)} running`);
612
+ printSandboxList(started);
613
+ info(chalk.dim(lifetimeLine(started[0].expiresAt ?? started[0].endAt)));
614
+ }
615
+ results.forEach((r, i) => { if (!r.sandbox) error(`Copy ${i + 1}: ${r.error ?? "fork failed"}`); });
616
+ });
617
+
556
618
  // Warm pools exist only for CRIU snapshots of runc pod sandboxes, which are no longer
557
619
  // created. These stay, out of the help, for the few such snapshots still around.
558
620
  for (const operation of ["pause", "resume"] as const) {
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { dirname, join, relative, sep } from "node:path";
5
5
  import { APIError } from "./api.js";
6
6
  import { gitURL, lg, repoPath, sendRaw } from "./lizard-git.js";
7
+ import { hasConflictMarkers, merge3 } from "./merge3.js";
7
8
 
8
9
  /**
9
10
  * A Lizard Git working copy (after Scion: lizard.build/git keeps no repositories as files and
@@ -58,6 +59,12 @@ export interface Checkout {
58
59
  sparse: string[] | null; // folders (or files) checked out; null: everything
59
60
  files: Record<string, FileState>;
60
61
  pending?: Pending; // commits made here, not pushed
62
+ conflicts?: Conflict[]; // left by the last sync for a person to fix (cleared by a commit)
63
+ }
64
+
65
+ export interface Conflict {
66
+ path: string;
67
+ reason: string;
61
68
  }
62
69
 
63
70
  interface WireEntry {
@@ -402,12 +409,17 @@ export interface SyncResult {
402
409
  to: string;
403
410
  updated: string[];
404
411
  removed: string[];
412
+ merged?: string[]; // changed here and on the branch: merged line by line
413
+ conflicts?: Conflict[]; // changed here and on the branch the same lines: marked in the file
405
414
  }
406
415
 
416
+ const isBinaryBuf = (b: Buffer) => b.subarray(0, 8000).includes(0);
417
+
407
418
  /**
408
- * Bring the files to the branch's tip (or another branch: switching). Local changes are kept;
409
- * if the tip changed the same files, nothing is touched and the conflict comes back: commit
410
- * first, and the server merges.
419
+ * Bring the files to the branch's tip (or another branch: switching). Local changes are kept.
420
+ * A file changed here and on the branch is merged here line by line, as the server merges:
421
+ * what both changed differently is written between conflict markers for a person to choose,
422
+ * and listed in the result (and in status until the next commit).
411
423
  */
412
424
  export async function sync(root: string, c: Checkout, toBranch?: string): Promise<SyncResult> {
413
425
  needNoPending(c);
@@ -432,11 +444,57 @@ export async function sync(root: string, c: Checkout, toBranch?: string): Promis
432
444
  const w = want.get(p);
433
445
  return !(l && w && l.h === w.h && l.m === w.m); // the same change on both sides is fine
434
446
  });
447
+ const clashSet = new Set(clash);
448
+ const merged: string[] = [];
449
+ const conflicts: Conflict[] = [];
435
450
  if (clash.length) {
436
- throw new APIError(409, `${branch} changed files you changed too: ${clash.slice(0, 10).join(", ")}${clash.length > 10 ? ", ..." : ""}. Commit and push them (lizard git commit --push -m "..."): the server merges them line by line.`);
451
+ const need = new Set<string>();
452
+ for (const p of clash) {
453
+ if (c.files[p]) need.add(c.files[p].h);
454
+ const w = want.get(p);
455
+ if (w) need.add(w.h);
456
+ }
457
+ const got = await getBlobs(c.repo, [...need]);
458
+ for (const p of clash) {
459
+ const baseF = c.files[p];
460
+ const w = want.get(p);
461
+ const l = local.get(p);
462
+ if (!l && !w) {
463
+ delete c.files[p]; // deleted on both sides
464
+ continue;
465
+ }
466
+ if (!l) {
467
+ // Deleted here, changed on the branch: theirs comes back; deleting it again is one rm away.
468
+ c.files[p] = writeFile(root, p, got.get(w!.h)!, w!.m);
469
+ conflicts.push({ path: p, reason: `you deleted it, ${branch} changed it: their version is back (delete it again if you meant to)` });
470
+ continue;
471
+ }
472
+ if (!w) {
473
+ // Deleted on the branch, changed here: yours stays (it is new to the branch now).
474
+ delete c.files[p];
475
+ conflicts.push({ path: p, reason: `${branch} deleted it, you changed it: your version is kept` });
476
+ continue;
477
+ }
478
+ const full = safeJoin(root, p);
479
+ const ours = l.m === "120000" ? Buffer.from(readlinkSync(full)) : readFileSync(full);
480
+ const base = baseF ? got.get(baseF.h)! : Buffer.alloc(0);
481
+ const theirs = got.get(w.h)!;
482
+ c.files[p] = { h: w.h, m: w.m }; // the branch's version is what yours now differs from
483
+ if (l.m === "120000" || w.m === "120000" || isBinaryBuf(base) || isBinaryBuf(ours) || isBinaryBuf(theirs)) {
484
+ writeFileSync(full + ".theirs", theirs);
485
+ conflicts.push({ path: p, reason: `binary, changed on both sides: yours kept, theirs saved as ${p}.theirs` });
486
+ continue;
487
+ }
488
+ const r = merge3(base.toString("utf8"), ours.toString("utf8"), theirs.toString("utf8"), branch);
489
+ // Yours kept its mode unless only the branch changed it.
490
+ writeFile(root, p, Buffer.from(r.text, "utf8"), baseF && l.m === baseF.m ? w.m : l.m);
491
+ if (r.conflicts) conflicts.push({ path: p, reason: `${r.conflicts} place${r.conflicts === 1 ? "" : "s"} both changed: marked <<<<<<< yours / ======= / >>>>>>> ${branch}` });
492
+ else merged.push(p);
493
+ }
437
494
  }
438
495
  const blobs = await getBlobs(c.repo, toWrite.filter(([p]) => !dirty.has(p)).map(([, f]) => f.h));
439
496
  for (const [p, f] of toWrite) {
497
+ if (clashSet.has(p)) continue;
440
498
  if (dirty.has(p)) {
441
499
  c.files[p] = { ...local.get(p)!, h: f.h, m: f.m }; // already identical locally
442
500
  continue;
@@ -445,6 +503,7 @@ export async function sync(root: string, c: Checkout, toBranch?: string): Promis
445
503
  res.updated.push(p);
446
504
  }
447
505
  for (const p of toRemove) {
506
+ if (clashSet.has(p)) continue;
448
507
  if (!dirty.has(p)) removeFile(root, p);
449
508
  delete c.files[p];
450
509
  res.removed.push(p);
@@ -452,7 +511,10 @@ export async function sync(root: string, c: Checkout, toBranch?: string): Promis
452
511
  c.base = r.id;
453
512
  c.root = r.root;
454
513
  c.branch = branch;
514
+ if (conflicts.length) c.conflicts = [...(c.conflicts ?? []).filter((x) => !conflicts.some((y) => y.path === x.path)), ...conflicts];
455
515
  saveCheckout(root, c);
516
+ if (merged.length) res.merged = merged;
517
+ if (conflicts.length) res.conflicts = conflicts;
456
518
  return res;
457
519
  }
458
520
 
@@ -473,7 +535,7 @@ export interface LocalCommitResult {
473
535
  }
474
536
 
475
537
  /** Record a commit of the changed files (all, or those under paths) here; push sends it. */
476
- export function commitLocal(root: string, c: Checkout, message: string, paths: string[], newBranch?: string): LocalCommitResult {
538
+ export function commitLocal(root: string, c: Checkout, message: string, paths: string[], newBranch?: string, force = false): LocalCommitResult {
477
539
  const local = scanWorking(root, c);
478
540
  const ch = changesOf(c, local);
479
541
  const pick = (p: string) => !paths.length || paths.some((x) => p === x || p.startsWith(x.replace(/\/+$/, "") + "/"));
@@ -481,6 +543,15 @@ export function commitLocal(root: string, c: Checkout, message: string, paths: s
481
543
  const deleted = ch.deleted.filter(pick);
482
544
  if (!changed.length && !deleted.length) throw new APIError(400, "nothing to commit: no changes");
483
545
  if (newBranch && c.pending?.commits.length) throw new APIError(409, "push the commits on this branch before starting another (lizard git push)");
546
+ if (!force) {
547
+ const marked = changed.filter((p) => {
548
+ const f = local.get(p)!;
549
+ if (f.m === "120000" || f.s > 8 << 20) return false;
550
+ const b = readFileSync(safeJoin(root, p));
551
+ return !isBinaryBuf(b) && hasConflictMarkers(b.toString("utf8"));
552
+ });
553
+ if (marked.length) throw new APIError(409, `conflict markers left in ${marked.join(", ")}: choose between yours and theirs there first (or commit --force)`);
554
+ }
484
555
  // Keep the contents as they are now: later edits don't change a commit.
485
556
  for (const p of changed) {
486
557
  const f = local.get(p)!;
@@ -506,6 +577,10 @@ export function commitLocal(root: string, c: Checkout, message: string, paths: s
506
577
  for (const p of changed) c.files[p] = { ...local.get(p)! };
507
578
  for (const p of deleted) delete c.files[p];
508
579
  if (newBranch) c.branch = newBranch;
580
+ if (c.conflicts) {
581
+ c.conflicts = c.conflicts.filter((x) => !changed.includes(x.path) && !deleted.includes(x.path));
582
+ if (!c.conflicts.length) delete c.conflicts;
583
+ }
509
584
  saveCheckout(root, c);
510
585
  return { branch: c.branch, files: changed.length + deleted.length, pending: c.pending.commits.length };
511
586
  }
@@ -527,11 +602,13 @@ export function uncommit(root: string, c: Checkout): number {
527
602
  }
528
603
 
529
604
  export interface PushResult {
605
+ pushed: boolean; // false: conflicts; the commits are changes in the files again, merged with the branch
530
606
  shas: string[];
531
607
  sha: string;
532
608
  branch: string;
533
609
  uploaded: number;
534
610
  sync: SyncResult;
611
+ messages?: string[]; // when not pushed: the commits' messages, to commit again
535
612
  }
536
613
 
537
614
  /** Send the commits not pushed: one branch move; then bring in others' commits if there were any. */
@@ -558,7 +635,19 @@ export async function push(root: string, c: Checkout): Promise<PushResult> {
558
635
  const body: Record<string, unknown> = { branch: c.branch, commits: pend.commits };
559
636
  if (pend.newBranch && pend.base) body.createFrom = pend.base;
560
637
  else if (pend.base) body.expect = pend.base;
561
- const r = await lg.post<{ shas: string[]; sha: string; parent?: string; root?: string }>(`${repoPath(c.repo)}/push`, body);
638
+ let r: { shas: string[]; sha: string; parent?: string; root?: string };
639
+ try {
640
+ r = await lg.post<{ shas: string[]; sha: string; parent?: string; root?: string }>(`${repoPath(c.repo)}/push`, body);
641
+ } catch (e) {
642
+ const conflicts = e instanceof APIError && e.status === 409 ? (e.body as { conflicts?: string[] } | null)?.conflicts : undefined;
643
+ if (!conflicts?.length) throw e;
644
+ // Someone changed the same lines: bring their commits here and merge in the files, so the
645
+ // person sees exactly what to choose; then commit and push again.
646
+ const messages = pend.commits.map((x) => x.message.trim());
647
+ uncommit(root, c);
648
+ const s = await sync(root, c);
649
+ return { pushed: false, shas: [], sha: "", branch: c.branch, uploaded: missing.size, sync: s, messages };
650
+ }
562
651
  const from = pend.base;
563
652
  c.pending = undefined;
564
653
  rmSync(join(root, STATE_DIR, "objects"), { recursive: true, force: true });
@@ -567,11 +656,11 @@ export async function push(root: string, c: Checkout): Promise<PushResult> {
567
656
  c.base = r.sha;
568
657
  c.root = r.root;
569
658
  saveCheckout(root, c);
570
- return { shas: r.shas, sha: r.sha, branch: c.branch, uploaded: missing.size, sync: { from, to: r.sha, updated: [], removed: [] } };
659
+ return { pushed: true, shas: r.shas, sha: r.sha, branch: c.branch, uploaded: missing.size, sync: { from, to: r.sha, updated: [], removed: [] } };
571
660
  }
572
661
  saveCheckout(root, c);
573
662
  const s = await sync(root, c);
574
- return { shas: r.shas, sha: r.sha, branch: c.branch, uploaded: missing.size, sync: s };
663
+ return { pushed: true, shas: r.shas, sha: r.sha, branch: c.branch, uploaded: missing.size, sync: s };
575
664
  }
576
665
 
577
666
  export interface CommitResult {
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Three-way line merge, as Lizard Git's server merges (lizard-git-server internal/store/merge.go,
3
+ * merge3): the changes each side made to base are combined; changes to different lines (even
4
+ * neighbouring ones) merge, the same change on both sides is taken once, and only lines both
5
+ * sides changed differently conflict. Where the server refuses, this writes both versions
6
+ * between conflict markers, for a person to choose, as git does.
7
+ */
8
+
9
+ export const MARK_OURS = "<<<<<<< yours";
10
+ export const MARK_SEP = "=======";
11
+ export const MARK_THEIRS = ">>>>>>>";
12
+
13
+ /** Lines with their line endings kept (the last one may have none). */
14
+ export function splitLines(s: string): string[] {
15
+ const out: string[] = [];
16
+ let start = 0;
17
+ for (let i = 0; i < s.length; i++) {
18
+ if (s[i] === "\n") {
19
+ out.push(s.slice(start, i + 1));
20
+ start = i + 1;
21
+ }
22
+ }
23
+ if (start < s.length) out.push(s.slice(start));
24
+ return out;
25
+ }
26
+
27
+ interface Op {
28
+ k: " " | "-" | "+";
29
+ a: number; // index in base
30
+ b: number; // index in side
31
+ }
32
+
33
+ /** Myers' diff of two line lists. */
34
+ export function diffLines(A: string[], B: string[]): Op[] {
35
+ const n = A.length;
36
+ const m = B.length;
37
+ const max = n + m;
38
+ const v: number[] = new Array(2 * max + 2).fill(0);
39
+ const trace: number[][] = [];
40
+ outer: for (let d = 0; d <= max; d++) {
41
+ trace.push(v.slice());
42
+ for (let k = -d; k <= d; k += 2) {
43
+ let x = k === -d || (k !== d && v[max + k - 1] < v[max + k + 1]) ? v[max + k + 1] : v[max + k - 1] + 1;
44
+ let y = x - k;
45
+ while (x < n && y < m && A[x] === B[y]) {
46
+ x++;
47
+ y++;
48
+ }
49
+ v[max + k] = x;
50
+ if (x >= n && y >= m) break outer;
51
+ }
52
+ }
53
+ const ops: Op[] = [];
54
+ let x = n;
55
+ let y = m;
56
+ for (let d = trace.length - 1; d >= 0; d--) {
57
+ const vd = trace[d];
58
+ const k = x - y;
59
+ const pk = k === -d || (k !== d && vd[max + k - 1] < vd[max + k + 1]) ? k + 1 : k - 1;
60
+ const px = vd[max + pk];
61
+ const py = px - pk;
62
+ while (x > px && y > py) ops.unshift({ k: " ", a: --x, b: --y });
63
+ if (d > 0) {
64
+ if (x === px) ops.unshift({ k: "+", a: x, b: --y });
65
+ else ops.unshift({ k: "-", a: --x, b: y });
66
+ }
67
+ }
68
+ return ops;
69
+ }
70
+
71
+ /** Base lines [start, end) replaced by lines. */
72
+ interface Region {
73
+ start: number;
74
+ end: number;
75
+ lines: string[];
76
+ }
77
+
78
+ function changeRegions(base: string[], side: string[]): Region[] {
79
+ const out: Region[] = [];
80
+ let cur: Region | null = null;
81
+ for (const o of diffLines(base, side)) {
82
+ if (o.k === " ") {
83
+ if (cur) out.push(cur);
84
+ cur = null;
85
+ continue;
86
+ }
87
+ if (!cur) cur = { start: o.a, end: o.a, lines: [] };
88
+ if (o.k === "-") cur.end = o.a + 1;
89
+ else cur.lines.push(side[o.b]);
90
+ }
91
+ if (cur) out.push(cur);
92
+ return out;
93
+ }
94
+
95
+ function overlaps(a: Region, b: Region): boolean {
96
+ // Touching insertions at the same point conflict too, as in git (and the server).
97
+ return (a.start < b.end && b.start < a.end) || a.start === b.start || (a.start === a.end && a.start === b.end) || (b.start === b.end && b.start === a.end);
98
+ }
99
+
100
+ function apply(base: string[], r: { start: number; end: number }, rs: Region[]): string {
101
+ let s = "";
102
+ let pos = r.start;
103
+ for (const x of rs) {
104
+ for (; pos < x.start; pos++) s += base[pos];
105
+ s += x.lines.join("");
106
+ pos = x.end;
107
+ }
108
+ for (; pos < r.end; pos++) s += base[pos];
109
+ return s;
110
+ }
111
+
112
+ const withEOL = (s: string) => (s === "" || s.endsWith("\n") ? s : s + "\n");
113
+
114
+ export interface MergeResult {
115
+ text: string;
116
+ conflicts: number; // regions between markers
117
+ }
118
+
119
+ /** Merge ours and theirs over base; conflicting regions are written with markers. */
120
+ export function merge3(base: string, ours: string, theirs: string, theirsLabel = "theirs"): MergeResult {
121
+ const b = splitLines(base);
122
+ const co = changeRegions(b, splitLines(ours));
123
+ const ct = changeRegions(b, splitLines(theirs));
124
+ let out = "";
125
+ let conflicts = 0;
126
+ let pos = 0;
127
+ let i = 0;
128
+ let j = 0;
129
+ while (i < co.length || j < ct.length) {
130
+ let r: { start: number; end: number };
131
+ const fromO: Region[] = [];
132
+ const fromT: Region[] = [];
133
+ if (j >= ct.length || (i < co.length && co[i].start <= ct[j].start)) {
134
+ r = { start: co[i].start, end: co[i].end };
135
+ fromO.push(co[i++]);
136
+ } else {
137
+ r = { start: ct[j].start, end: ct[j].end };
138
+ fromT.push(ct[j++]);
139
+ }
140
+ for (let grew = true; grew; ) {
141
+ grew = false;
142
+ while (i < co.length && overlaps(co[i], { ...r, lines: [] })) {
143
+ r = { start: Math.min(r.start, co[i].start), end: Math.max(r.end, co[i].end) };
144
+ fromO.push(co[i++]);
145
+ grew = true;
146
+ }
147
+ while (j < ct.length && overlaps(ct[j], { ...r, lines: [] })) {
148
+ r = { start: Math.min(r.start, ct[j].start), end: Math.max(r.end, ct[j].end) };
149
+ fromT.push(ct[j++]);
150
+ grew = true;
151
+ }
152
+ }
153
+ for (; pos < r.start; pos++) out += b[pos];
154
+ if (!fromT.length) out += apply(b, r, fromO);
155
+ else if (!fromO.length) out += apply(b, r, fromT);
156
+ else {
157
+ const mo = apply(b, r, fromO);
158
+ const mt = apply(b, r, fromT);
159
+ if (mo === mt) out += mo;
160
+ else {
161
+ conflicts++;
162
+ out += `${MARK_OURS}\n${withEOL(mo)}${MARK_SEP}\n${withEOL(mt)}${MARK_THEIRS} ${theirsLabel}\n`;
163
+ }
164
+ }
165
+ pos = r.end;
166
+ }
167
+ for (; pos < b.length; pos++) out += b[pos];
168
+ return { text: out, conflicts };
169
+ }
170
+
171
+ /** Whether text still holds conflict markers (a whole marker line from merge3). */
172
+ export function hasConflictMarkers(text: string): boolean {
173
+ let ours = false;
174
+ for (const l of text.split("\n")) {
175
+ if (l === MARK_OURS) ours = true;
176
+ else if (ours && l.startsWith(MARK_THEIRS + " ")) return true;
177
+ }
178
+ return false;
179
+ }
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
5
5
  import os from "node:os";
6
6
  import { spawn } from "node:child_process";
7
7
 
8
- export const CURRENT_VERSION = "4.0.74";
8
+ export const CURRENT_VERSION = "4.0.76";
9
9
  const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
10
10
  const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
11
11
 
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hasConflictMarkers, merge3 } from "../../src/lib/merge3.js";
3
+
4
+ const L = (...xs: string[]) => xs.map((x) => x + "\n").join("");
5
+ const ten = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`);
6
+
7
+ describe("merge3 (as the server merges)", () => {
8
+ it("merges changes to different lines, even neighbouring ones", () => {
9
+ const base = L(...ten);
10
+ expect(merge3(base, L("LINE 1", ...ten.slice(1)), L(...ten.slice(0, 9), "LINE 10"))).toEqual({ text: L("LINE 1", ...ten.slice(1, 9), "LINE 10"), conflicts: 0 });
11
+ expect(merge3(base, L(...ten.slice(0, 4), "LINE 5", ...ten.slice(5)), L(...ten.slice(0, 5), "LINE 6", ...ten.slice(6))).text)
12
+ .toBe(L(...ten.slice(0, 4), "LINE 5", "LINE 6", ...ten.slice(6)));
13
+ });
14
+ it("takes the same change once", () => {
15
+ const x = L(...ten.slice(0, 4), "SAME", ...ten.slice(5));
16
+ expect(merge3(L(...ten), x, x)).toEqual({ text: x, conflicts: 0 });
17
+ });
18
+ it("marks lines both changed differently, and merges the rest", () => {
19
+ const base = L(...ten);
20
+ const r = merge3(base, L("TOP", ...ten.slice(1, 4), "OURS", ...ten.slice(5)), L(...ten.slice(0, 4), "THEIRS", ...ten.slice(5, 9), "BOTTOM"), "main");
21
+ expect(r.conflicts).toBe(1);
22
+ expect(r.text).toBe(L("TOP", ...ten.slice(1, 4), "<<<<<<< yours", "OURS", "=======", "THEIRS", ">>>>>>> main", ...ten.slice(5, 9), "BOTTOM"));
23
+ expect(hasConflictMarkers(r.text)).toBe(true);
24
+ });
25
+ it("both appending at the end conflicts (as in git)", () => {
26
+ const r = merge3(L("a"), L("a", "ours"), L("a", "theirs"));
27
+ expect(r.conflicts).toBe(1);
28
+ });
29
+ it("keeps CRLF and a missing final newline", () => {
30
+ expect(merge3("a\r\nb\r\nc\r\nd\r\n", "A\r\nb\r\nc\r\nd\r\n", "a\r\nb\r\nc\r\nD\r\n").text).toBe("A\r\nb\r\nc\r\nD\r\n");
31
+ expect(merge3("a\nb\nc\nd", "A\nb\nc\nd", "a\nb\nc\nD").text).toBe("A\nb\nc\nD");
32
+ });
33
+ it("a file both added differently: everything is one marked region", () => {
34
+ const r = merge3("", "ours\n", "theirs\n");
35
+ expect(r.text).toBe("<<<<<<< yours\nours\n=======\ntheirs\n>>>>>>> theirs\n");
36
+ });
37
+ it("marker detection needs whole marker lines", () => {
38
+ expect(hasConflictMarkers("x <<<<<<< yours\n")).toBe(false);
39
+ expect(hasConflictMarkers("a\n=======\nb\n")).toBe(false);
40
+ });
41
+ });
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { Command } from "commander";
3
3
  import { registerSandbox, lifetimeLine, sandboxNextSteps } from "../../src/commands/sandbox.js";
4
4
  import { api, APIError } from "../../src/lib/api.js";
@@ -84,7 +84,7 @@ describe("sandbox create without a project", () => {
84
84
  });
85
85
 
86
86
  describe("unsupported sandbox commands", () => {
87
- it.each(["fork", "snapshot-fork", "logs"])(
87
+ it.each(["snapshot-fork", "logs"])(
88
88
  "rejects %s without making an API request", async (name) => {
89
89
  vi.clearAllMocks();
90
90
  const program = new Command().exitOverride().configureOutput({ writeErr: () => {} });
@@ -98,6 +98,61 @@ describe("unsupported sandbox commands", () => {
98
98
  );
99
99
  });
100
100
 
101
+ function sandboxCmd(args: string[]) {
102
+ const program = new Command().exitOverride().configureOutput({ writeErr: () => {} });
103
+ registerSandbox(program);
104
+ return program.parseAsync(["sandbox", ...args], { from: "user" });
105
+ }
106
+
107
+ describe("sandbox rm aliases", () => {
108
+ beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.delete).mockResolvedValue(undefined); });
109
+
110
+ it.each(["rm", "delete", "kill"])("%s deletes the sandbox", async (name) => {
111
+ await sandboxCmd([name, "brave-otter-1234", "-y"]);
112
+ expect(api.delete).toHaveBeenCalledWith("/api/sandboxes/brave-otter-1234");
113
+ expect(printJSON).toHaveBeenCalledWith({ id: "brave-otter-1234", status: "deleted" });
114
+ });
115
+ });
116
+
117
+ describe("sandbox fork", () => {
118
+ const copy = (id: string) => ({ sandbox: { sandboxId: id, id, status: "running", forkedFrom: "src-1234" } });
119
+ beforeEach(() => { vi.clearAllMocks(); process.exitCode = undefined; });
120
+ afterEach(() => { process.exitCode = undefined; });
121
+
122
+ it("asks for one copy with the source's lifetime by default", async () => {
123
+ vi.mocked(api.post).mockResolvedValue([copy("a-1")]);
124
+ await sandboxCmd(["fork", "src-1234"]);
125
+ expect(api.post).toHaveBeenCalledWith("/api/sandboxes/src-1234/fork", { count: 1, timeoutMs: undefined });
126
+ expect(printJSON).toHaveBeenCalledWith([copy("a-1")]);
127
+ expect(process.exitCode).toBeUndefined();
128
+ });
129
+
130
+ it("sends --count and --timeout", async () => {
131
+ vi.mocked(api.post).mockResolvedValue([copy("a-1"), copy("a-2"), copy("a-3")]);
132
+ await sandboxCmd(["fork", "src-1234", "-n", "3", "--timeout", "900000"]);
133
+ expect(api.post).toHaveBeenCalledWith("/api/sandboxes/src-1234/fork", { count: 3, timeoutMs: 900_000 });
134
+ expect(process.exitCode).toBeUndefined();
135
+ });
136
+
137
+ it("prints a partial result and exits 1 when some copies could not start", async () => {
138
+ const results = [copy("a-1"), { error: "Sandbox limit reached" }];
139
+ vi.mocked(api.post).mockResolvedValue(results);
140
+ await sandboxCmd(["fork", "src-1234", "--count", "2"]);
141
+ expect(printJSON).toHaveBeenCalledWith(results);
142
+ expect(process.exitCode).toBe(1);
143
+ });
144
+
145
+ it.each(["0", "101", "1.5", "two"])("rejects --count %s before making a request", async (n) => {
146
+ await expect(sandboxCmd(["fork", "src-1234", "--count", n])).rejects.toThrow(/Count must be/);
147
+ expect(api.post).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it.each(["0", "999", "2147483648", "1h"])("rejects --timeout %s, since 0 would mean the source's lifetime", async (ms) => {
151
+ await expect(sandboxCmd(["fork", "src-1234", "--timeout", ms])).rejects.toThrow(/Timeout must be/);
152
+ expect(api.post).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
101
156
  describe("private sandbox snapshots", () => {
102
157
  beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.post).mockResolvedValue({ id: 'snap-test', status: 'building' }); });
103
158
  it("creates a snapshot without a pool size", async () => {