@lizard-build/cli 4.0.73 → 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/dist/commands/add.js +8 -2
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/git-hosting.js +49 -14
- package/dist/commands/git-hosting.js.map +1 -1
- package/dist/lib/checkout.d.ts +14 -4
- package/dist/lib/checkout.js +100 -8
- package/dist/lib/checkout.js.map +1 -1
- package/dist/lib/merge3.d.ts +28 -0
- package/dist/lib/merge3.js +173 -0
- package/dist/lib/merge3.js.map +1 -0
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/package.json +1 -1
- package/src/commands/add.ts +7 -2
- package/src/commands/git-hosting.ts +42 -15
- package/src/lib/checkout.ts +97 -8
- package/src/lib/merge3.ts +179 -0
- package/src/lib/updater.ts +1 -1
- package/test/unit/merge3.test.ts +41 -0
|
@@ -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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
@@ -281,14 +304,15 @@ export function registerLizardGit(git: Command) {
|
|
|
281
304
|
.option("-d, --description <text>")
|
|
282
305
|
.option("--readme", "Start with a README (otherwise empty)")
|
|
283
306
|
.option("--default-branch <name>", "Default branch (main)")
|
|
307
|
+
.option("-w, --workspace <slug>", "Workspace whose members become its collaborators (default: your personal one)")
|
|
284
308
|
.action(async (name: string, opts) => {
|
|
285
309
|
const r = await lg.post<{ owner: string; name: string }>("/api/repos", {
|
|
286
|
-
name, private: !!opts.private, description: opts.description, readme: !!opts.readme, defaultBranch: opts.defaultBranch,
|
|
310
|
+
name, private: !!opts.private, description: opts.description, readme: !!opts.readme, defaultBranch: opts.defaultBranch, workspace: opts.workspace,
|
|
287
311
|
});
|
|
288
312
|
const full = `${r.owner}/${r.name}`;
|
|
289
313
|
out({ ...r, url: `${gitURL()}/${full}` }, () => {
|
|
290
314
|
success(`Created ${chalk.bold(full)} ${link(`${gitURL()}/${full}`)}`);
|
|
291
|
-
info(chalk.dim(` Check out: lizard git clone ${full} then: lizard git commit -m "..."`));
|
|
315
|
+
info(chalk.dim(` Check out: lizard git clone ${full} then: lizard git commit -m "..." && lizard git push`));
|
|
292
316
|
});
|
|
293
317
|
});
|
|
294
318
|
repoOpt(repo.command("view").description("A repository's details").argument("[repo]"))
|
|
@@ -297,22 +321,25 @@ export function registerLizardGit(git: Command) {
|
|
|
297
321
|
const m = await lg.get<any>(repoPath(r));
|
|
298
322
|
out(m, () => {
|
|
299
323
|
info(`${chalk.bold(r)} ${m.repo.private ? "private" : "public"} ${chalk.dim(`you: ${m.role || "reader"}`)}`);
|
|
324
|
+
if (m.workspace) info(chalk.dim(`workspace: ${m.workspace.name} (${m.workspace.slug}): its members are collaborators`));
|
|
300
325
|
if (m.repo.description) info(m.repo.description);
|
|
301
326
|
if (m.forkOf) info(chalk.dim(`forked from ${m.forkOf}`));
|
|
302
327
|
info(`default branch ${m.repo.defaultBranch} · ${m.branches} branches · ${m.tags} tags · ${m.openIssues} open issues · ${m.openPulls} open pull requests · ${m.repo.stars} stars`);
|
|
303
328
|
info(`${link(`${gitURL()}/${r}`)}\ncheck out: lizard git clone ${r}`);
|
|
304
329
|
});
|
|
305
330
|
});
|
|
306
|
-
repoOpt(repo.command("edit").description("Change name, description, visibility
|
|
331
|
+
repoOpt(repo.command("edit").description("Change name, description, visibility, default branch or workspace").argument("[repo]"))
|
|
307
332
|
.option("--name <name>")
|
|
308
333
|
.option("-d, --description <text>")
|
|
309
334
|
.option("--private").option("--public")
|
|
310
335
|
.option("--default-branch <name>")
|
|
336
|
+
.option("-w, --workspace <slug>", "Attach to a workspace you're in: its members become collaborators (\"none\" detaches; owner only)")
|
|
311
337
|
.action(async (arg: string | undefined, opts) => {
|
|
312
338
|
const r = await resolveRepo(arg ?? opts.repo);
|
|
313
339
|
await lg.patch(repoPath(r), {
|
|
314
340
|
name: opts.name, description: opts.description, defaultBranch: opts.defaultBranch,
|
|
315
341
|
private: opts.private ? true : opts.public ? false : undefined,
|
|
342
|
+
workspace: opts.workspace === undefined ? undefined : opts.workspace === "none" ? "" : opts.workspace,
|
|
316
343
|
});
|
|
317
344
|
done({ ok: true, repo: opts.name ? `${r.split("/")[0]}/${opts.name}` : r }, `Updated ${r}`);
|
|
318
345
|
});
|
|
@@ -972,7 +999,7 @@ export function registerLizardGit(git: Command) {
|
|
|
972
999
|
const r = await resolveRepo(opts.repo);
|
|
973
1000
|
const c = await lg.get<any>(`${repoPath(r)}/collaborators`);
|
|
974
1001
|
out(c, () => {
|
|
975
|
-
c.collaborators.length ? table(["LOGIN", "ROLE", "SINCE"], c.collaborators.map((x: any) => [x.login, x.role, ago(x.createdAt)])) : info("No collaborators.");
|
|
1002
|
+
c.collaborators.length ? table(["LOGIN", "ROLE", "SINCE", "VIA"], c.collaborators.map((x: any) => [x.login, x.role, ago(x.createdAt), x.fromWorkspace ? "workspace" : ""])) : info("No collaborators.");
|
|
976
1003
|
if (c.invitations?.length) {
|
|
977
1004
|
info("\nInvited:");
|
|
978
1005
|
table(["ID", "LOGIN", "ROLE", "BY"], c.invitations.map((x: any) => [String(x.id), x.login, x.role, x.inviter]));
|
package/src/lib/checkout.ts
CHANGED
|
@@ -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
|
-
*
|
|
410
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/updater.ts
CHANGED
|
@@ -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.
|
|
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
|
+
});
|