@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +342 -220
- package/dist/cli/src/api.js +27 -2
- package/dist/cli/src/cli.js +323 -87
- package/dist/cli/src/git_history.js +188 -0
- package/dist/cli/src/git_remote.js +1118 -0
- package/dist/cli/src/git_remote_bin.js +22 -0
- package/dist/cli/src/help.js +8 -8
- package/dist/cli/src/import_command.js +150 -0
- package/dist/cli/src/licence_commands.js +5 -5
- package/dist/cli/src/mcp.js +1 -1
- package/dist/cli/src/publish.js +37 -0
- package/dist/cli/src/registry.js +1 -1
- package/dist/cli/src/runner.js +2 -2
- package/dist/cli/src/service_commands.js +53 -3
- package/dist/cli/src/skill_command.js +3 -3
- package/dist/cli/src/track_commands.js +7 -7
- package/dist/desktop-app/src/main/tracks.js +12 -2
- package/dist/desktop-app/src/main/upload.js +11 -1
- package/package.json +53 -51
- package/skills/coderook/SKILL.md +130 -130
|
@@ -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
|
+
}
|