@lizard-build/cli 4.0.72 → 4.0.74

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.
@@ -16,10 +16,11 @@ import { gitURL, lg, repoPath, sendRaw } from "./lizard-git.js";
16
16
  * checkout on this machine (~/.cache/lizard/git; immutable, so it never needs invalidating),
17
17
  * and a sparse checkout only ever fetches the folders it covers.
18
18
  *
19
- * `commit` sends what changed: the server is asked which contents it lacks, those are
20
- * uploaded, then one request makes the commit on the branch. If the branch moved since base,
21
- * the server places the change on the new tip (merging the same files line by line); only a
22
- * real conflict comes back.
19
+ * `commit` records a commit here (pending, with copies of its contents under
20
+ * .lizard-git/objects/); `push` sends every pending commit: the server is asked which contents
21
+ * it lacks, those are uploaded, then one request makes the commits on the branch and moves it
22
+ * once (one CI run, one deploy). If the branch moved since they were made, the server places
23
+ * them on the new tip (merging the same files line by line); only a real conflict comes back.
23
24
  */
24
25
 
25
26
  export const STATE_DIR = ".lizard-git";
@@ -32,6 +33,21 @@ export interface FileState {
32
33
  t?: number;
33
34
  }
34
35
 
36
+ /** A commit made here and not pushed yet: its files' states (full contents, by hash). */
37
+ export interface PendingCommit {
38
+ message: string;
39
+ time: number; // unix seconds
40
+ files: ({ path: string; hash: string; mode: string } | { path: string; delete: true })[];
41
+ }
42
+
43
+ export interface Pending {
44
+ base: string; // the commit the first one was made on ("" in an empty repository)
45
+ newBranch?: boolean; // the branch doesn't exist on the server yet (commit -b): push creates it at base
46
+ fromBranch?: string; // with newBranch: the branch the checkout was on
47
+ commits: PendingCommit[];
48
+ orig: Record<string, FileState | null>; // the touched files' states at base (null: not there), for uncommit
49
+ }
50
+
35
51
  export interface Checkout {
36
52
  version: 1;
37
53
  url: string;
@@ -41,6 +57,7 @@ export interface Checkout {
41
57
  root: string; // its root tree
42
58
  sparse: string[] | null; // folders (or files) checked out; null: everything
43
59
  files: Record<string, FileState>;
60
+ pending?: Pending; // commits made here, not pushed
44
61
  }
45
62
 
46
63
  interface WireEntry {
@@ -393,6 +410,7 @@ export interface SyncResult {
393
410
  * first, and the server merges.
394
411
  */
395
412
  export async function sync(root: string, c: Checkout, toBranch?: string): Promise<SyncResult> {
413
+ needNoPending(c);
396
414
  const branch = toBranch ?? c.branch;
397
415
  const r = await lg.get<{ id: string; root: string }>(`${repoPath(c.repo)}/resolve?rev=${encodeURIComponent(branch)}`);
398
416
  const res: SyncResult = { from: c.base, to: r.id, updated: [], removed: [] };
@@ -415,7 +433,7 @@ export async function sync(root: string, c: Checkout, toBranch?: string): Promis
415
433
  return !(l && w && l.h === w.h && l.m === w.m); // the same change on both sides is fine
416
434
  });
417
435
  if (clash.length) {
418
- throw new APIError(409, `${branch} changed files you changed too: ${clash.slice(0, 10).join(", ")}${clash.length > 10 ? ", ..." : ""}. Commit first (lizard git commit): the server merges them line by line.`);
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.`);
419
437
  }
420
438
  const blobs = await getBlobs(c.repo, toWrite.filter(([p]) => !dirty.has(p)).map(([, f]) => f.h));
421
439
  for (const [p, f] of toWrite) {
@@ -438,6 +456,124 @@ export async function sync(root: string, c: Checkout, toBranch?: string): Promis
438
456
  return res;
439
457
  }
440
458
 
459
+ /** Refuse what would move this checkout's base while commits wait to be pushed. */
460
+ export function needNoPending(c: Checkout): void {
461
+ const n = c.pending?.commits.length ?? 0;
462
+ if (n) throw new APIError(409, `${n} commit${n === 1 ? "" : "s"} not pushed yet: lizard git push first (the server puts them on top of others' commits), or lizard git uncommit`);
463
+ }
464
+
465
+ function objectPath(root: string, h: string): string {
466
+ return join(root, STATE_DIR, "objects", h.slice(0, 2), h);
467
+ }
468
+
469
+ export interface LocalCommitResult {
470
+ branch: string;
471
+ files: number;
472
+ pending: number; // commits waiting to be pushed, this one included
473
+ }
474
+
475
+ /** 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 {
477
+ const local = scanWorking(root, c);
478
+ const ch = changesOf(c, local);
479
+ const pick = (p: string) => !paths.length || paths.some((x) => p === x || p.startsWith(x.replace(/\/+$/, "") + "/"));
480
+ const changed = [...ch.added, ...ch.modified].filter(pick);
481
+ const deleted = ch.deleted.filter(pick);
482
+ if (!changed.length && !deleted.length) throw new APIError(400, "nothing to commit: no changes");
483
+ if (newBranch && c.pending?.commits.length) throw new APIError(409, "push the commits on this branch before starting another (lizard git push)");
484
+ // Keep the contents as they are now: later edits don't change a commit.
485
+ for (const p of changed) {
486
+ const f = local.get(p)!;
487
+ const dst = objectPath(root, f.h);
488
+ if (existsSync(dst)) continue;
489
+ const full = safeJoin(root, p);
490
+ const content = f.m === "120000" ? Buffer.from(readlinkSync(full)) : readFileSync(full);
491
+ if (sha256(content) !== f.h) throw new APIError(409, `${p} changed while committing; try again`);
492
+ mkdirSync(dirname(dst), { recursive: true });
493
+ writeFileSync(dst + ".tmp", content);
494
+ renameSync(dst + ".tmp", dst);
495
+ }
496
+ if (!c.pending) c.pending = { base: c.base, commits: [], orig: {}, ...(newBranch ? { newBranch: true, fromBranch: c.branch } : {}) };
497
+ for (const p of [...changed, ...deleted]) if (!(p in c.pending.orig)) c.pending.orig[p] = c.files[p] ?? null;
498
+ c.pending.commits.push({
499
+ message,
500
+ time: Math.floor(Date.now() / 1000),
501
+ files: [
502
+ ...changed.map((p) => ({ path: p, hash: local.get(p)!.h, mode: local.get(p)!.m })),
503
+ ...deleted.map((p) => ({ path: p, delete: true as const })),
504
+ ],
505
+ });
506
+ for (const p of changed) c.files[p] = { ...local.get(p)! };
507
+ for (const p of deleted) delete c.files[p];
508
+ if (newBranch) c.branch = newBranch;
509
+ saveCheckout(root, c);
510
+ return { branch: c.branch, files: changed.length + deleted.length, pending: c.pending.commits.length };
511
+ }
512
+
513
+ /** Turn the commits not pushed back into changes in the files (their messages are dropped). */
514
+ export function uncommit(root: string, c: Checkout): number {
515
+ const n = c.pending?.commits.length ?? 0;
516
+ if (!c.pending) return 0;
517
+ for (const [p, f] of Object.entries(c.pending.orig)) {
518
+ if (f) c.files[p] = f;
519
+ else delete c.files[p];
520
+ }
521
+ // A branch that never reached the server: back on the one it started from.
522
+ if (c.pending.newBranch && c.pending.fromBranch) c.branch = c.pending.fromBranch;
523
+ c.pending = undefined;
524
+ rmSync(join(root, STATE_DIR, "objects"), { recursive: true, force: true });
525
+ saveCheckout(root, c);
526
+ return n;
527
+ }
528
+
529
+ export interface PushResult {
530
+ shas: string[];
531
+ sha: string;
532
+ branch: string;
533
+ uploaded: number;
534
+ sync: SyncResult;
535
+ }
536
+
537
+ /** Send the commits not pushed: one branch move; then bring in others' commits if there were any. */
538
+ export async function push(root: string, c: Checkout): Promise<PushResult> {
539
+ const pend = c.pending;
540
+ if (!pend?.commits.length) throw new APIError(400, "nothing to push: no commits waiting (lizard git commit -m \"...\")");
541
+ const hashes = [...new Set(pend.commits.flatMap((x) => x.files.flatMap((f) => ("hash" in f ? [f.hash] : []))))];
542
+ const missing = new Set<string>();
543
+ for (let i = 0; i < hashes.length; i += 5000) {
544
+ const r = await lg.post<{ missing: string[] }>(`${repoPath(c.repo)}/blobs/missing`, { hashes: hashes.slice(i, i + 5000) });
545
+ for (const h of r.missing) missing.add(h);
546
+ }
547
+ const list = [...missing];
548
+ let next = 0;
549
+ await Promise.all(Array.from({ length: Math.min(6, list.length) }, async () => {
550
+ while (next < list.length) {
551
+ const h = list[next++];
552
+ const content = readFileSync(objectPath(root, h));
553
+ if (sha256(content) !== h) throw new APIError(409, `.lizard-git/objects/${h.slice(0, 12)} is damaged`);
554
+ await sendRaw("PUT", `${repoPath(c.repo)}/blobs/${h}`, content, { "Content-Type": "application/octet-stream" });
555
+ cachePut("b", h, content);
556
+ }
557
+ }));
558
+ const body: Record<string, unknown> = { branch: c.branch, commits: pend.commits };
559
+ if (pend.newBranch && pend.base) body.createFrom = pend.base;
560
+ 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);
562
+ const from = pend.base;
563
+ c.pending = undefined;
564
+ rmSync(join(root, STATE_DIR, "objects"), { recursive: true, force: true });
565
+ // Nobody else committed meanwhile: the files here are exactly the last commit's.
566
+ if (r.root && (r.parent ?? "") === from) {
567
+ c.base = r.sha;
568
+ c.root = r.root;
569
+ saveCheckout(root, c);
570
+ return { shas: r.shas, sha: r.sha, branch: c.branch, uploaded: missing.size, sync: { from, to: r.sha, updated: [], removed: [] } };
571
+ }
572
+ saveCheckout(root, c);
573
+ const s = await sync(root, c);
574
+ return { shas: r.shas, sha: r.sha, branch: c.branch, uploaded: missing.size, sync: s };
575
+ }
576
+
441
577
  export interface CommitResult {
442
578
  sha: string;
443
579
  branch: string;
@@ -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.72";
8
+ export const CURRENT_VERSION = "4.0.74";
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