@lizard-build/cli 4.0.74 → 4.0.75

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lizard-build/cli",
3
- "version": "4.0.74",
3
+ "version": "4.0.75",
4
4
  "description": "Lizard CLI — deploy apps, databases and sandboxes to Lizard (lizard.build) from your terminal or your coding agent. Detects Go, Node, Python, Rust, Ruby, PHP, Java and static sites; adds Managed Postgres, Managed Redis and Managed Object Storage in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,12 +71,16 @@ export function checkoutStatus(): boolean {
71
71
  const c = wc.loadCheckout(root);
72
72
  const ch = wc.changesOf(c, wc.scanWorking(root, c));
73
73
  const unpushed = (c.pending?.commits ?? []).map((x) => ({ message: x.message.trim(), time: x.time, files: x.files.length }));
74
- out({ repo: c.repo, branch: c.branch, commit: c.base, sparse: c.sparse, ...ch, unpushed }, () => {
74
+ out({ repo: c.repo, branch: c.branch, commit: c.base, sparse: c.sparse, ...ch, unpushed, conflicts: c.conflicts ?? [] }, () => {
75
75
  info(`${chalk.bold(c.repo)} on ${chalk.cyan(c.branch)} at ${c.base.slice(0, 12)}${c.sparse ? chalk.dim(` (sparse: ${c.sparse.join(", ")})`) : ""}`);
76
76
  if (unpushed.length) {
77
77
  info(`${chalk.yellow(`${unpushed.length} commit${unpushed.length === 1 ? "" : "s"} not pushed`)} ${chalk.dim("(lizard git push)")}`);
78
78
  for (const u of unpushed) info(` ${chalk.dim("•")} ${u.message.split("\n")[0]}`);
79
79
  }
80
+ if (c.conflicts?.length) {
81
+ info(chalk.red(`${c.conflicts.length} conflict${c.conflicts.length === 1 ? "" : "s"} to fix`));
82
+ for (const x of c.conflicts) info(` ${chalk.red("conflict")} ${x.path} ${chalk.dim(x.reason)}`);
83
+ }
80
84
  const n = ch.added.length + ch.modified.length + ch.deleted.length;
81
85
  if (!n) return info(chalk.dim("no changes"));
82
86
  for (const p of ch.modified) info(` ${chalk.yellow("modified")} ${p}`);
@@ -160,6 +164,23 @@ export function registerLizardGit(git: Command) {
160
164
  const extra = r.sync.updated.length + r.sync.removed.length;
161
165
  return `Pushed ${r.shas.length} commit${r.shas.length === 1 ? "" : "s"} to ${r.branch}: ${r.sha.slice(0, 12)}${r.uploaded ? chalk.dim(` (${r.uploaded} file(s) uploaded)`) : ""}${extra ? chalk.dim(` · ${extra} file(s) from others' commits updated`) : ""}`;
162
166
  };
167
+ // A sync's merges and conflicts, for people.
168
+ const showMerge = (s: wc.SyncResult) => {
169
+ for (const p of s.merged ?? []) info(` ${chalk.green("merged")} ${p}`);
170
+ for (const x of s.conflicts ?? []) info(` ${chalk.red("conflict")} ${x.path} ${chalk.dim(x.reason)}`);
171
+ };
172
+ // Push, or after a conflict: what was merged and what to fix (exit status 1).
173
+ const reportPush = (r: wc.PushResult) => {
174
+ if (r.pushed) return done(r, pushed(r));
175
+ out(r, () => {
176
+ info(chalk.yellow(`Not pushed: ${r.branch} has commits that change the same places.`));
177
+ info(chalk.dim(`Your ${r.messages?.length ?? 0} commit(s) are changes in your files again, merged with ${r.branch}:`));
178
+ showMerge(r.sync);
179
+ const msg = (r.messages ?? []).map((m) => m.split("\n")[0]).join("; ");
180
+ info(`Fix the marked places, then: lizard git commit --push -m ${JSON.stringify(msg || "...")}`);
181
+ });
182
+ process.exitCode = 1;
183
+ };
163
184
 
164
185
  git
165
186
  .command("commit")
@@ -168,14 +189,12 @@ export function registerLizardGit(git: Command) {
168
189
  .requiredOption("-m, --message <text>", "Commit message")
169
190
  .option("-b, --branch <name>", "Commit to a new branch, starting at this checkout's commit (and switch to it)")
170
191
  .option("--push", "Push right after committing")
171
- .action(async (paths: string[], opts: { message: string; branch?: string; push?: boolean }) => {
192
+ .option("--force", "Commit even with conflict markers left in a file")
193
+ .action(async (paths: string[], opts: { message: string; branch?: string; push?: boolean; force?: boolean }) => {
172
194
  const root = needCheckout();
173
195
  const c = wc.loadCheckout(root);
174
- const r = wc.commitLocal(root, c, opts.message.endsWith("\n") ? opts.message : opts.message + "\n", paths, opts.branch);
175
- if (opts.push) {
176
- const p = await wc.push(root, c);
177
- return done(p, pushed(p));
178
- }
196
+ const r = wc.commitLocal(root, c, opts.message.endsWith("\n") ? opts.message : opts.message + "\n", paths, opts.branch, !!opts.force);
197
+ if (opts.push) return reportPush(await wc.push(root, c));
179
198
  done(r, `Committed ${r.files} file(s) on ${r.branch} ${chalk.dim(`(${r.pending} not pushed: lizard git push)`)}`);
180
199
  });
181
200
 
@@ -185,8 +204,7 @@ export function registerLizardGit(git: Command) {
185
204
  .action(async () => {
186
205
  const root = needCheckout();
187
206
  const c = wc.loadCheckout(root);
188
- const r = await wc.push(root, c);
189
- done(r, pushed(r));
207
+ reportPush(await wc.push(root, c));
190
208
  });
191
209
 
192
210
  git
@@ -207,8 +225,13 @@ export function registerLizardGit(git: Command) {
207
225
  const root = needCheckout();
208
226
  const c = wc.loadCheckout(root);
209
227
  const r = await wc.sync(root, c);
210
- done(r, r.from === r.to ? `Already at ${c.branch}'s latest (${r.to.slice(0, 12)})` :
211
- `${c.branch}: ${r.from.slice(0, 12)} -> ${r.to.slice(0, 12)} (${r.updated.length} updated, ${r.removed.length} removed)`);
228
+ out(r, () => {
229
+ info(r.from === r.to ? `Already at ${c.branch}'s latest (${r.to.slice(0, 12)})` :
230
+ `${c.branch}: ${r.from.slice(0, 12)} -> ${r.to.slice(0, 12)} (${r.updated.length} updated, ${r.removed.length} removed)`);
231
+ showMerge(r);
232
+ if (r.conflicts?.length) info(chalk.dim(`Fix the marked places, then commit and push them.`));
233
+ });
234
+ if (r.conflicts?.length) process.exitCode = 1;
212
235
  });
213
236
 
214
237
  git
@@ -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.75";
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
+ });