@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
|
@@ -3,13 +3,15 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename } from "node:path";
|
|
5
5
|
import { APIError } from "../lib/api.js";
|
|
6
|
-
import { validToken } from "../lib/auth.js";
|
|
7
6
|
import { isJSONMode, printJSON, table, success, info, warn, link } from "../lib/format.js";
|
|
8
|
-
import { lg, qs, seg, segs, resolveRepo, repoPath, myLogin,
|
|
7
|
+
import { lg, qs, seg, segs, resolveRepo, repoPath, myLogin, gitURL, ago, ACTIVE_RUN, } from "../lib/lizard-git.js";
|
|
8
|
+
import * as wc from "../lib/checkout.js";
|
|
9
9
|
/**
|
|
10
10
|
* Lizard Git (lizard.build/git) from the terminal: repositories, issues, pull requests,
|
|
11
11
|
* reviews, CI runs, releases, secrets, collaborators, webhooks, branch rules, labels,
|
|
12
|
-
* notifications, tokens
|
|
12
|
+
* notifications, tokens; and working copies: clone, status, diff, commit, sync, switch. Lizard
|
|
13
|
+
* Git is Scion-style (no git protocol): a checkout holds files and .lizard-git/checkout.json, and
|
|
14
|
+
* every commit is made on the server (lib/checkout.ts).
|
|
13
15
|
*
|
|
14
16
|
* Registered under `lizard git`, next to the GitHub integration commands.
|
|
15
17
|
*/
|
|
@@ -57,70 +59,161 @@ function bodyFrom(opts) {
|
|
|
57
59
|
return opts.body;
|
|
58
60
|
}
|
|
59
61
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
62
|
+
/** The checkout this directory is in, or a clear error. */
|
|
63
|
+
function needCheckout() {
|
|
64
|
+
const root = wc.findCheckout();
|
|
65
|
+
if (!root)
|
|
66
|
+
throw new APIError(400, "not in a Lizard Git checkout (lizard git clone owner/name)");
|
|
67
|
+
return root;
|
|
68
|
+
}
|
|
69
|
+
/** `lizard git status` inside a checkout: your changes. */
|
|
70
|
+
export function checkoutStatus() {
|
|
71
|
+
const root = wc.findCheckout();
|
|
72
|
+
if (!root)
|
|
73
|
+
return false;
|
|
74
|
+
const c = wc.loadCheckout(root);
|
|
75
|
+
const ch = wc.changesOf(c, wc.scanWorking(root, c));
|
|
76
|
+
out({ repo: c.repo, branch: c.branch, commit: c.base, sparse: c.sparse, ...ch }, () => {
|
|
77
|
+
info(`${chalk.bold(c.repo)} on ${chalk.cyan(c.branch)} at ${c.base.slice(0, 12)}${c.sparse ? chalk.dim(` (sparse: ${c.sparse.join(", ")})`) : ""}`);
|
|
78
|
+
const n = ch.added.length + ch.modified.length + ch.deleted.length;
|
|
79
|
+
if (!n)
|
|
80
|
+
return info(chalk.dim("no changes"));
|
|
81
|
+
for (const p of ch.modified)
|
|
82
|
+
info(` ${chalk.yellow("modified")} ${p}`);
|
|
83
|
+
for (const p of ch.added)
|
|
84
|
+
info(` ${chalk.green("added")} ${p}`);
|
|
85
|
+
for (const p of ch.deleted)
|
|
86
|
+
info(` ${chalk.red("deleted")} ${p}`);
|
|
87
|
+
info(chalk.dim(` commit them: lizard git commit -m "..."`));
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
60
91
|
function repoOpt(c) {
|
|
61
|
-
return c.option("-R, --repo <owner/name>", "Repository (default: the
|
|
92
|
+
return c.option("-R, --repo <owner/name>", "Repository (default: the one this checkout is of)");
|
|
62
93
|
}
|
|
63
94
|
export function registerLizardGit(git) {
|
|
64
95
|
git.description("Lizard Git (lizard.build/git) repositories, pull requests, CI; and GitHub integration");
|
|
65
|
-
// ──
|
|
96
|
+
// ── Working copies ───────────────────────────────────────────────────
|
|
66
97
|
git
|
|
67
|
-
.command("
|
|
68
|
-
.description("
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
spawnSync("git", ["config", scope, `credential.${url}.username`, "lizard"]);
|
|
82
|
-
}
|
|
83
|
-
const who = await myLogin();
|
|
84
|
-
out({ helper: helperCommand(), scope: opts.local ? "local" : "global", host: gitURL(), login: who }, () => {
|
|
85
|
-
success(`git now signs in to ${gitURL()} as ${chalk.bold(who)} (${opts.local ? "this repository" : "every repository"})`);
|
|
86
|
-
info(chalk.dim(` Try: lizard git clone ${who}/<repo>`));
|
|
87
|
-
});
|
|
98
|
+
.command("clone")
|
|
99
|
+
.description("Check out a Lizard Git repository: only files, fetched on demand by hash (no history to download)")
|
|
100
|
+
.argument("<repo>", "owner/name, or name for one of yours")
|
|
101
|
+
.argument("[dir]", "Directory (default: the repository name)")
|
|
102
|
+
.option("-b, --branch <name>", "Branch (default: the repository's default branch)")
|
|
103
|
+
.option("--sparse <paths>", "Only these folders or files, comma-separated (a sparse checkout fetches nothing else)")
|
|
104
|
+
.action(async (repoArg, dir, opts) => {
|
|
105
|
+
const repo = await resolveRepo(repoArg);
|
|
106
|
+
const target = dir ?? repo.split("/")[1];
|
|
107
|
+
const sparse = list(opts.sparse)?.map((x) => x.replace(/^\/+|\/+$/g, "")) ?? null;
|
|
108
|
+
const c = await wc.clone(repo, target, opts.branch, sparse, (m) => !isJSONMode() && process.stderr.write(chalk.dim(` ${m}\r`)));
|
|
109
|
+
if (!isJSONMode())
|
|
110
|
+
process.stderr.write("\x1b[2K");
|
|
111
|
+
done({ repo, dir: target, branch: c.branch, commit: c.base, files: Object.keys(c.files).length, sparse }, `Checked out ${repo} (${c.branch} at ${c.base.slice(0, 12)}, ${Object.keys(c.files).length} files) into ${target}`);
|
|
88
112
|
});
|
|
89
113
|
git
|
|
90
|
-
.command("
|
|
91
|
-
.description("
|
|
92
|
-
.argument("
|
|
93
|
-
.action(async (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
.command("init")
|
|
115
|
+
.description("Make this folder the checkout of an empty repository (then lizard git commit)")
|
|
116
|
+
.argument("<repo>", "owner/name, or name for one of yours")
|
|
117
|
+
.action(async (repoArg) => {
|
|
118
|
+
const repo = await resolveRepo(repoArg);
|
|
119
|
+
const c = await wc.initHere(repo, process.cwd());
|
|
120
|
+
done({ repo, branch: c.branch }, `This folder is now ${repo} (${c.branch}); commit with lizard git commit -m "..."`);
|
|
121
|
+
});
|
|
122
|
+
git
|
|
123
|
+
.command("diff")
|
|
124
|
+
.description("Your changes in this checkout against the commit it's at")
|
|
125
|
+
.argument("[paths...]", "Only these files or folders")
|
|
126
|
+
.option("--name-only", "Only the changed paths")
|
|
127
|
+
.action(async (paths, opts) => {
|
|
128
|
+
const root = needCheckout();
|
|
129
|
+
const c = wc.loadCheckout(root);
|
|
130
|
+
const local = wc.scanWorking(root, c);
|
|
131
|
+
const ch = wc.changesOf(c, local);
|
|
132
|
+
const pick = (p) => !paths.length || paths.some((x) => p === x || p.startsWith(x.replace(/\/+$/, "") + "/"));
|
|
133
|
+
const all = [...ch.added.map((p) => ["added", p]), ...ch.modified.map((p) => ["modified", p]), ...ch.deleted.map((p) => ["deleted", p])].filter(([, p]) => pick(p));
|
|
134
|
+
if (isJSONMode() || opts.nameOnly) {
|
|
135
|
+
out(all.map(([status, path]) => ({ status, path })), () => all.forEach(([, p]) => console.log(p)));
|
|
104
136
|
return;
|
|
105
137
|
}
|
|
106
|
-
|
|
138
|
+
const before = await wc.getBlobs(c.repo, all.filter(([k]) => k !== "added").map(([, p]) => c.files[p].h));
|
|
139
|
+
for (const [kind, p] of all) {
|
|
140
|
+
const old = kind === "added" ? "" : before.get(c.files[p].h).toString();
|
|
141
|
+
const cur = kind === "deleted" ? "" : readFileSync(`${root}/${p}`, "utf8");
|
|
142
|
+
if (old.includes("\0") || cur.includes("\0")) {
|
|
143
|
+
console.log(chalk.bold(`Binary ${p} ${kind}`));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (old.length + cur.length > 4 << 20) {
|
|
147
|
+
console.log(chalk.bold(`${p}: ${kind} (too large to show)`));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const line of wc.unifiedDiff(old, cur, p).split("\n")) {
|
|
151
|
+
console.log(line.startsWith("+") && !line.startsWith("+++") ? chalk.green(line) : line.startsWith("-") && !line.startsWith("---") ? chalk.red(line) : line.startsWith("@@") ? chalk.cyan(line) : line);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
107
154
|
});
|
|
108
155
|
git
|
|
109
|
-
.command("
|
|
110
|
-
.description("
|
|
111
|
-
.argument("
|
|
112
|
-
.
|
|
113
|
-
.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
.command("commit")
|
|
157
|
+
.description("Commit your changes on the server (nothing to push): if the branch moved, the server puts them on top")
|
|
158
|
+
.argument("[paths...]", "Only these files or folders (default: every change)")
|
|
159
|
+
.requiredOption("-m, --message <text>", "Commit message")
|
|
160
|
+
.option("-b, --branch <name>", "Commit to a new branch, starting at this checkout's commit (and switch to it)")
|
|
161
|
+
.action(async (paths, opts) => {
|
|
162
|
+
const root = needCheckout();
|
|
163
|
+
const c = wc.loadCheckout(root);
|
|
164
|
+
const r = await wc.commit(root, c, opts.message.endsWith("\n") ? opts.message : opts.message + "\n", paths, opts.branch);
|
|
165
|
+
const extra = r.sync.updated.length + r.sync.removed.length;
|
|
166
|
+
done(r, `Committed ${r.files} file(s) to ${r.branch}: ${r.sha.slice(0, 12)}${r.uploaded ? chalk.dim(` (${r.uploaded} uploaded)`) : ""}${extra ? chalk.dim(` · ${extra} file(s) from others' commits updated`) : ""}`);
|
|
167
|
+
});
|
|
168
|
+
git
|
|
169
|
+
.command("sync")
|
|
170
|
+
.alias("pull")
|
|
171
|
+
.description("Bring this checkout to the branch's latest commit (keeps your changes)")
|
|
172
|
+
.action(async () => {
|
|
173
|
+
const root = needCheckout();
|
|
174
|
+
const c = wc.loadCheckout(root);
|
|
175
|
+
const r = await wc.sync(root, c);
|
|
176
|
+
done(r, r.from === r.to ? `Already at ${c.branch}'s latest (${r.to.slice(0, 12)})` :
|
|
177
|
+
`${c.branch}: ${r.from.slice(0, 12)} -> ${r.to.slice(0, 12)} (${r.updated.length} updated, ${r.removed.length} removed)`);
|
|
178
|
+
});
|
|
179
|
+
git
|
|
180
|
+
.command("switch")
|
|
181
|
+
.description("Switch this checkout to another branch (keeps your changes)")
|
|
182
|
+
.argument("<branch>")
|
|
183
|
+
.option("-c, --create", "Create the branch at this checkout's commit first")
|
|
184
|
+
.action(async (branch, opts) => {
|
|
185
|
+
const root = needCheckout();
|
|
186
|
+
const c = wc.loadCheckout(root);
|
|
187
|
+
if (opts.create)
|
|
188
|
+
await lg.post(`${repoPath(c.repo)}/branches`, { name: branch, from: c.base });
|
|
189
|
+
const r = await wc.sync(root, c, branch);
|
|
190
|
+
done({ ...r, branch }, `On ${branch} at ${r.to.slice(0, 12)} (${r.updated.length} updated, ${r.removed.length} removed)`);
|
|
191
|
+
});
|
|
192
|
+
git
|
|
193
|
+
.command("sparse")
|
|
194
|
+
.description("Change which folders this checkout has (comma-separated; \"all\" for everything)")
|
|
195
|
+
.argument("<paths>")
|
|
196
|
+
.action(async (paths) => {
|
|
197
|
+
const root = needCheckout();
|
|
198
|
+
const c = wc.loadCheckout(root);
|
|
199
|
+
const local = wc.scanWorking(root, c);
|
|
200
|
+
const ch = wc.changesOf(c, local);
|
|
201
|
+
if (ch.added.length + ch.modified.length + ch.deleted.length)
|
|
202
|
+
throw new APIError(409, "commit your changes first (lizard git commit)");
|
|
203
|
+
const next = paths === "all" ? null : list(paths).map((x) => x.replace(/^\/+|\/+$/g, ""));
|
|
204
|
+
// Drop what leaves the set, then sync with the new set (fetches what joins it).
|
|
205
|
+
const keep = (p) => !next || next.some((x) => p === x || p.startsWith(x + "/"));
|
|
206
|
+
for (const p of Object.keys(c.files)) {
|
|
207
|
+
if (!keep(p)) {
|
|
208
|
+
wc.removeFileAt(root, p);
|
|
209
|
+
delete c.files[p];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
c.sparse = next;
|
|
213
|
+
const before = c.base;
|
|
214
|
+
c.base = ""; // make sync list and fetch everything in the new set
|
|
215
|
+
const r = await wc.sync(root, c);
|
|
216
|
+
done({ sparse: next, files: Object.keys(c.files).length, commit: r.to }, `Sparse: ${next ? next.join(", ") : "everything"} (${Object.keys(c.files).length} files at ${(r.to || before).slice(0, 12)})`);
|
|
124
217
|
});
|
|
125
218
|
// ── Repositories ─────────────────────────────────────────────────────
|
|
126
219
|
const repo = git.command("repo").description("Lizard Git repositories");
|
|
@@ -150,16 +243,16 @@ export function registerLizardGit(git) {
|
|
|
150
243
|
.argument("<name>")
|
|
151
244
|
.option("--private", "Only you and collaborators can see it")
|
|
152
245
|
.option("-d, --description <text>")
|
|
153
|
-
.option("--readme", "Start with a README (otherwise empty
|
|
246
|
+
.option("--readme", "Start with a README (otherwise empty)")
|
|
154
247
|
.option("--default-branch <name>", "Default branch (main)")
|
|
155
248
|
.action(async (name, opts) => {
|
|
156
249
|
const r = await lg.post("/api/repos", {
|
|
157
250
|
name, private: !!opts.private, description: opts.description, readme: !!opts.readme, defaultBranch: opts.defaultBranch,
|
|
158
251
|
});
|
|
159
252
|
const full = `${r.owner}/${r.name}`;
|
|
160
|
-
out({ ...r, url: `${gitURL()}/${full}
|
|
253
|
+
out({ ...r, url: `${gitURL()}/${full}` }, () => {
|
|
161
254
|
success(`Created ${chalk.bold(full)} ${link(`${gitURL()}/${full}`)}`);
|
|
162
|
-
info(chalk.dim(`
|
|
255
|
+
info(chalk.dim(` Check out: lizard git clone ${full} then: lizard git commit -m "..."`));
|
|
163
256
|
});
|
|
164
257
|
});
|
|
165
258
|
repoOpt(repo.command("view").description("A repository's details").argument("[repo]"))
|
|
@@ -173,7 +266,7 @@ export function registerLizardGit(git) {
|
|
|
173
266
|
if (m.forkOf)
|
|
174
267
|
info(chalk.dim(`forked from ${m.forkOf}`));
|
|
175
268
|
info(`default branch ${m.repo.defaultBranch} · ${m.branches} branches · ${m.tags} tags · ${m.openIssues} open issues · ${m.openPulls} open pull requests · ${m.repo.stars} stars`);
|
|
176
|
-
info(`${link(`${gitURL()}/${r}`)}\
|
|
269
|
+
info(`${link(`${gitURL()}/${r}`)}\ncheck out: lizard git clone ${r}`);
|
|
177
270
|
});
|
|
178
271
|
});
|
|
179
272
|
repoOpt(repo.command("edit").description("Change name, description, visibility or default branch").argument("[repo]"))
|
|
@@ -181,13 +274,11 @@ export function registerLizardGit(git) {
|
|
|
181
274
|
.option("-d, --description <text>")
|
|
182
275
|
.option("--private").option("--public")
|
|
183
276
|
.option("--default-branch <name>")
|
|
184
|
-
.option("--api-commits", "Also accept commits written through the API by agents without a checkout")
|
|
185
|
-
.option("--no-api-commits", "Changes only by git push and merged pull requests (the default for new repositories)")
|
|
186
277
|
.action(async (arg, opts) => {
|
|
187
278
|
const r = await resolveRepo(arg ?? opts.repo);
|
|
188
279
|
await lg.patch(repoPath(r), {
|
|
189
280
|
name: opts.name, description: opts.description, defaultBranch: opts.defaultBranch,
|
|
190
|
-
private: opts.private ? true : opts.public ? false : undefined,
|
|
281
|
+
private: opts.private ? true : opts.public ? false : undefined,
|
|
191
282
|
});
|
|
192
283
|
done({ ok: true, repo: opts.name ? `${r.split("/")[0]}/${opts.name}` : r }, `Updated ${r}`);
|
|
193
284
|
});
|
|
@@ -208,7 +299,7 @@ export function registerLizardGit(git) {
|
|
|
208
299
|
const f = await lg.post(`${repoPath(r)}/forks`, { name: opts.name });
|
|
209
300
|
const full = `${f.owner}/${f.name}`;
|
|
210
301
|
if (opts.clone)
|
|
211
|
-
|
|
302
|
+
await wc.clone(full, f.name, undefined, null);
|
|
212
303
|
done({ ...f, url: `${gitURL()}/${full}` }, `Forked ${r} to ${chalk.bold(full)}`);
|
|
213
304
|
});
|
|
214
305
|
repoOpt(repo.command("forks").description("Forks of a repository").argument("[repo]"))
|
|
@@ -345,9 +436,9 @@ export function registerLizardGit(git) {
|
|
|
345
436
|
if (pulls) {
|
|
346
437
|
let head = opts.head;
|
|
347
438
|
if (!head) {
|
|
348
|
-
const
|
|
349
|
-
head =
|
|
350
|
-
if (!head
|
|
439
|
+
const root = wc.findCheckout();
|
|
440
|
+
head = root ? wc.loadCheckout(root).branch : "";
|
|
441
|
+
if (!head)
|
|
351
442
|
throw new APIError(400, "pass --head <branch>");
|
|
352
443
|
}
|
|
353
444
|
Object.assign(body, { head, base: opts.base, headRepo: opts.headRepo, draft: !!opts.draft, reviewers: opts.reviewer.length ? opts.reviewer : undefined });
|
|
@@ -551,21 +642,15 @@ export function registerLizardGit(git) {
|
|
|
551
642
|
});
|
|
552
643
|
done(m, `Merged #${n} (${String(m.sha).slice(0, 7)})`);
|
|
553
644
|
});
|
|
554
|
-
repoOpt(grp.command("checkout").description("
|
|
645
|
+
repoOpt(grp.command("checkout").description("Switch this checkout to a pull request's branch").argument("<number>", "", int))
|
|
555
646
|
.action(async (n, opts) => {
|
|
556
|
-
const
|
|
557
|
-
const
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
throw new APIError(500, `git ${args[0]} failed`);
|
|
564
|
-
};
|
|
565
|
-
run(["fetch", "origin", `+${remoteRef}:refs/remotes/origin/${local}`]);
|
|
566
|
-
const exists = spawnSync("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${local}`]).status === 0;
|
|
567
|
-
run(exists ? ["checkout", local] : ["checkout", "-b", local, "--track", `origin/${local}`]);
|
|
568
|
-
done({ branch: local }, `On branch ${local} (#${n})`);
|
|
647
|
+
const root = needCheckout();
|
|
648
|
+
const c = wc.loadCheckout(root);
|
|
649
|
+
const d = await lg.get(`${repoPath(c.repo)}/issues/${n}`);
|
|
650
|
+
// A fork's branch is read here under refs/pull/<n>/head: look, don't commit.
|
|
651
|
+
const target = d.issue.headRepo && d.issue.headRepo !== c.repo ? `pull/${n}/head` : d.issue.headRef;
|
|
652
|
+
const r = await wc.sync(root, c, target);
|
|
653
|
+
done({ branch: target, commit: r.to }, `On ${target} (#${n}) at ${r.to.slice(0, 12)}`);
|
|
569
654
|
});
|
|
570
655
|
}
|
|
571
656
|
// ── CI runs and workflows ────────────────────────────────────────────
|