@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.
- 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/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/package.json +1 -1
- 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/lizard-git.test.ts +63 -22
package/src/commands/git.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { api, getBaseURL, streamSSE, withScope } from "../lib/api.js";
|
|
|
7
7
|
import { openURL } from "../lib/auth.js";
|
|
8
8
|
import { resolveProjectScope, resolveService } from "../lib/resolve.js";
|
|
9
9
|
import { success, error, info, fail, isJSONMode, printJSON, isTTY, link } from "../lib/format.js";
|
|
10
|
-
import { registerLizardGit } from "./git-hosting.js";
|
|
10
|
+
import { registerLizardGit, checkoutStatus } from "./git-hosting.js";
|
|
11
11
|
|
|
12
12
|
interface GitHubInstallation {
|
|
13
13
|
id: number;
|
|
@@ -171,9 +171,10 @@ export function registerGit(program: Command) {
|
|
|
171
171
|
// lizard git status
|
|
172
172
|
git
|
|
173
173
|
.command("status")
|
|
174
|
-
.description("
|
|
174
|
+
.description("In a Lizard Git checkout: your changes. Elsewhere: GitHub connection and the repositories this project's services deploy from")
|
|
175
175
|
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
176
176
|
.action(async (opts) => {
|
|
177
|
+
if (!opts.project && checkoutStatus()) return;
|
|
177
178
|
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
178
179
|
|
|
179
180
|
const [githubStatus, services] = await Promise.all([
|
package/src/index.ts
CHANGED
|
@@ -146,9 +146,8 @@ program
|
|
|
146
146
|
topLevel = topLevel.parent;
|
|
147
147
|
}
|
|
148
148
|
if (noAuth.has(topLevel.name())) return;
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
if (topLevel.name() === "git" && actionCommand.name() === "credential") return;
|
|
149
|
+
// Lizard Git with its own token (LIZARD_GIT_TOKEN: CI, agents): no Lizard sign-in needed.
|
|
150
|
+
if (topLevel.name() === "git" && process.env.LIZARD_GIT_TOKEN) return;
|
|
152
151
|
|
|
153
152
|
// Require auth — auto-triggers login flow if not logged in
|
|
154
153
|
const creds = await requireAuth();
|
|
@@ -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
|
+
}
|