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