@coderook/cli 0.22.2 → 0.24.0

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,1118 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.main = main;
40
+ /**
41
+ * `git push coderook` — a git remote helper.
42
+ *
43
+ * Git looks for a program called `git-remote-<scheme>` on PATH when it meets a
44
+ * remote URL it does not recognise, hands it the remote name and URL, and then
45
+ * talks a small line protocol over stdin and stdout. This is that program for
46
+ * `coderook://` URLs, which is what lets every editor's built-in git panel work
47
+ * with CodeRook without any per-editor work.
48
+ *
49
+ * Both directions are implemented: `push` publishes commits as versions, and
50
+ * `import` rebuilds a git history from versions so `git clone` and `git fetch`
51
+ * work.
52
+ *
53
+ * ## Why the `push` capability rather than `export`
54
+ *
55
+ * A helper may declare `export`, in which case git runs `git fast-export` and
56
+ * pipes the stream in. It is less code and it is the wrong shape here:
57
+ * fast-export states each commit as a *delta against its parent*, so replaying
58
+ * it onto one working tree is only correct while the history is a straight
59
+ * line. The moment a branch or a merge appears, the tree a commit is applied to
60
+ * is not the tree it was written against, and the version published is a blend
61
+ * of two commits that never existed. That failure is silent — every file is
62
+ * valid, the push reports success, and the contents are wrong.
63
+ *
64
+ * Declaring `push` instead means git tells us which refs to move and leaves the
65
+ * method to us, so each commit is read as a *complete tree* and parentage never
66
+ * has to be reconstructed. It also lets us diff any two commits directly, which
67
+ * is what keeps the upload incremental.
68
+ *
69
+ * ## What is deliberately not supported
70
+ *
71
+ * Named here rather than discovered by failure:
72
+ *
73
+ * - **Tag kind.** A tag becomes a release and a release becomes a tag, but
74
+ * fast-import can only create annotated tags — so a lightweight tag pushed
75
+ * comes back annotated. CodeRook has no notion of the difference to record.
76
+ * - **Force pushes and deletions.** CodeRook versions are immutable, so there
77
+ * is nothing to rewind to. Both are refused rather than silently ignored.
78
+ * - **Submodules.** A gitlink is a pointer into another repository and there
79
+ * are no bytes to publish. Skipped on push, and said out loud.
80
+ * - **File modes.** Everything is imported as `100644`. CodeRook records an
81
+ * executable bit but git's mode is not round-tripped yet, so a script
82
+ * cloned back needs `chmod +x`.
83
+ *
84
+ * ## What a round trip does and does not preserve
85
+ *
86
+ * Push then clone returns the same *contents*, not the same *commits*. The
87
+ * rebuilt commits have different ids, because a git commit id covers its
88
+ * author, committer and timestamps, and CodeRook stores who published a
89
+ * version but not the original stamps. So a clone of a pushed project is a
90
+ * faithful copy of the files and an honest approximation of the history.
91
+ *
92
+ * Merges are asymmetric, and the asymmetry is in the service rather than here.
93
+ * A publish states one base version, so pushing a git merge commit records one
94
+ * parent and the second is lost — the merged *tree* is exact, the fork in the
95
+ * history is not. Reading back is the richer direction: a version states its
96
+ * parents in order, so a version that genuinely has two is rebuilt as a real
97
+ * git merge commit. Push then clone therefore returns a straight line even
98
+ * where the original branched.
99
+ */
100
+ const node_child_process_1 = require("node:child_process");
101
+ const promises_1 = require("node:fs/promises");
102
+ const node_os_1 = __importDefault(require("node:os"));
103
+ const node_path_1 = __importDefault(require("node:path"));
104
+ const node_process_1 = __importDefault(require("node:process"));
105
+ const upload_js_1 = require("../../desktop-app/src/main/upload.js");
106
+ const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
107
+ const identify_js_1 = require("../../desktop-app/src/main/identify.js");
108
+ const config_js_1 = require("./config.js");
109
+ const publish_js_1 = require("./publish.js");
110
+ const git_history_js_1 = require("./git_history.js");
111
+ const api_js_1 = require("./api.js");
112
+ /** Everything the helper writes for a person goes to stderr; stdout is protocol. */
113
+ function say(text) {
114
+ node_process_1.default.stderr.write(`${text}\n`);
115
+ }
116
+ function send(text) {
117
+ node_process_1.default.stdout.write(`${text}\n`);
118
+ }
119
+ /** Run git and return stdout as text. Throws with git's own message. */
120
+ function git(args) {
121
+ const result = (0, node_child_process_1.spawnSync)("git", args, {
122
+ encoding: "utf8",
123
+ maxBuffer: 1024 * 1024 * 256,
124
+ });
125
+ if (result.status !== 0) {
126
+ throw new Error(`git ${args.slice(0, 3).join(" ")} failed: ${(result.stderr || "").trim()}`);
127
+ }
128
+ return result.stdout;
129
+ }
130
+ /** Run git for its exit status alone. False rather than throwing. */
131
+ function gitOk(args) {
132
+ return ((0, node_child_process_1.spawnSync)("git", args, { encoding: "utf8", stdio: "ignore" }).status === 0);
133
+ }
134
+ /**
135
+ * One long-lived `git cat-file --batch` process.
136
+ *
137
+ * A push reads one blob per changed path per commit, and starting a git process
138
+ * for each costs more than the reading does. GitLab measured the same walk as
139
+ * 70% faster with a single batch process, which is the whole reason this is a
140
+ * class rather than a function that shells out.
141
+ *
142
+ * The protocol is: write `<object>\n`, read a header line
143
+ * `<oid> <type> <size>\n`, then exactly `<size>` bytes, then one newline.
144
+ */
145
+ class CatFile {
146
+ child = (0, node_child_process_1.spawn)("git", ["cat-file", "--batch"], {
147
+ stdio: ["pipe", "pipe", "inherit"],
148
+ });
149
+ buffer = Buffer.alloc(0);
150
+ waiters = [];
151
+ closed = false;
152
+ constructor() {
153
+ this.child.stdout.on("data", (chunk) => {
154
+ this.buffer = Buffer.concat([this.buffer, chunk]);
155
+ const waiting = this.waiters;
156
+ this.waiters = [];
157
+ for (const wake of waiting)
158
+ wake();
159
+ });
160
+ this.child.stdout.on("end", () => {
161
+ this.closed = true;
162
+ for (const wake of this.waiters)
163
+ wake();
164
+ this.waiters = [];
165
+ });
166
+ }
167
+ async more() {
168
+ if (this.closed)
169
+ throw new Error("git cat-file closed unexpectedly");
170
+ await new Promise((resolve) => this.waiters.push(resolve));
171
+ }
172
+ async readLine() {
173
+ for (;;) {
174
+ const at = this.buffer.indexOf(0x0a);
175
+ if (at !== -1) {
176
+ const line = this.buffer.subarray(0, at).toString("utf8");
177
+ this.buffer = this.buffer.subarray(at + 1);
178
+ return line;
179
+ }
180
+ await this.more();
181
+ }
182
+ }
183
+ async readBytes(count) {
184
+ while (this.buffer.length < count)
185
+ await this.more();
186
+ const out = this.buffer.subarray(0, count);
187
+ this.buffer = this.buffer.subarray(count);
188
+ return Buffer.from(out);
189
+ }
190
+ /** The bytes of one blob, named as `<commit>:<path>` or by object id. */
191
+ async blob(reference) {
192
+ this.child.stdin.write(`${reference}\n`);
193
+ const header = await this.readLine();
194
+ if (header.endsWith(" missing")) {
195
+ throw new Error(`git has no object for ${reference}`);
196
+ }
197
+ const size = Number(header.split(" ")[2]);
198
+ const body = await this.readBytes(size);
199
+ await this.readBytes(1); // the trailing newline
200
+ return body;
201
+ }
202
+ close() {
203
+ this.child.stdin.end();
204
+ this.child.kill();
205
+ }
206
+ }
207
+ /**
208
+ * Bring the scratch tree from one commit to another, and say what moved.
209
+ *
210
+ * Renames are asked for as a delete plus an add (`--no-renames`) rather than
211
+ * as a rename. CodeRook is content-addressed, so the added path costs nothing
212
+ * to store when the bytes already exist, and the alternative is carrying a
213
+ * second change shape through every branch below for no benefit.
214
+ */
215
+ async function applyCommit(catFile, scratch, fromCommit, toCommit) {
216
+ const raw = git([
217
+ "diff",
218
+ "--name-status",
219
+ "--no-renames",
220
+ "-z",
221
+ fromCommit,
222
+ toCommit,
223
+ ]);
224
+ const fields = raw.split("\0").filter((field) => field.length > 0);
225
+ const written = [];
226
+ const deleted = [];
227
+ const skipped = [];
228
+ for (let index = 0; index + 1 < fields.length; index += 2) {
229
+ const status = fields[index];
230
+ const filePath = fields[index + 1];
231
+ const target = node_path_1.default.join(scratch, filePath);
232
+ if (status.startsWith("D")) {
233
+ await (0, promises_1.rm)(target, { force: true });
234
+ deleted.push(filePath);
235
+ continue;
236
+ }
237
+ /*
238
+ A gitlink has a mode but no bytes. Writing the commit id it points at as
239
+ the file's contents would produce a forty-byte text file where a
240
+ directory belongs, which is worse than not publishing it.
241
+ */
242
+ const mode = git(["ls-tree", "-z", toCommit, "--", filePath])
243
+ .split("\0")[0]
244
+ ?.split(/\s+/)[0];
245
+ if (mode === "160000") {
246
+ skipped.push(filePath);
247
+ continue;
248
+ }
249
+ const bytes = await catFile.blob(`${toCommit}:${filePath}`);
250
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
251
+ await (0, promises_1.writeFile)(target, bytes);
252
+ written.push(filePath);
253
+ }
254
+ return { written, deleted, skipped };
255
+ }
256
+ /**
257
+ * The refs the remote already holds, as far as we can tell.
258
+ *
259
+ * Git uses this to work out which commits to send, so being wrong here is
260
+ * expensive in both directions: claim too much and work is silently dropped,
261
+ * claim too little and a history is published twice.
262
+ */
263
+ async function remoteRefs(repositoryId) {
264
+ const known = new Map();
265
+ if (!repositoryId)
266
+ return known;
267
+ const state = await remoteState(repositoryId);
268
+ const marks = await readMarks();
269
+ for (const track of state.tracks) {
270
+ if (track.kind !== "line" || !track.headVersionId)
271
+ continue;
272
+ const sha = headCommit(track.headVersionId, state, marks);
273
+ if (sha)
274
+ known.set(`refs/heads/${track.name}`, sha);
275
+ }
276
+ return known;
277
+ }
278
+ /**
279
+ * Which commit *in this repository* holds a version.
280
+ *
281
+ * There are two answers and they are not interchangeable. The message marker
282
+ * names the commit a version was published from, which is the right answer in
283
+ * the repository that published it. The mark table names the commit this
284
+ * repository built when it imported that version, which is the right answer in
285
+ * a clone. A repository that cloned and then pushes has both, and they disagree
286
+ * permanently — the imported commit is a different object with a different id.
287
+ *
288
+ * The mark wins, because git is about to be told this id and has to be able to
289
+ * find it. Offering the marker instead makes every push from a clone fail with
290
+ * "the remote has a commit you do not have", naming a commit that only exists
291
+ * on somebody else's machine — which is precisely the loop this closed.
292
+ */
293
+ function headCommit(versionId, state, marks) {
294
+ const mark = marks?.ofVersion.get(versionId);
295
+ const imported = mark === undefined ? undefined : marks?.sha.get(mark);
296
+ return imported ?? state.shaOfVersion.get(versionId);
297
+ }
298
+ /**
299
+ * Everything about the project that a push needs to decide anything.
300
+ *
301
+ * Gathered once per push rather than per branch: it is two calls, both of
302
+ * which were being made repeatedly, and having one snapshot removes the
303
+ * possibility of two decisions in the same push disagreeing about what the
304
+ * remote holds.
305
+ */
306
+ async function remoteState(repositoryId) {
307
+ const tracks = await new tracks_js_1.Tracks(config_js_1.credentials).list(repositoryId);
308
+ const { versions } = await Promise.resolve().then(() => __importStar(require("./api.js")));
309
+ const all = await versions(repositoryId);
310
+ const versionOfSha = new Map();
311
+ const shaOfVersion = new Map();
312
+ for (const version of all) {
313
+ const sha = (0, git_history_js_1.commitFromMessage)(version.message);
314
+ if (!sha)
315
+ continue;
316
+ versionOfSha.set(sha, version.id);
317
+ shaOfVersion.set(version.id, sha);
318
+ }
319
+ return { tracks, versionOfSha, shaOfVersion, versionCount: all.length };
320
+ }
321
+ /**
322
+ * The commit this branch should fork from, and the version that holds it.
323
+ *
324
+ * Walking the branch's own ancestry newest-first and stopping at the first
325
+ * commit the project already has is the same answer `git merge-base` would
326
+ * give against every pushed branch at once, without having to ask which
327
+ * branches those are.
328
+ */
329
+ function forkPoint(tip, versionOfSha) {
330
+ const ancestry = git(["rev-list", tip])
331
+ .split("\n")
332
+ .map((line) => line.trim())
333
+ .filter(Boolean);
334
+ for (const sha of ancestry) {
335
+ const versionId = versionOfSha.get(sha);
336
+ if (versionId)
337
+ return { sha, versionId };
338
+ }
339
+ return null;
340
+ }
341
+ async function readMarks() {
342
+ let gitDir;
343
+ try {
344
+ gitDir = git(["rev-parse", "--absolute-git-dir"]).trim();
345
+ }
346
+ catch {
347
+ return null;
348
+ }
349
+ if (!gitDir)
350
+ return null;
351
+ const dir = node_path_1.default.join(gitDir, "coderook");
352
+ const marks = {
353
+ file: node_path_1.default.join(dir, "marks"),
354
+ ofVersion: new Map(),
355
+ sha: new Map(),
356
+ dir,
357
+ };
358
+ try {
359
+ const table = await (0, promises_1.readFile)(node_path_1.default.join(dir, "versions"), "utf8");
360
+ for (const line of table.split("\n")) {
361
+ const [versionId, mark] = line.trim().split(/\s+/);
362
+ if (versionId && mark)
363
+ marks.ofVersion.set(versionId, Number(mark));
364
+ }
365
+ }
366
+ catch {
367
+ // No table yet. Every version is new, which is correct for a clone.
368
+ }
369
+ try {
370
+ const table = await (0, promises_1.readFile)(marks.file, "utf8");
371
+ for (const line of table.split("\n")) {
372
+ const match = line.trim().match(/^:(\d+)\s+([0-9a-f]{40})$/);
373
+ if (match)
374
+ marks.sha.set(Number(match[1]), match[2]);
375
+ }
376
+ }
377
+ catch {
378
+ // Likewise.
379
+ }
380
+ /*
381
+ Drop any version whose mark no object ever got.
382
+
383
+ The two files are written by different things at different moments, so a
384
+ run that dies between them leaves a version claiming a mark that names
385
+ nothing. Believing it is the worst outcome available: the next fetch
386
+ reports the project already imported and hands back an empty history,
387
+ silently, with no error to notice. Cross-checking here makes a half-write
388
+ cost a re-import instead of a wrong answer.
389
+ */
390
+ for (const [versionId, mark] of [...marks.ofVersion]) {
391
+ if (!marks.sha.has(mark))
392
+ marks.ofVersion.delete(versionId);
393
+ }
394
+ return marks;
395
+ }
396
+ async function writeMarks(marks) {
397
+ await (0, promises_1.mkdir)(marks.dir, { recursive: true });
398
+ const body = [...marks.ofVersion]
399
+ .map(([versionId, mark]) => `${versionId} ${mark}`)
400
+ .join("\n");
401
+ await (0, promises_1.writeFile)(node_path_1.default.join(marks.dir, "versions"), `${body}\n`, "utf8");
402
+ }
403
+ /**
404
+ * Record commits this repository *pushed* in the same table imports use.
405
+ *
406
+ * Without this, pushing and fetching keep separate ideas of what a version is.
407
+ * A repository that pushes gets a tracking ref pointing at its own commits; a
408
+ * later fetch rebuilds those same versions as new commits with new ids, and
409
+ * git refuses to move the ref because the two histories share no ancestor:
410
+ *
411
+ * not updating refs/coderook/main (new tip … does not contain …)
412
+ *
413
+ * Writing the pushed commits here means a later import recognises those
414
+ * versions as already present, skips them, and hangs anything new off the
415
+ * commit that is genuinely in this repository. One identity space, both ways.
416
+ *
417
+ * The fast-import marks file is written directly rather than left to
418
+ * fast-import, because no import ran — the objects are already here.
419
+ */
420
+ async function recordPushedCommits(marks) {
421
+ await (0, promises_1.mkdir)(marks.dir, { recursive: true });
422
+ const table = [...marks.sha]
423
+ .sort(([left], [right]) => left - right)
424
+ .map(([mark, sha]) => `:${mark} ${sha}`)
425
+ .join("\n");
426
+ await (0, promises_1.writeFile)(marks.file, table ? `${table}\n` : "", "utf8");
427
+ await writeMarks(marks);
428
+ }
429
+ /**
430
+ * Rebuild a git history from a project's versions and write it to stdout as a
431
+ * fast-import stream.
432
+ *
433
+ * Each version becomes one commit holding that version's complete file list,
434
+ * stated with `deleteall` followed by every path. Emitting a delta instead
435
+ * would be smaller and would mean tracking what the previous commit held on
436
+ * every branch of the graph; restating the tree is bounded work per commit and
437
+ * cannot drift.
438
+ *
439
+ * Contents are deduplicated by SHA-256 across the whole history, so a file
440
+ * unchanged across a thousand versions is downloaded and sent to git once.
441
+ */
442
+ async function doImport(refs, url) {
443
+ const { slug } = (0, git_history_js_1.parseRemoteUrl)(url);
444
+ const project = await (0, api_js_1.findProject)(slug);
445
+ if (!project) {
446
+ say(`No CodeRook project called "${slug}", or you cannot read it.`);
447
+ send("done");
448
+ return;
449
+ }
450
+ const repositoryId = project.id;
451
+ const state = await remoteState(repositoryId);
452
+ const { versions } = await Promise.resolve().then(() => __importStar(require("./api.js")));
453
+ const all = await versions(repositoryId);
454
+ const wanted = new Map();
455
+ for (const ref of refs) {
456
+ const branch = (0, git_history_js_1.branchOf)(ref);
457
+ if (!branch)
458
+ continue;
459
+ const track = state.tracks.find((candidate) => candidate.name === branch && candidate.kind === "line");
460
+ if (track?.headVersionId)
461
+ wanted.set(ref, track.headVersionId);
462
+ else
463
+ say(` no line called "${branch}" on ${slug}`);
464
+ }
465
+ if (!wanted.size) {
466
+ send("done");
467
+ return;
468
+ }
469
+ const order = (0, git_history_js_1.versionsInOrder)([...wanted.values()], all);
470
+ const marks = await readMarks();
471
+ /** SHA-256 of the contents to the fast-import mark already carrying it. */
472
+ const blobMark = new Map();
473
+ /** Version id to the mark of the commit built from it. */
474
+ const commitMark = new Map(marks?.ofVersion ?? []);
475
+ /*
476
+ Numbering continues above every mark this repository already holds.
477
+ Restarting at 1 would hand a new commit a mark that already names a
478
+ different object, and fast-import would happily believe us.
479
+ */
480
+ let nextMark = 1;
481
+ for (const mark of commitMark.values())
482
+ nextMark = Math.max(nextMark, mark + 1);
483
+ const fresh = order.filter((version) => !commitMark.has(version.id));
484
+ if (!fresh.length) {
485
+ say(`Already up to date with ${slug}.`);
486
+ send("done");
487
+ return;
488
+ }
489
+ say(`Importing ${fresh.length} version${fresh.length === 1 ? "" : "s"} from ${slug}` +
490
+ (order.length > fresh.length
491
+ ? ` (${order.length - fresh.length} already here)`
492
+ : "") +
493
+ ".");
494
+ const { Downloader } = await Promise.resolve().then(() => __importStar(require("../../desktop-app/src/main/download.js")));
495
+ const downloader = new Downloader(config_js_1.credentials);
496
+ const scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "coderook-fetch-"));
497
+ try {
498
+ send("feature done");
499
+ if (marks) {
500
+ /*
501
+ `-if-exists` because the first import has no file yet, and plain
502
+ `import-marks` is a hard error when the file is missing.
503
+ */
504
+ send(`feature import-marks-if-exists=${marks.file.replaceAll("\\", "/")}`);
505
+ send(`feature export-marks=${marks.file.replaceAll("\\", "/")}`);
506
+ }
507
+ let done = 0;
508
+ for (const version of fresh) {
509
+ const files = await downloader.files(repositoryId, version.id);
510
+ // Blobs first: fast-import requires a mark to exist before a commit
511
+ // names it.
512
+ for (const file of files) {
513
+ if (blobMark.has(file.sha256))
514
+ continue;
515
+ const target = node_path_1.default.join(scratch, "blob.bin");
516
+ await (0, promises_1.rm)(target, { force: true });
517
+ await downloader.fileTo(repositoryId, version.id, file, target);
518
+ const bytes = await (0, promises_1.readFile)(target);
519
+ const mark = nextMark++;
520
+ blobMark.set(file.sha256, mark);
521
+ send("blob");
522
+ send(`mark :${mark}`);
523
+ send(`data ${bytes.length}`);
524
+ node_process_1.default.stdout.write(bytes);
525
+ node_process_1.default.stdout.write("\n");
526
+ }
527
+ const mark = nextMark++;
528
+ commitMark.set(version.id, mark);
529
+ marks?.ofVersion.set(version.id, mark);
530
+ const ref = [...wanted.entries()].find(([, head]) => head === version.id)?.[0] ??
531
+ `refs/heads/${state.tracks.find((track) => track.headVersionId === version.id)?.name ?? "main"}`;
532
+ const parents = version.parentVersionIds
533
+ .map((parent) => commitMark.get(parent))
534
+ .filter((value) => value !== undefined);
535
+ const author = version.authorName || "CodeRook";
536
+ const when = `${(0, git_history_js_1.stamp)(version.createdAt)} +0000`;
537
+ const message = (0, git_history_js_1.messageWithoutMarker)(version.message) || `Version ${version.sequence}`;
538
+ const body = Buffer.from(message, "utf8");
539
+ send(`commit ${(0, git_history_js_1.importRef)(ref)}`);
540
+ send(`mark :${mark}`);
541
+ /*
542
+ No email is invented. CodeRook records who published a version, not an
543
+ address, and a plausible-looking address that belongs to nobody is
544
+ worse than an obviously synthetic one: it survives being copied into a
545
+ mailing list or a CONTRIBUTORS file.
546
+ */
547
+ send(`author ${author} <noreply@coderook.com> ${when}`);
548
+ send(`committer ${author} <noreply@coderook.com> ${when}`);
549
+ send(`data ${body.length}`);
550
+ node_process_1.default.stdout.write(body);
551
+ node_process_1.default.stdout.write("\n");
552
+ if (parents[0] !== undefined)
553
+ send(`from :${parents[0]}`);
554
+ for (const extra of parents.slice(1))
555
+ send(`merge :${extra}`);
556
+ send("deleteall");
557
+ for (const file of files) {
558
+ send(`M 100644 :${blobMark.get(file.sha256)} ${(0, git_history_js_1.quotePath)(file.path)}`);
559
+ }
560
+ send("");
561
+ done += 1;
562
+ if (done % 10 === 0 || done === fresh.length) {
563
+ say(` ${done}/${fresh.length} versions`);
564
+ }
565
+ }
566
+ /*
567
+ Releases come back as tags, because that is the same statement in the
568
+ other vocabulary: a version somebody gave a name to.
569
+
570
+ Emitted after every commit, so the mark a tag points at is certain to
571
+ exist. A release naming a version outside the branches being imported is
572
+ skipped rather than guessed at — the tag would have nothing to point to.
573
+ */
574
+ try {
575
+ const { releases } = await Promise.resolve().then(() => __importStar(require("./api.js")));
576
+ const named = await releases(repositoryId);
577
+ let tagged = 0;
578
+ for (const release of named) {
579
+ const mark = commitMark.get(release.versionId);
580
+ if (mark === undefined)
581
+ continue;
582
+ const body = Buffer.from(release.notes || release.name, "utf8");
583
+ send(`tag ${release.name}`);
584
+ send(`from :${mark}`);
585
+ send(`tagger ${release.releasedBy || "CodeRook"} <noreply@coderook.com> ` +
586
+ `${(0, git_history_js_1.stamp)(release.releasedAt)} +0000`);
587
+ send(`data ${body.length}`);
588
+ node_process_1.default.stdout.write(body);
589
+ node_process_1.default.stdout.write("\n");
590
+ tagged += 1;
591
+ }
592
+ if (tagged)
593
+ say(` ${tagged} release${tagged === 1 ? "" : "s"} as tags`);
594
+ }
595
+ catch (error) {
596
+ // A project whose releases cannot be read is still worth importing.
597
+ say(` could not read releases: ${error.message}`);
598
+ }
599
+ send("done");
600
+ /*
601
+ Written after the stream, not during. fast-import only writes its own
602
+ mark file when it finishes, so a table saved earlier would name marks
603
+ that no object ever got — and the next fetch would build commits whose
604
+ parents do not exist.
605
+ */
606
+ if (marks)
607
+ await writeMarks(marks);
608
+ }
609
+ finally {
610
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
611
+ }
612
+ }
613
+ async function doPush(requests, url) {
614
+ const { slug } = (0, git_history_js_1.parseRemoteUrl)(url);
615
+ const project = await (0, api_js_1.findProject)(slug);
616
+ const repositoryId = project?.id ?? null;
617
+ const state = repositoryId ? await remoteState(repositoryId) : null;
618
+ const marks = await readMarks();
619
+ const known = new Map();
620
+ for (const track of state?.tracks ?? []) {
621
+ if (track.kind !== "line" || !track.headVersionId)
622
+ continue;
623
+ const sha = headCommit(track.headVersionId, state, marks);
624
+ if (sha)
625
+ known.set(`refs/heads/${track.name}`, sha);
626
+ }
627
+ let nextPushMark = 1;
628
+ for (const mark of marks?.sha.keys() ?? []) {
629
+ nextPushMark = Math.max(nextPushMark, mark + 1);
630
+ }
631
+ const uploader = new upload_js_1.Uploader(config_js_1.credentials);
632
+ const catFile = new CatFile();
633
+ const scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "coderook-push-"));
634
+ let warnedAboutMerges = false;
635
+ try {
636
+ for (const request of requests) {
637
+ /*
638
+ A tag names a version, which is exactly what a CodeRook release is:
639
+ "a Version with a name on it, not a different kind of object". So a tag
640
+ does not publish anything — it finds the version the tagged commit
641
+ already became and gives it that name. Pushing a tag for a commit that
642
+ was never pushed is refused rather than guessed at.
643
+ */
644
+ const tag = (0, git_history_js_1.tagOf)(request.dst);
645
+ if (tag) {
646
+ if (!request.src) {
647
+ send(`error ${request.dst} deleting a tag is not supported`);
648
+ continue;
649
+ }
650
+ if (!state) {
651
+ send(`error ${request.dst} push a branch before tagging it`);
652
+ continue;
653
+ }
654
+ const target = git(["rev-list", "-n", "1", request.src]).trim();
655
+ const versionId = state.versionOfSha.get(target);
656
+ if (!versionId) {
657
+ send(`error ${request.dst} commit ${target.slice(0, 8)} has not been pushed to CodeRook yet`);
658
+ say(`\n Push the branch first, then push the tag.\n`);
659
+ continue;
660
+ }
661
+ /*
662
+ An annotated tag carries a message and a lightweight one does not.
663
+ `for-each-ref` gives the annotation only for the former, and reading
664
+ the commit's message instead would put the commit text on the
665
+ release, which is a different thing that happens to be nearby.
666
+ */
667
+ /*
668
+ Only an annotated tag has a message of its own. `%(contents)` on a
669
+ lightweight tag falls through to the commit it points at, so asking
670
+ for it unconditionally puts the commit's message on the release —
671
+ text nobody wrote about the release, presented as release notes.
672
+
673
+ `%(objecttype)` separates the two: `tag` for an annotated one, and
674
+ `commit` for a lightweight tag, which points straight at the commit
675
+ and carries nothing.
676
+ */
677
+ const described = git([
678
+ "for-each-ref",
679
+ "--format=%(objecttype)%0a%(contents)",
680
+ request.dst,
681
+ ]);
682
+ const newline = described.indexOf("\n");
683
+ const kind = (newline === -1 ? described : described.slice(0, newline)).trim();
684
+ const notes = kind === "tag" && newline !== -1 ? described.slice(newline + 1).trim() : "";
685
+ try {
686
+ const { markRelease, releases } = await Promise.resolve().then(() => __importStar(require("./api.js")));
687
+ /*
688
+ A version carries one name, so two tags on the same commit cannot
689
+ both survive — the second renames the release the first made. Git
690
+ allows it and CodeRook cannot represent it, and quietly dropping a
691
+ release somebody just published is the wrong way to find that out.
692
+ */
693
+ const existing = await releases(repositoryId).catch(() => []);
694
+ const already = existing.find((release) => release.versionId === versionId && release.name !== tag);
695
+ if (already) {
696
+ say(` note: v${already.sequence} was already released as "${already.name}".
697
+ ` +
698
+ ` A version carries one name, so it is now "${tag}".`);
699
+ }
700
+ await markRelease(repositoryId, versionId, tag, notes || null);
701
+ send(`ok ${request.dst}`);
702
+ say(` released "${tag}" at ${target.slice(0, 8)}`);
703
+ }
704
+ catch (error) {
705
+ send(`error ${request.dst} ${error instanceof Error ? error.message : String(error)}`);
706
+ }
707
+ continue;
708
+ }
709
+ const branch = (0, git_history_js_1.branchOf)(request.dst);
710
+ if (!branch) {
711
+ send(`error ${request.dst} only branches and tags can be pushed to CodeRook`);
712
+ continue;
713
+ }
714
+ if (!request.src) {
715
+ send(`error ${request.dst} deleting a branch is not supported; CodeRook versions are immutable`);
716
+ continue;
717
+ }
718
+ /*
719
+ Refused rather than quietly treated as an ordinary push. A force push
720
+ means "make the remote match me, discarding what is there", and a
721
+ published version cannot be unpublished — so the one thing the person
722
+ asked for is the one thing that cannot happen. Accepting it and
723
+ appending instead would be a different operation wearing its name.
724
+ */
725
+ if (request.force) {
726
+ send(`error ${request.dst} force pushing is not supported; published versions are immutable and cannot be discarded`);
727
+ continue;
728
+ }
729
+ const tip = git(["rev-parse", request.src]).trim();
730
+ const already = known.get(request.dst);
731
+ if (already === tip) {
732
+ send(`ok ${request.dst}`);
733
+ say(` ${branch}: already up to date`);
734
+ continue;
735
+ }
736
+ /*
737
+ Refuse a push that is not a fast-forward, the way git itself does.
738
+
739
+ Left alone, the publish still succeeds: the service notices the
740
+ divergence, keeps both sides and opens a merge track. That is the right
741
+ behaviour for the desktop app and exactly the wrong end state here,
742
+ because the person is standing in git and a merge track is not
743
+ something git can see, let alone resolve — they would be told the push
744
+ failed while their work sat in a queue that only `cbx merge` can
745
+ empty.
746
+
747
+ Rejecting first keeps the whole loop inside git: pull, merge, push.
748
+ Nothing is uploaded, so there is nothing stranded to clean up.
749
+ */
750
+ if (already) {
751
+ if (!gitOk(["cat-file", "-e", `${already}^{commit}`])) {
752
+ send(`error ${request.dst} the remote has commit ${already.slice(0, 8)}, which this repository does not have; run "git fetch" first`);
753
+ continue;
754
+ }
755
+ if (!gitOk(["merge-base", "--is-ancestor", already, tip])) {
756
+ send(`error ${request.dst} non-fast-forward; the remote has work you do not have locally`);
757
+ say(`\n Someone published to "${branch}" since you last fetched.\n` +
758
+ ` git pull --rebase coderook ${branch}\n` +
759
+ ` then push again. Nothing was uploaded.\n`);
760
+ continue;
761
+ }
762
+ }
763
+ /*
764
+ A project whose versions cannot be tied to any commit is refused
765
+ rather than appended to. Those versions are real work — published from
766
+ the desktop app, the CLI or an import — and replaying a git history on
767
+ top of them would interleave two unrelated sequences with no way back.
768
+
769
+ The test is whether the project came from git *at all*, not whether
770
+ this particular branch is known. Checking the branch instead refuses
771
+ the ordinary act of pushing a second branch to a project git already
772
+ owns, which is the common case and not a conflict of any kind.
773
+ */
774
+ if (state && !already && state.versionCount > 0 && state.versionOfSha.size === 0) {
775
+ send(`error ${request.dst} the project "${slug}" already has ${state.versionCount} version${state.versionCount === 1 ? "" : "s"} that did not come from git; pushing would publish this history a second time`);
776
+ say(`\n Push to a new project instead:\n` +
777
+ ` git remote set-url coderook coderook://<a-new-name>\n`);
778
+ continue;
779
+ }
780
+ /*
781
+ A branch the project has never seen starts at the newest commit it
782
+ *has* seen — its fork point — rather than at the project head. Starting
783
+ a track at the head would give the branch every change made on the
784
+ line it forked away from, which is precisely the work the person
785
+ branched to avoid.
786
+ */
787
+ let fork = null;
788
+ if (!already && state && state.versionOfSha.size > 0) {
789
+ fork = forkPoint(tip, state.versionOfSha);
790
+ }
791
+ const from = already ?? fork?.sha ?? null;
792
+ const range = from ? `${from}..${tip}` : tip;
793
+ const commits = git(["rev-list", "--reverse", "--topo-order", range])
794
+ .split("\n")
795
+ .map((line) => line.trim())
796
+ .filter(Boolean);
797
+ if (!commits.length) {
798
+ /*
799
+ Nothing to publish does not mean nothing to do. A branch whose tip
800
+ the project already holds — one merged into another branch, most
801
+ often — still has to exist as a line, or the push reports a new
802
+ branch that is not there and the next push agrees it is up to date.
803
+ */
804
+ if (!already && fork && repositoryId) {
805
+ try {
806
+ await new tracks_js_1.Tracks(config_js_1.credentials).create(repositoryId, branch, fork.versionId);
807
+ say(` created line "${branch}" at ${fork.sha.slice(0, 8)} (already published)`);
808
+ }
809
+ catch (error) {
810
+ const text = error instanceof Error ? error.message : String(error);
811
+ if (!/exist/i.test(text)) {
812
+ send(`error ${request.dst} ${text}`);
813
+ continue;
814
+ }
815
+ }
816
+ }
817
+ send(`ok ${request.dst}`);
818
+ continue;
819
+ }
820
+ /*
821
+ Said before the work starts, not after. Each commit is a published
822
+ version and versions are not free to make, so somebody pushing years
823
+ of history deserves the chance to stop and import a snapshot instead.
824
+ */
825
+ const estimate = Math.round((commits.length * 11) / 60);
826
+ say(` ${commits.length} commit${commits.length === 1 ? "" : "s"} to publish as version${commits.length === 1 ? "" : "s"}` +
827
+ (commits.length > 20 ? ` — roughly ${estimate} minute${estimate === 1 ? "" : "s"}` : ""));
828
+ /*
829
+ A push that continues a branch has to start from where the branch
830
+ actually is, in three separate senses, and getting any one of them
831
+ wrong fails differently:
832
+
833
+ - `previous` is the commit the remote already holds. Diffing from the
834
+ empty tree instead would mark every file as added and republish the
835
+ whole project as though it were new.
836
+ - `baseVersionId` is the version this publish is based on. Sending
837
+ null claims the project is empty, and the service correctly refuses
838
+ rather than laying this history over somebody's work.
839
+ - `local`/`known` is what the remote holds now. Without it, a file
840
+ present in the version but not in our scratch tree is
841
+ indistinguishable from one this push deleted — and the service would
842
+ drop it.
843
+ */
844
+ let baseVersionId = null;
845
+ let manifest = {};
846
+ let local = {};
847
+ let previous = git_history_js_1.EMPTY_TREE;
848
+ let currentRepositoryId = repositoryId;
849
+ let published = 0;
850
+ if (from && repositoryId && state) {
851
+ previous = from;
852
+ if (already) {
853
+ const track = state.tracks.find((candidate) => candidate.name === branch && candidate.kind === "line");
854
+ if (!track?.headVersionId) {
855
+ send(`error ${request.dst} could not read the current head of "${branch}"`);
856
+ continue;
857
+ }
858
+ baseVersionId = track.headVersionId;
859
+ }
860
+ else if (fork) {
861
+ /*
862
+ The track has to exist before anything can be published onto it,
863
+ and it has to start at the fork point. `create` is allowed to fail
864
+ with "already exists" — another push may have made it — in which
865
+ case the existing one is what we wanted anyway.
866
+ */
867
+ baseVersionId = fork.versionId;
868
+ try {
869
+ await new tracks_js_1.Tracks(config_js_1.credentials).create(repositoryId, branch, fork.versionId);
870
+ say(` created line "${branch}" from ${fork.sha.slice(0, 8)}`);
871
+ }
872
+ catch (error) {
873
+ const text = error instanceof Error ? error.message : String(error);
874
+ if (!/exist/i.test(text))
875
+ throw error;
876
+ }
877
+ }
878
+ const { Downloader } = await Promise.resolve().then(() => __importStar(require("../../desktop-app/src/main/download.js")));
879
+ const held = await new Downloader(config_js_1.credentials).files(repositoryId, baseVersionId);
880
+ manifest = Object.fromEntries(held.map((file) => [file.path, file.sha256]));
881
+ local = { ...manifest };
882
+ }
883
+ for (const sha of commits) {
884
+ const parents = git(["rev-list", "--parents", "-n", "1", sha])
885
+ .trim()
886
+ .split(/\s+/)
887
+ .slice(1);
888
+ if (parents.length > 1 && !warnedAboutMerges) {
889
+ warnedAboutMerges = true;
890
+ say(` note: merge commits are published as one version holding the ` +
891
+ `merged tree.\n The contents are exact; the branch shape is not kept.`);
892
+ }
893
+ const changes = await applyCommit(catFile, scratch, previous, sha);
894
+ previous = sha;
895
+ if (changes.skipped.length) {
896
+ say(` skipped ${changes.skipped.length} submodule path(s) in ${sha.slice(0, 8)}`);
897
+ }
898
+ if (!changes.written.length && !changes.deleted.length) {
899
+ // An empty commit, or one that only touched submodules. Nothing to
900
+ // publish, but the ref still has to end up pointing at it.
901
+ published += 1;
902
+ continue;
903
+ }
904
+ const subject = git(["log", "-1", "--format=%B", sha]).trim();
905
+ /*
906
+ Built by the shared description rather than assembled here. Every
907
+ sharp edge in that contract — deletions in both lists, `known`,
908
+ compare-and-swap on the base version — was learned once by
909
+ `cbx submit` and then learned again here, as a bug.
910
+ */
911
+ const uploadRequest = (0, publish_js_1.uploadRequestFor)({
912
+ localPath: scratch,
913
+ changed: changes.written,
914
+ deleted: changes.deleted,
915
+ message: (0, git_history_js_1.messageWithCommit)(subject || `Commit ${sha.slice(0, 8)}`, sha),
916
+ projectName: slug,
917
+ repositoryId: currentRepositoryId,
918
+ baseVersionId,
919
+ track: branch,
920
+ allowIgnored: true,
921
+ ...(Object.keys(manifest).length ? { known: local } : {}),
922
+ });
923
+ let result;
924
+ try {
925
+ const plan = await uploader.plan(uploadRequest, () => { });
926
+ result = await uploader.execute(uploadRequest, plan, () => { });
927
+ }
928
+ catch (error) {
929
+ const failure = (0, publish_js_1.classifyPublishFailure)(error);
930
+ if (failure?.kind === "interrupted") {
931
+ /*
932
+ Said rather than left to be assumed. The service records the
933
+ attempt, so pushing again is answered with the version it
934
+ already made — somebody not told this will assume the push half
935
+ happened and go looking for a way to undo it.
936
+ */
937
+ send(`error ${request.dst} the connection failed part way through`);
938
+ say(`
939
+ ${published} of ${commits.length} commits were published.
940
+ ` +
941
+ ` Push again — commits already published are not sent twice.
942
+ `);
943
+ throw error;
944
+ }
945
+ if (failure?.kind === "conflict") {
946
+ send(`error ${request.dst} somebody published to "${branch}" while this push was running`);
947
+ say(`
948
+ git fetch, then push again.
949
+ `);
950
+ throw error;
951
+ }
952
+ throw error;
953
+ }
954
+ if (result.mergeTrack) {
955
+ send(`error ${request.dst} somebody else published to "${branch}" during this push; it is waiting on a merge`);
956
+ throw new Error("push interrupted by a concurrent publish");
957
+ }
958
+ currentRepositoryId = result.repositoryId;
959
+ baseVersionId = result.versionId;
960
+ /*
961
+ The version and the commit it came from are the same thing in this
962
+ repository from here on. Recorded so a later fetch does not rebuild
963
+ it as a second, different commit.
964
+ */
965
+ if (marks) {
966
+ const mark = nextPushMark++;
967
+ marks.ofVersion.set(result.versionId, mark);
968
+ marks.sha.set(mark, sha);
969
+ }
970
+ manifest = result.manifest;
971
+ local = result.local;
972
+ published += 1;
973
+ say(` [${published}/${commits.length}] v${result.sequence} ${sha.slice(0, 8)} ${subject.split("\n")[0].slice(0, 48)}`);
974
+ }
975
+ send(`ok ${request.dst}`);
976
+ }
977
+ }
978
+ finally {
979
+ catFile.close();
980
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
981
+ if (marks)
982
+ await recordPushedCommits(marks);
983
+ }
984
+ send("");
985
+ }
986
+ /** Read stdin as lines. The push protocol is text throughout. */
987
+ async function* lines() {
988
+ let buffer = "";
989
+ for await (const chunk of node_process_1.default.stdin) {
990
+ buffer += chunk.toString("utf8");
991
+ let at = buffer.indexOf("\n");
992
+ while (at !== -1) {
993
+ yield buffer.slice(0, at);
994
+ buffer = buffer.slice(at + 1);
995
+ at = buffer.indexOf("\n");
996
+ }
997
+ }
998
+ if (buffer.length)
999
+ yield buffer;
1000
+ }
1001
+ async function main(argv) {
1002
+ const url = argv[1] ?? argv[0] ?? "";
1003
+ (0, identify_js_1.declareClient)("cli", "git-remote");
1004
+ const pending = [];
1005
+ const importing = [];
1006
+ for await (const line of lines()) {
1007
+ const command = line.trim();
1008
+ if (command === "capabilities") {
1009
+ // `push` rather than `export`, and `import` rather than `fetch`; see the
1010
+ // note at the top of this file.
1011
+ send("import");
1012
+ send("push");
1013
+ send("refspec refs/heads/*:refs/coderook/*");
1014
+ send("option");
1015
+ send("");
1016
+ continue;
1017
+ }
1018
+ if (command.startsWith("option ")) {
1019
+ // Nothing here changes behaviour yet, and claiming otherwise would make
1020
+ // git believe a setting took effect.
1021
+ send("unsupported");
1022
+ continue;
1023
+ }
1024
+ if (command === "list" || command === "list for-push") {
1025
+ try {
1026
+ const { slug } = (0, git_history_js_1.parseRemoteUrl)(url);
1027
+ const project = await (0, api_js_1.findProject)(slug);
1028
+ if (command === "list for-push") {
1029
+ const known = await remoteRefs(project?.id ?? null);
1030
+ for (const [ref, sha] of known)
1031
+ send(`${sha} ${ref}`);
1032
+ send("");
1033
+ continue;
1034
+ }
1035
+ /*
1036
+ A line whose head this clone has imported before is advertised by
1037
+ its real commit id; one it has not is advertised as `?`.
1038
+
1039
+ `?` is the format's way of saying "I cannot tell you", and it is the
1040
+ honest answer the first time: the git commits are built during the
1041
+ import that follows, from versions that are not git commits, so their
1042
+ ids do not exist yet. It is the wrong answer on every fetch after
1043
+ that, because git cannot then see that nothing has changed and asks
1044
+ for the history again. The mark table is what turns the second and
1045
+ later answers into real ids.
1046
+ */
1047
+ const state = project ? await remoteState(project.id) : null;
1048
+ const lines = (state?.tracks ?? []).filter((track) => track.kind === "line" && track.headVersionId);
1049
+ const marks = await readMarks();
1050
+ for (const track of lines) {
1051
+ const mark = marks?.ofVersion.get(track.headVersionId);
1052
+ const sha = mark === undefined ? undefined : marks?.sha.get(mark);
1053
+ send(`${sha ?? "?"} refs/heads/${track.name}`);
1054
+ }
1055
+ const head = lines.find((track) => track.name === "main") ?? lines[0];
1056
+ if (head)
1057
+ send(`@refs/heads/${head.name} HEAD`);
1058
+ }
1059
+ catch (error) {
1060
+ say(`Could not read the remote: ${error.message}`);
1061
+ }
1062
+ send("");
1063
+ continue;
1064
+ }
1065
+ if (command.startsWith("import ")) {
1066
+ /*
1067
+ Deduplicated. Git asks for the same ref more than once in one batch —
1068
+ observed twice for a clone, once for the branch and once resolving
1069
+ HEAD — and importing it twice would rebuild and re-send the entire
1070
+ history for no reason.
1071
+ */
1072
+ const ref = command.slice("import ".length).trim();
1073
+ if (!importing.includes(ref))
1074
+ importing.push(ref);
1075
+ continue;
1076
+ }
1077
+ if (command.startsWith("push ")) {
1078
+ const spec = command.slice("push ".length);
1079
+ const force = spec.startsWith("+");
1080
+ const [src, dst] = (force ? spec.slice(1) : spec).split(":");
1081
+ pending.push({ src: src ?? "", dst: dst ?? "", force });
1082
+ continue;
1083
+ }
1084
+ if (command === "") {
1085
+ if (importing.length) {
1086
+ const batch = importing.splice(0, importing.length);
1087
+ try {
1088
+ await doImport(batch, url);
1089
+ }
1090
+ catch (error) {
1091
+ say(`Import failed: ${error instanceof Error ? error.message : String(error)}`);
1092
+ // `done` regardless, so fast-import closes cleanly instead of git
1093
+ // waiting on a stream that will never end.
1094
+ send("done");
1095
+ }
1096
+ continue;
1097
+ }
1098
+ if (pending.length) {
1099
+ const batch = pending.splice(0, pending.length);
1100
+ try {
1101
+ const account = await (0, api_js_1.whoami)();
1102
+ say(`Pushing to CodeRook as ${account.username || account.displayName}.`);
1103
+ }
1104
+ catch {
1105
+ say("Not signed in. Run `cbx sign-in` first.");
1106
+ for (const request of batch) {
1107
+ send(`error ${request.dst} not signed in to CodeRook`);
1108
+ }
1109
+ send("");
1110
+ continue;
1111
+ }
1112
+ await doPush(batch, url);
1113
+ }
1114
+ continue;
1115
+ }
1116
+ }
1117
+ return 0;
1118
+ }