@lizard-build/cli 4.0.67 → 4.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * A Lizard Git working copy (after Scion: lizard.build/git keeps no repositories as files and
3
+ * makes every commit on the server).
4
+ *
5
+ * A checkout is files plus .lizard-git/checkout.json (named like .git: a repository's own
6
+ * .lizard/ folder holds its CI workflows): which repository and branch, the changeset
7
+ * the files came from (base), and each file's hash. Nothing else is local: no history, no
8
+ * object database. Contents come from the server by hash, through a cache shared by every
9
+ * checkout on this machine (~/.cache/lizard/git; immutable, so it never needs invalidating),
10
+ * and a sparse checkout only ever fetches the folders it covers.
11
+ *
12
+ * `commit` sends what changed: the server is asked which contents it lacks, those are
13
+ * uploaded, then one request makes the commit on the branch. If the branch moved since base,
14
+ * the server places the change on the new tip (merging the same files line by line); only a
15
+ * real conflict comes back.
16
+ */
17
+ export declare const STATE_DIR = ".lizard-git";
18
+ export interface FileState {
19
+ h: string;
20
+ m: string;
21
+ s?: number;
22
+ t?: number;
23
+ }
24
+ export interface Checkout {
25
+ version: 1;
26
+ url: string;
27
+ repo: string;
28
+ branch: string;
29
+ base: string;
30
+ root: string;
31
+ sparse: string[] | null;
32
+ files: Record<string, FileState>;
33
+ }
34
+ interface WireEntry {
35
+ name: string;
36
+ mode: string;
37
+ type: "tree" | "blob" | "commit";
38
+ hash: string;
39
+ size: number;
40
+ }
41
+ /** The checkout containing dir (walking up), or null. */
42
+ export declare function findCheckout(dir?: string): string | null;
43
+ export declare function loadCheckout(root: string): Checkout;
44
+ export declare function saveCheckout(root: string, c: Checkout): void;
45
+ export declare const sha256: (b: Buffer | string) => string;
46
+ /** Trees by hash (cached forever: a tree never changes). */
47
+ export declare function getTrees(repo: string, hashes: string[]): Promise<Map<string, WireEntry[]>>;
48
+ /** File contents by hash: small ones batched, big ones one by one, all cached. */
49
+ export declare function getBlobs(repo: string, hashes: string[], onProgress?: (done: number) => void): Promise<Map<string, Buffer>>;
50
+ /**
51
+ * Every file under root (within sparse), walked one level per request: the client never says
52
+ * what it has, it asks for the trees it walks into (Scion's negotiation).
53
+ */
54
+ export declare function listFiles(repo: string, root: string, sparse: string[] | null): Promise<Map<string, {
55
+ h: string;
56
+ m: string;
57
+ size: number;
58
+ }>>;
59
+ export declare function removeFileAt(root: string, p: string): void;
60
+ export interface LocalFile {
61
+ h: string;
62
+ m: string;
63
+ s: number;
64
+ t: number;
65
+ }
66
+ /** The working files within the sparse set, hashed (unchanged ones skipped by size and mtime). */
67
+ export declare function scanWorking(root: string, c: Checkout): Map<string, LocalFile>;
68
+ export interface Changes {
69
+ added: string[];
70
+ modified: string[];
71
+ deleted: string[];
72
+ }
73
+ export declare function changesOf(c: Checkout, local: Map<string, LocalFile>): Changes;
74
+ /** Make an existing folder the checkout of an empty repository: its files become the first commit. */
75
+ export declare function initHere(repo: string, dir: string): Promise<Checkout>;
76
+ /** Point a fresh checkout at a branch's tip and write its files. */
77
+ export declare function clone(repo: string, dir: string, branch: string | undefined, sparse: string[] | null, onProgress?: (msg: string) => void): Promise<Checkout>;
78
+ export interface SyncResult {
79
+ from: string;
80
+ to: string;
81
+ updated: string[];
82
+ removed: string[];
83
+ }
84
+ /**
85
+ * Bring the files to the branch's tip (or another branch: switching). Local changes are kept;
86
+ * if the tip changed the same files, nothing is touched and the conflict comes back: commit
87
+ * first, and the server merges.
88
+ */
89
+ export declare function sync(root: string, c: Checkout, toBranch?: string): Promise<SyncResult>;
90
+ export interface CommitResult {
91
+ sha: string;
92
+ branch: string;
93
+ uploaded: number;
94
+ files: number;
95
+ sync: SyncResult;
96
+ }
97
+ /**
98
+ * Commit the changed files (all, or those under paths) on the server, then sync to the result:
99
+ * if the branch had moved, the server put the change on top and the new tip has both.
100
+ */
101
+ export declare function commit(root: string, c: Checkout, message: string, paths: string[], newBranch?: string): Promise<CommitResult>;
102
+ /** Line diff (Myers), for `lizard git diff`. */
103
+ export declare function unifiedDiff(a: string, b: string, path: string, context?: number): string;
104
+ export {};
@@ -0,0 +1,545 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, relative, sep } from "node:path";
5
+ import { APIError } from "./api.js";
6
+ import { gitURL, lg, repoPath, sendRaw } from "./lizard-git.js";
7
+ /**
8
+ * A Lizard Git working copy (after Scion: lizard.build/git keeps no repositories as files and
9
+ * makes every commit on the server).
10
+ *
11
+ * A checkout is files plus .lizard-git/checkout.json (named like .git: a repository's own
12
+ * .lizard/ folder holds its CI workflows): which repository and branch, the changeset
13
+ * the files came from (base), and each file's hash. Nothing else is local: no history, no
14
+ * object database. Contents come from the server by hash, through a cache shared by every
15
+ * checkout on this machine (~/.cache/lizard/git; immutable, so it never needs invalidating),
16
+ * and a sparse checkout only ever fetches the folders it covers.
17
+ *
18
+ * `commit` sends what changed: the server is asked which contents it lacks, those are
19
+ * uploaded, then one request makes the commit on the branch. If the branch moved since base,
20
+ * the server places the change on the new tip (merging the same files line by line); only a
21
+ * real conflict comes back.
22
+ */
23
+ export const STATE_DIR = ".lizard-git";
24
+ const STATE_FILE = "checkout.json";
25
+ // ── state ──────────────────────────────────────────────────────────────
26
+ /** The checkout containing dir (walking up), or null. */
27
+ export function findCheckout(dir = process.cwd()) {
28
+ for (let d = dir;; d = dirname(d)) {
29
+ if (existsSync(join(d, STATE_DIR, STATE_FILE)))
30
+ return d;
31
+ if (dirname(d) === d)
32
+ return null;
33
+ }
34
+ }
35
+ export function loadCheckout(root) {
36
+ const c = JSON.parse(readFileSync(join(root, STATE_DIR, STATE_FILE), "utf8"));
37
+ if (c.version !== 1 || !c.repo || !c.branch)
38
+ throw new APIError(400, `${root}: not a Lizard Git checkout`);
39
+ return c;
40
+ }
41
+ export function saveCheckout(root, c) {
42
+ mkdirSync(join(root, STATE_DIR), { recursive: true });
43
+ const p = join(root, STATE_DIR, STATE_FILE);
44
+ writeFileSync(p + ".tmp", JSON.stringify(c));
45
+ renameSync(p + ".tmp", p);
46
+ }
47
+ export const sha256 = (b) => createHash("sha256").update(b).digest("hex");
48
+ // ── objects, through the machine cache ──────────────────────────────────
49
+ function cacheDir() {
50
+ return process.env.LIZARD_GIT_CACHE || join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "lizard", "git");
51
+ }
52
+ function cachePath(kind, h) {
53
+ return join(cacheDir(), kind, h.slice(0, 2), h);
54
+ }
55
+ function cacheGet(kind, h) {
56
+ try {
57
+ const b = readFileSync(cachePath(kind, h));
58
+ if (kind === "b" && sha256(b) !== h)
59
+ return null; // damaged: fetch again
60
+ return b;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function cachePut(kind, h, b) {
67
+ try {
68
+ const p = cachePath(kind, h);
69
+ mkdirSync(dirname(p), { recursive: true });
70
+ const tmp = `${p}.${process.pid}.tmp`;
71
+ writeFileSync(tmp, b);
72
+ renameSync(tmp, p);
73
+ }
74
+ catch {
75
+ // the cache is an optimisation; a read-only home just means no cache
76
+ }
77
+ }
78
+ const CHUNK = 2000;
79
+ /** Trees by hash (cached forever: a tree never changes). */
80
+ export async function getTrees(repo, hashes) {
81
+ const out = new Map();
82
+ const need = [];
83
+ for (const h of new Set(hashes)) {
84
+ const c = cacheGet("t", h);
85
+ if (c)
86
+ out.set(h, JSON.parse(c.toString()));
87
+ else
88
+ need.push(h);
89
+ }
90
+ for (let i = 0; i < need.length; i += CHUNK) {
91
+ const r = await lg.post(`${repoPath(repo)}/objects`, { trees: need.slice(i, i + CHUNK), blobs: [] });
92
+ for (const [h, entries] of Object.entries(r.trees)) {
93
+ out.set(h, entries);
94
+ cachePut("t", h, Buffer.from(JSON.stringify(entries)));
95
+ }
96
+ }
97
+ for (const h of need)
98
+ if (!out.has(h))
99
+ throw new APIError(404, `tree ${h.slice(0, 12)} is not in ${repo}`);
100
+ return out;
101
+ }
102
+ /** File contents by hash: small ones batched, big ones one by one, all cached. */
103
+ export async function getBlobs(repo, hashes, onProgress) {
104
+ const out = new Map();
105
+ const need = [];
106
+ for (const h of new Set(hashes)) {
107
+ const c = cacheGet("b", h);
108
+ if (c)
109
+ out.set(h, c);
110
+ else
111
+ need.push(h);
112
+ }
113
+ const single = [];
114
+ for (let i = 0; i < need.length; i += 500) {
115
+ const r = await lg.post(`${repoPath(repo)}/objects`, { trees: [], blobs: need.slice(i, i + 500) });
116
+ for (const [h, b64] of Object.entries(r.blobs)) {
117
+ const b = Buffer.from(b64, "base64");
118
+ if (sha256(b) !== h)
119
+ throw new APIError(502, `content ${h.slice(0, 12)} does not match its hash`);
120
+ out.set(h, b);
121
+ cachePut("b", h, b);
122
+ }
123
+ single.push(...(r.fetchSeparately ?? []));
124
+ onProgress?.(out.size);
125
+ }
126
+ // Big files: a few at a time.
127
+ let next = 0;
128
+ await Promise.all(Array.from({ length: Math.min(6, single.length) }, async () => {
129
+ while (next < single.length) {
130
+ const h = single[next++];
131
+ const b = Buffer.from(await (await sendRaw("GET", `${repoPath(repo)}/objects/${h}`)).arrayBuffer());
132
+ if (sha256(b) !== h)
133
+ throw new APIError(502, `content ${h.slice(0, 12)} does not match its hash`);
134
+ out.set(h, b);
135
+ cachePut("b", h, b);
136
+ onProgress?.(out.size);
137
+ }
138
+ }));
139
+ for (const h of need)
140
+ if (!out.has(h))
141
+ throw new APIError(404, `content ${h.slice(0, 12)} is not in ${repo}`);
142
+ return out;
143
+ }
144
+ // ── trees → file lists ─────────────────────────────────────────────────
145
+ /** Is path inside the sparse set (or a folder on the way to one)? */
146
+ function inSparse(p, sparse, orAncestor = false) {
147
+ if (!sparse)
148
+ return true;
149
+ return sparse.some((s) => p === s || p.startsWith(s + "/") || (orAncestor && s.startsWith(p + "/")));
150
+ }
151
+ /**
152
+ * Every file under root (within sparse), walked one level per request: the client never says
153
+ * what it has, it asks for the trees it walks into (Scion's negotiation).
154
+ */
155
+ export async function listFiles(repo, root, sparse) {
156
+ const files = new Map();
157
+ let level = [{ hash: root, prefix: "" }];
158
+ while (level.length) {
159
+ const trees = await getTrees(repo, level.map((l) => l.hash));
160
+ const next = [];
161
+ for (const { hash, prefix } of level) {
162
+ for (const e of trees.get(hash)) {
163
+ const p = prefix + e.name;
164
+ if (e.type === "tree") {
165
+ if (inSparse(p, sparse, true))
166
+ next.push({ hash: e.hash, prefix: p + "/" });
167
+ }
168
+ else if (e.type === "blob" && inSparse(p, sparse)) {
169
+ files.set(p, { h: e.hash, m: e.mode, size: e.size });
170
+ }
171
+ }
172
+ }
173
+ level = next;
174
+ }
175
+ return files;
176
+ }
177
+ // ── working files ──────────────────────────────────────────────────────
178
+ function safeJoin(root, p) {
179
+ if (p.split("/").some((x) => x === "" || x === "." || x === ".." || x === STATE_DIR) || p.includes("\\") || p.includes("\0")) {
180
+ throw new APIError(400, `refusing a path outside the checkout: ${p}`);
181
+ }
182
+ return join(root, ...p.split("/"));
183
+ }
184
+ function writeFile(root, p, content, mode) {
185
+ const full = safeJoin(root, p);
186
+ mkdirSync(dirname(full), { recursive: true });
187
+ try {
188
+ const st = lstatSync(full);
189
+ if (st.isDirectory())
190
+ rmSync(full, { recursive: true });
191
+ else
192
+ unlinkSync(full);
193
+ }
194
+ catch { }
195
+ if (mode === "120000") {
196
+ symlinkSync(content.toString(), full);
197
+ }
198
+ else {
199
+ writeFileSync(full, content);
200
+ chmodSync(full, mode === "100755" ? 0o755 : 0o644);
201
+ }
202
+ const st = lstatSync(full);
203
+ return { h: sha256(content), m: mode, s: st.size, t: Math.floor(st.mtimeMs) };
204
+ }
205
+ export function removeFileAt(root, p) {
206
+ removeFile(root, p);
207
+ }
208
+ function removeFile(root, p) {
209
+ const full = safeJoin(root, p);
210
+ try {
211
+ unlinkSync(full);
212
+ }
213
+ catch { }
214
+ // Empty folders go too (as on the server).
215
+ for (let d = dirname(full); d !== root && d.startsWith(root + sep); d = dirname(d)) {
216
+ try {
217
+ if (readdirSync(d).length)
218
+ break;
219
+ rmSync(d, { recursive: true });
220
+ }
221
+ catch {
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ /** Ignore rules: .lizard/, .git/, node_modules/ and the simple patterns of .gitignore files. */
227
+ function ignorer(root) {
228
+ const rules = [];
229
+ const add = (pat, base) => {
230
+ let neg = false;
231
+ if (pat.startsWith("!")) {
232
+ neg = true;
233
+ pat = pat.slice(1);
234
+ }
235
+ const dirOnly = pat.endsWith("/");
236
+ pat = pat.replace(/\/+$/, "");
237
+ if (!pat)
238
+ return;
239
+ const anchored = pat.includes("/");
240
+ const esc = pat.replace(/^\//, "").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\0/g, ".*");
241
+ const prefix = base ? base.replace(/[.+^${}()|[\]\\]/g, "\\$&") + "/" : "";
242
+ rules.push({ re: new RegExp(anchored ? `^${prefix}${esc}$` : `^${prefix}(?:.*/)?${esc}$`), dirOnly, neg });
243
+ };
244
+ const load = (dir, base) => {
245
+ try {
246
+ for (const line of readFileSync(join(dir, ".gitignore"), "utf8").split("\n")) {
247
+ const l = line.trim();
248
+ if (l && !l.startsWith("#"))
249
+ add(l, base);
250
+ }
251
+ }
252
+ catch { }
253
+ };
254
+ load(root, "");
255
+ const loaded = new Set([""]);
256
+ return (rel, isDir) => {
257
+ const name = rel.split("/").pop();
258
+ if (name === STATE_DIR || name === ".git")
259
+ return true;
260
+ if (isDir && name === "node_modules")
261
+ return true;
262
+ const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
263
+ if (!loaded.has(parent)) {
264
+ loaded.add(parent);
265
+ load(join(root, ...parent.split("/")), parent);
266
+ }
267
+ let ignored = false;
268
+ for (const r of rules)
269
+ if ((!r.dirOnly || isDir) && r.re.test(rel))
270
+ ignored = !r.neg;
271
+ return ignored;
272
+ };
273
+ }
274
+ /** The working files within the sparse set, hashed (unchanged ones skipped by size and mtime). */
275
+ export function scanWorking(root, c) {
276
+ const ignore = ignorer(root);
277
+ const out = new Map();
278
+ const walk = (dir) => {
279
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
280
+ const full = join(dir, ent.name);
281
+ const rel = relative(root, full).split(sep).join("/");
282
+ if (ent.isDirectory()) {
283
+ if (!ignore(rel, true) && inSparse(rel, c.sparse, true))
284
+ walk(full);
285
+ continue;
286
+ }
287
+ if (ignore(rel, false) || !inSparse(rel, c.sparse))
288
+ continue;
289
+ const st = lstatSync(full);
290
+ let mode;
291
+ if (st.isSymbolicLink())
292
+ mode = "120000";
293
+ else if (st.isFile())
294
+ mode = st.mode & 0o111 ? "100755" : "100644";
295
+ else
296
+ continue;
297
+ const known = c.files[rel];
298
+ const t = Math.floor(st.mtimeMs);
299
+ if (known && known.s === st.size && known.t === t && known.m === mode) {
300
+ out.set(rel, { h: known.h, m: mode, s: st.size, t });
301
+ continue;
302
+ }
303
+ const content = mode === "120000" ? Buffer.from(readlinkSync(full)) : readFileSync(full);
304
+ out.set(rel, { h: sha256(content), m: mode, s: st.size, t });
305
+ }
306
+ };
307
+ walk(root);
308
+ return out;
309
+ }
310
+ export function changesOf(c, local) {
311
+ const ch = { added: [], modified: [], deleted: [] };
312
+ for (const [p, f] of local) {
313
+ const known = c.files[p];
314
+ if (!known)
315
+ ch.added.push(p);
316
+ else if (known.h !== f.h || known.m !== f.m)
317
+ ch.modified.push(p);
318
+ }
319
+ for (const p of Object.keys(c.files))
320
+ if (!local.has(p))
321
+ ch.deleted.push(p);
322
+ for (const l of [ch.added, ch.modified, ch.deleted])
323
+ l.sort();
324
+ return ch;
325
+ }
326
+ // ── operations ─────────────────────────────────────────────────────────
327
+ /** Make an existing folder the checkout of an empty repository: its files become the first commit. */
328
+ export async function initHere(repo, dir) {
329
+ if (findCheckout(dir))
330
+ throw new APIError(400, `${dir} is in a checkout already`);
331
+ const info = await lg.get(repoPath(repo));
332
+ if (!info.empty)
333
+ throw new APIError(409, `${repo} has commits already: check it out with lizard git clone ${repo}`);
334
+ const c = { version: 1, url: gitURL(), repo, branch: info.repo.defaultBranch, base: "", root: "", sparse: null, files: {} };
335
+ saveCheckout(dir, c);
336
+ return c;
337
+ }
338
+ /** Point a fresh checkout at a branch's tip and write its files. */
339
+ export async function clone(repo, dir, branch, sparse, onProgress) {
340
+ if (existsSync(dir) && readdirSync(dir).length)
341
+ throw new APIError(400, `${dir} exists and is not empty`);
342
+ const info = await lg.get(repoPath(repo));
343
+ mkdirSync(dir, { recursive: true });
344
+ if (info.empty) {
345
+ // Nothing to fetch: the first commit from here creates the branch.
346
+ const c = { version: 1, url: gitURL(), repo, branch: branch ?? info.repo.defaultBranch, base: "", root: "", sparse, files: {} };
347
+ saveCheckout(dir, c);
348
+ return c;
349
+ }
350
+ const r = await lg.get(`${repoPath(repo)}/resolve${branch ? `?rev=${encodeURIComponent(branch)}` : ""}`);
351
+ const c = { version: 1, url: gitURL(), repo, branch: branch ?? info.repo.defaultBranch, base: r.id, root: r.root, sparse, files: {} };
352
+ onProgress?.("listing files");
353
+ const files = await listFiles(repo, r.root, sparse);
354
+ onProgress?.(`fetching ${files.size} files`);
355
+ const blobs = await getBlobs(repo, [...files.values()].map((f) => f.h), (n) => onProgress?.(`fetched ${n}/${files.size}`));
356
+ for (const [p, f] of files)
357
+ c.files[p] = writeFile(dir, p, blobs.get(f.h), f.m);
358
+ saveCheckout(dir, c);
359
+ return c;
360
+ }
361
+ /**
362
+ * Bring the files to the branch's tip (or another branch: switching). Local changes are kept;
363
+ * if the tip changed the same files, nothing is touched and the conflict comes back: commit
364
+ * first, and the server merges.
365
+ */
366
+ export async function sync(root, c, toBranch) {
367
+ const branch = toBranch ?? c.branch;
368
+ const r = await lg.get(`${repoPath(c.repo)}/resolve?rev=${encodeURIComponent(branch)}`);
369
+ const res = { from: c.base, to: r.id, updated: [], removed: [] };
370
+ if (r.id === c.base && branch === c.branch)
371
+ return res;
372
+ const want = await listFiles(c.repo, r.root, c.sparse);
373
+ const local = scanWorking(root, c);
374
+ const mine = changesOf(c, local);
375
+ const dirty = new Set([...mine.added, ...mine.modified, ...mine.deleted]);
376
+ const toWrite = [];
377
+ const toRemove = [];
378
+ for (const [p, f] of want) {
379
+ const k = c.files[p];
380
+ if (!k || k.h !== f.h || k.m !== f.m)
381
+ toWrite.push([p, f]);
382
+ }
383
+ for (const p of Object.keys(c.files))
384
+ if (!want.has(p))
385
+ toRemove.push(p);
386
+ const clash = [...toWrite.map(([p]) => p), ...toRemove].filter((p) => {
387
+ if (!dirty.has(p))
388
+ return false;
389
+ const l = local.get(p);
390
+ const w = want.get(p);
391
+ return !(l && w && l.h === w.h && l.m === w.m); // the same change on both sides is fine
392
+ });
393
+ if (clash.length) {
394
+ 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.`);
395
+ }
396
+ const blobs = await getBlobs(c.repo, toWrite.filter(([p]) => !dirty.has(p)).map(([, f]) => f.h));
397
+ for (const [p, f] of toWrite) {
398
+ if (dirty.has(p)) {
399
+ c.files[p] = { ...local.get(p), h: f.h, m: f.m }; // already identical locally
400
+ continue;
401
+ }
402
+ c.files[p] = writeFile(root, p, blobs.get(f.h), f.m);
403
+ res.updated.push(p);
404
+ }
405
+ for (const p of toRemove) {
406
+ if (!dirty.has(p))
407
+ removeFile(root, p);
408
+ delete c.files[p];
409
+ res.removed.push(p);
410
+ }
411
+ c.base = r.id;
412
+ c.root = r.root;
413
+ c.branch = branch;
414
+ saveCheckout(root, c);
415
+ return res;
416
+ }
417
+ /**
418
+ * Commit the changed files (all, or those under paths) on the server, then sync to the result:
419
+ * if the branch had moved, the server put the change on top and the new tip has both.
420
+ */
421
+ export async function commit(root, c, message, paths, newBranch) {
422
+ const local = scanWorking(root, c);
423
+ const ch = changesOf(c, local);
424
+ const pick = (p) => !paths.length || paths.some((x) => p === x || p.startsWith(x.replace(/\/+$/, "") + "/"));
425
+ const changed = [...ch.added, ...ch.modified].filter(pick);
426
+ const deleted = ch.deleted.filter(pick);
427
+ if (!changed.length && !deleted.length)
428
+ throw new APIError(400, "nothing to commit: no changes");
429
+ // Upload only the contents the server doesn't have.
430
+ const hashes = [...new Set(changed.map((p) => local.get(p).h))];
431
+ const missing = new Set();
432
+ for (let i = 0; i < hashes.length; i += 5000) {
433
+ const r = await lg.post(`${repoPath(c.repo)}/blobs/missing`, { hashes: hashes.slice(i, i + 5000) });
434
+ for (const h of r.missing)
435
+ missing.add(h);
436
+ }
437
+ const byHash = new Map(changed.map((p) => [local.get(p).h, p]));
438
+ let next = 0;
439
+ const list = [...missing];
440
+ await Promise.all(Array.from({ length: Math.min(6, list.length) }, async () => {
441
+ while (next < list.length) {
442
+ const h = list[next++];
443
+ const p = byHash.get(h);
444
+ const full = safeJoin(root, p);
445
+ const content = local.get(p).m === "120000" ? Buffer.from(readlinkSync(full)) : readFileSync(full);
446
+ if (sha256(content) !== h)
447
+ throw new APIError(409, `${p} changed while committing; try again`);
448
+ await sendRaw("PUT", `${repoPath(c.repo)}/blobs/${h}`, content, { "Content-Type": "application/octet-stream" });
449
+ cachePut("b", h, content);
450
+ }
451
+ }));
452
+ const files = [
453
+ ...changed.map((p) => ({ path: p, hash: local.get(p).h, mode: local.get(p).m })),
454
+ ...deleted.map((p) => ({ path: p, delete: true })),
455
+ ];
456
+ const branch = newBranch ?? c.branch;
457
+ const body = { branch, message, files };
458
+ if (newBranch)
459
+ body.createFrom = c.base;
460
+ else if (c.base)
461
+ body.expect = c.base;
462
+ const r = await lg.post(`${repoPath(c.repo)}/commits`, body);
463
+ // What was sent is now the base for those files.
464
+ for (const p of changed)
465
+ c.files[p] = { ...local.get(p) };
466
+ for (const p of deleted)
467
+ delete c.files[p];
468
+ saveCheckout(root, c);
469
+ const s = await sync(root, c, branch);
470
+ return { sha: r.sha, branch, uploaded: missing.size, files: files.length, sync: s };
471
+ }
472
+ /** Line diff (Myers), for `lizard git diff`. */
473
+ export function unifiedDiff(a, b, path, context = 3) {
474
+ const A = a.split("\n");
475
+ const B = b.split("\n");
476
+ const n = A.length;
477
+ const m = B.length;
478
+ const max = n + m;
479
+ const v = new Array(2 * max + 2).fill(0);
480
+ const trace = [];
481
+ outer: for (let d = 0; d <= max; d++) {
482
+ trace.push(v.slice());
483
+ for (let k = -d; k <= d; k += 2) {
484
+ let x = k === -d || (k !== d && v[max + k - 1] < v[max + k + 1]) ? v[max + k + 1] : v[max + k - 1] + 1;
485
+ let y = x - k;
486
+ while (x < n && y < m && A[x] === B[y]) {
487
+ x++;
488
+ y++;
489
+ }
490
+ v[max + k] = x;
491
+ if (x >= n && y >= m)
492
+ break outer;
493
+ }
494
+ }
495
+ const ops = [];
496
+ let x = n;
497
+ let y = m;
498
+ for (let d = trace.length - 1; d >= 0; d--) {
499
+ const vd = trace[d];
500
+ const k = x - y;
501
+ const pk = k === -d || (k !== d && vd[max + k - 1] < vd[max + k + 1]) ? k + 1 : k - 1;
502
+ const px = vd[max + pk];
503
+ const py = px - pk;
504
+ while (x > px && y > py)
505
+ ops.unshift({ k: " ", t: A[--x], a: x, b: --y });
506
+ if (d > 0) {
507
+ if (x === px)
508
+ ops.unshift({ k: "+", t: B[--y], a: x, b: y });
509
+ else
510
+ ops.unshift({ k: "-", t: A[--x], a: x, b: y });
511
+ }
512
+ }
513
+ const out = [`--- a/${path}`, `+++ b/${path}`];
514
+ for (let i = 0; i < ops.length;) {
515
+ if (ops[i].k === " ") {
516
+ i++;
517
+ continue;
518
+ }
519
+ const start = Math.max(0, i - context);
520
+ let end = i;
521
+ while (end < ops.length) {
522
+ if (ops[end].k !== " ") {
523
+ end++;
524
+ continue;
525
+ }
526
+ let run = 0;
527
+ while (end + run < ops.length && ops[end + run].k === " ")
528
+ run++;
529
+ if (end + run >= ops.length || run > 2 * context) {
530
+ end += Math.min(run, context);
531
+ break;
532
+ }
533
+ end += run;
534
+ }
535
+ const h = ops.slice(start, end);
536
+ const oa = h.filter((o) => o.k !== "+").length;
537
+ const nb = h.filter((o) => o.k !== "-").length;
538
+ out.push(`@@ -${h[0].a + 1},${oa} +${h[0].b + 1},${nb} @@`);
539
+ for (const o of h)
540
+ out.push(o.k + o.t);
541
+ i = end;
542
+ }
543
+ return out.join("\n");
544
+ }
545
+ //# sourceMappingURL=checkout.js.map