@coderook/cli 0.29.4 → 0.30.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/dist/cli/src/api.js +46 -1
- package/dist/cli/src/cli.js +118 -7
- package/dist/cli/src/diff_command.js +218 -0
- package/dist/desktop-app/src/main/detect.js +91 -5
- package/dist/desktop-app/src/main/rules.js +21 -3
- package/dist/desktop-app/src/main/transfer_progress.js +109 -0
- package/dist/desktop-app/src/main/upload.js +617 -559
- package/dist/desktop-app/src/main/worktree.js +158 -5
- package/dist/desktop-app/src/shared/merge_diff.js +191 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "coderook",
|
|
3
3
|
"displayName": "CodeRook",
|
|
4
4
|
"description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.30.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/api.js
CHANGED
|
@@ -18,6 +18,8 @@ exports.cancelMerge = cancelMerge;
|
|
|
18
18
|
exports.aiAccess = aiAccess;
|
|
19
19
|
exports.setAiAccess = setAiAccess;
|
|
20
20
|
exports.updateProject = updateProject;
|
|
21
|
+
exports.compareVersions = compareVersions;
|
|
22
|
+
exports.fileTextAt = fileTextAt;
|
|
21
23
|
exports.versions = versions;
|
|
22
24
|
exports.markRelease = markRelease;
|
|
23
25
|
exports.issues = issues;
|
|
@@ -313,7 +315,50 @@ async function updateProject(repositoryId, change) {
|
|
|
313
315
|
body: change,
|
|
314
316
|
});
|
|
315
317
|
}
|
|
316
|
-
/**
|
|
318
|
+
/**
|
|
319
|
+
* What changed between two saved versions.
|
|
320
|
+
*
|
|
321
|
+
* The service answers this from the stored trees, so an untouched folder deep
|
|
322
|
+
* in the project costs nothing: identical subtree ids short-circuit before
|
|
323
|
+
* either side is listed. That is why this asks the service rather than
|
|
324
|
+
* fetching both file lists and comparing them here — on a project of twenty
|
|
325
|
+
* thousand files the difference is one request against two very large ones.
|
|
326
|
+
*/
|
|
327
|
+
async function compareVersions(repositoryId, from, to) {
|
|
328
|
+
const body = await call(`/v1/repositories/${repositoryId}/compare` +
|
|
329
|
+
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
|
330
|
+
return body.changes ?? [];
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* One file's contents at one version, as text.
|
|
334
|
+
*
|
|
335
|
+
* Returns null for anything that is not text — a diff of a PNG is noise, and
|
|
336
|
+
* deciding that here keeps every caller from having to guess. The byte cap is
|
|
337
|
+
* the same reasoning: past it a file is data, and a line-by-line comparison of
|
|
338
|
+
* data is neither readable nor quick.
|
|
339
|
+
*/
|
|
340
|
+
async function fileTextAt(repositoryId, versionId, filePath, mostBytes = 2 * 1024 * 1024) {
|
|
341
|
+
const token = await (0, config_js_1.loadToken)();
|
|
342
|
+
if (!token)
|
|
343
|
+
throw new Error("Not signed in. Run: cbx sign-in");
|
|
344
|
+
const route = `${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}` +
|
|
345
|
+
`/versions/${versionId}/file?path=${encodeURIComponent(filePath)}`;
|
|
346
|
+
const response = await fetch(route, {
|
|
347
|
+
headers: {
|
|
348
|
+
authorization: `Bearer ${token}`,
|
|
349
|
+
"user-agent": "CodeRook-CLI/0.1",
|
|
350
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
351
|
+
range: `bytes=0-${mostBytes - 1}`,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
if (!response.ok)
|
|
355
|
+
return null;
|
|
356
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
357
|
+
/* A NUL byte is the cheapest reliable sign that this is not text. */
|
|
358
|
+
if (bytes.includes(0))
|
|
359
|
+
return null;
|
|
360
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
361
|
+
}
|
|
317
362
|
async function versions(repositoryId) {
|
|
318
363
|
const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/versions`);
|
|
319
364
|
return (body.versions ?? []).map((row) => ({
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -35,11 +35,13 @@ const skill_command_js_1 = require("./skill_command.js");
|
|
|
35
35
|
const licence_commands_js_1 = require("./licence_commands.js");
|
|
36
36
|
const service_commands_js_1 = require("./service_commands.js");
|
|
37
37
|
const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
|
|
38
|
+
const rules_js_1 = require("../../desktop-app/src/main/rules.js");
|
|
38
39
|
const upload_js_1 = require("../../desktop-app/src/main/upload.js");
|
|
39
40
|
const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
40
41
|
const faults_js_1 = require("../../desktop-app/src/main/faults.js");
|
|
41
42
|
const version_commands_js_1 = require("./version_commands.js");
|
|
42
43
|
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
44
|
+
const diff_command_js_1 = require("./diff_command.js");
|
|
43
45
|
const profile_js_1 = require("../../desktop-app/src/main/profile.js");
|
|
44
46
|
const detect_js_1 = require("../../desktop-app/src/main/detect.js");
|
|
45
47
|
const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
|
|
@@ -348,7 +350,22 @@ async function commandStatus(parsed) {
|
|
|
348
350
|
console.log(dim("Offline — comparing with the last Version cached for this folder."));
|
|
349
351
|
}
|
|
350
352
|
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
351
|
-
|
|
353
|
+
/*
|
|
354
|
+
Scanned the way `cbx diff` scans, and reported the way `cbx submit` acts.
|
|
355
|
+
|
|
356
|
+
Status used to ask only for additions and updates, which is exactly what a
|
|
357
|
+
plain save sends — so it was not wrong. But a deleted file then appeared
|
|
358
|
+
nowhere: `cbx diff` showed it as removed, `cbx status` said nothing, and
|
|
359
|
+
the two commands disagreed about the same folder. Somebody who had deleted
|
|
360
|
+
a file read that as CodeRook not having noticed it.
|
|
361
|
+
|
|
362
|
+
So deletions are found and named here, kept apart from what a save will
|
|
363
|
+
send, with the flag that would send them spelled out. The safe default
|
|
364
|
+
does not change: a save adds and updates.
|
|
365
|
+
*/
|
|
366
|
+
const found = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, "synchronize");
|
|
367
|
+
const gone = found.filter((file) => file.deleted);
|
|
368
|
+
const files = found.filter((file) => !file.deleted);
|
|
352
369
|
console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
|
|
353
370
|
console.log(link
|
|
354
371
|
? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} files ${dim(`(${link.slug})`)}`
|
|
@@ -369,18 +386,85 @@ async function commandStatus(parsed) {
|
|
|
369
386
|
for (const [path] of stale.slice(0, 10))
|
|
370
387
|
console.log(dim(` behind ${path}`));
|
|
371
388
|
}
|
|
389
|
+
/** The deletions, and what a plain save will do about them. */
|
|
390
|
+
/*
|
|
391
|
+
What this folder is, and what its own rules never heard about.
|
|
392
|
+
|
|
393
|
+
A real .gitignore always wins — that is not negotiable — but it also meant
|
|
394
|
+
that a Unity project whose file was written before the engine rules
|
|
395
|
+
existed went on sending its entire import cache, and nothing anywhere
|
|
396
|
+
said so. One real upload of forty-seven thousand files was forty-five
|
|
397
|
+
thousand of rebuildable cache, and it broke the project on the machine it
|
|
398
|
+
was downloaded to.
|
|
399
|
+
*/
|
|
400
|
+
const engine = async () => {
|
|
401
|
+
const { kind, missing } = await (0, worktree_js_1.missingEngineRules)(folder);
|
|
402
|
+
if (!kind.length || !missing.length)
|
|
403
|
+
return;
|
|
404
|
+
/*
|
|
405
|
+
Only the rules that would actually leave something out.
|
|
406
|
+
|
|
407
|
+
Naming every line the template has and this folder does not produced
|
|
408
|
+
sixty-odd of them, most matching nothing at all — a Visual Studio
|
|
409
|
+
template mentions `[Dd]ebug/x64/` whether or not you have one. What a
|
|
410
|
+
person needs is the two that are costing them forty thousand files, so
|
|
411
|
+
each candidate is measured against what this folder is currently
|
|
412
|
+
sending and the ones worth nothing are not mentioned.
|
|
413
|
+
*/
|
|
414
|
+
const sending = found.map((one) => one.path);
|
|
415
|
+
const worth = missing
|
|
416
|
+
.map((rule) => {
|
|
417
|
+
const layers = [{ base: "", rules: (0, rules_js_1.parseRules)(rule) }];
|
|
418
|
+
return {
|
|
419
|
+
rule,
|
|
420
|
+
files: sending.filter((one) => (0, rules_js_1.excludes)(one, false, layers)).length,
|
|
421
|
+
};
|
|
422
|
+
})
|
|
423
|
+
.filter((one) => one.files > 0)
|
|
424
|
+
.sort((left, right) => right.files - left.files);
|
|
425
|
+
if (!worth.length)
|
|
426
|
+
return;
|
|
427
|
+
const total = worth.reduce((sum, one) => sum + one.files, 0);
|
|
428
|
+
console.log(dim(`\nThis looks like a ${kind.join(" + ")} project, and its own rules are ` +
|
|
429
|
+
`missing ${worth.length} that would leave out ` +
|
|
430
|
+
`${total.toLocaleString()} file${total === 1 ? "" : "s"} you are sending — ` +
|
|
431
|
+
`${accent("cbx ignore --template " + kind[0])} adds them.`));
|
|
432
|
+
for (const one of worth.slice(0, 6)) {
|
|
433
|
+
console.log(dim(` ${String(one.files).padStart(7)} ${one.rule}`));
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const deletions = () => {
|
|
437
|
+
if (!gone.length)
|
|
438
|
+
return;
|
|
439
|
+
const many = gone.length === 1 ? "" : "s";
|
|
440
|
+
console.log(dim(`\n${gone.length} file${many} deleted here ${gone.length === 1 ? "is" : "are"} ` +
|
|
441
|
+
`still in the saved version. A save adds and updates, so ` +
|
|
442
|
+
`${gone.length === 1 ? "it stays" : "they stay"} until you pass ` +
|
|
443
|
+
`${accent("--sync")}.`));
|
|
444
|
+
for (const file of gone.slice(0, 10)) {
|
|
445
|
+
console.log(` ${red("deleted")} ${file.path}`);
|
|
446
|
+
}
|
|
447
|
+
if (gone.length > 10)
|
|
448
|
+
console.log(dim(` …and ${gone.length - 10} more`));
|
|
449
|
+
};
|
|
372
450
|
if (!files.length) {
|
|
373
451
|
console.log(stale.length
|
|
374
452
|
? "Nothing to submit; your own work is all saved."
|
|
375
|
-
:
|
|
453
|
+
: gone.length
|
|
454
|
+
? "Nothing here to add or update."
|
|
455
|
+
: "Nothing to submit; this folder matches the saved version.");
|
|
456
|
+
deletions();
|
|
457
|
+
await engine();
|
|
376
458
|
return 0;
|
|
377
459
|
}
|
|
378
460
|
console.log(`\n${files.length} file${files.length === 1 ? "" : "s"} to submit:`);
|
|
379
461
|
for (const file of files.slice(0, 50)) {
|
|
380
|
-
console.log(` ${
|
|
462
|
+
console.log(` ${accent("changed")} ${file.path}`);
|
|
381
463
|
}
|
|
382
464
|
if (files.length > 50)
|
|
383
465
|
console.log(dim(` …and ${files.length - 50} more`));
|
|
466
|
+
deletions();
|
|
467
|
+
await engine();
|
|
384
468
|
return 0;
|
|
385
469
|
}
|
|
386
470
|
/**
|
|
@@ -1310,11 +1394,14 @@ async function commandRules(parsed) {
|
|
|
1310
1394
|
return suggestRules(folder, hasFlag(parsed, "apply"));
|
|
1311
1395
|
}
|
|
1312
1396
|
if (hasFlag(parsed, "init")) {
|
|
1397
|
+
/*
|
|
1398
|
+
Whatever `readRules` decided, which is now the template that matches the
|
|
1399
|
+
folder rather than one generic list. Writing STARTER_IGNORE here would
|
|
1400
|
+
have handed a Unity project the JavaScript rules, which is how an upload
|
|
1401
|
+
came back missing things inside Assets.
|
|
1402
|
+
*/
|
|
1313
1403
|
const existing = await (0, worktree_js_1.readRules)(folder);
|
|
1314
|
-
await (0, worktree_js_1.writeRules)(folder,
|
|
1315
|
-
shared: existing.shared.trim() ? existing.shared : worktree_js_1.STARTER_IGNORE,
|
|
1316
|
-
local: existing.local,
|
|
1317
|
-
});
|
|
1404
|
+
await (0, worktree_js_1.writeRules)(folder, existing);
|
|
1318
1405
|
console.log(`Wrote ${node_path_1.default.join(folder, ".gitignore")}`);
|
|
1319
1406
|
return 0;
|
|
1320
1407
|
}
|
|
@@ -1722,6 +1809,30 @@ const SPECS = [
|
|
|
1722
1809
|
],
|
|
1723
1810
|
run: commandSubmit,
|
|
1724
1811
|
},
|
|
1812
|
+
{
|
|
1813
|
+
name: "diff",
|
|
1814
|
+
aliases: ["changes"],
|
|
1815
|
+
group: "Working with a folder",
|
|
1816
|
+
summary: "what changed, line by line",
|
|
1817
|
+
usage: "diff [from] [to]",
|
|
1818
|
+
detail: "With nothing after it, compares this folder against the version it is\n" +
|
|
1819
|
+
"on. With one version, compares this folder against that one. With two,\n" +
|
|
1820
|
+
"compares two saved versions — which the service answers from its own\n" +
|
|
1821
|
+
"stored trees, so an untouched corner of a large project costs nothing.\n\n" +
|
|
1822
|
+
"A version can be given as a number, as v12, or by the name it was\n" +
|
|
1823
|
+
"released under.",
|
|
1824
|
+
options: [
|
|
1825
|
+
{ flags: "--name-only", description: "list the files and not their lines" },
|
|
1826
|
+
{ flags: "--path <text>", description: "only paths containing this" },
|
|
1827
|
+
{
|
|
1828
|
+
flags: "--context <n>",
|
|
1829
|
+
description: "lines of context around a change (default 3)",
|
|
1830
|
+
},
|
|
1831
|
+
{ flags: "--project <name>", description: "which project" },
|
|
1832
|
+
],
|
|
1833
|
+
examples: ["cbx diff", "cbx diff 12", "cbx diff 11 12 --name-only"],
|
|
1834
|
+
run: diff_command_js_1.commandDiff,
|
|
1835
|
+
},
|
|
1725
1836
|
{
|
|
1726
1837
|
name: "unlink",
|
|
1727
1838
|
group: "Working with a folder",
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.commandDiff = commandDiff;
|
|
7
|
+
/**
|
|
8
|
+
* What changed, from the terminal.
|
|
9
|
+
*
|
|
10
|
+
* This was the one central thing the command line could not do. The service
|
|
11
|
+
* has been able to compare two versions since the beginning — GET /compare
|
|
12
|
+
* answers it from the stored trees — and nothing called the route. Meanwhile
|
|
13
|
+
* the desktop application's diff shells out to `git diff`, so on a project
|
|
14
|
+
* that is not a git repository the feature is silently absent, which is an odd
|
|
15
|
+
* thing for a version host to depend on.
|
|
16
|
+
*
|
|
17
|
+
* So this reads the service's own answer and renders it. Two questions, which
|
|
18
|
+
* people mean at different times:
|
|
19
|
+
*
|
|
20
|
+
* cbx diff what have I changed since the version I am on
|
|
21
|
+
* cbx diff 12 the same, against version 12
|
|
22
|
+
* cbx diff 11 12 what changed between two saved versions
|
|
23
|
+
*/
|
|
24
|
+
const promises_1 = require("node:fs/promises");
|
|
25
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
26
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
27
|
+
const api_js_1 = require("./api.js");
|
|
28
|
+
const config_js_1 = require("./config.js");
|
|
29
|
+
const project_commands_js_1 = require("./project_commands.js");
|
|
30
|
+
const merge_diff_js_1 = require("../../desktop-app/src/shared/merge_diff.js");
|
|
31
|
+
const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
|
|
32
|
+
/*
|
|
33
|
+
Built rather than typed, so the escape character never sits literally in the
|
|
34
|
+
source. The other command files spell theirs out; this reads the same on a
|
|
35
|
+
terminal and survives being copied through a tool that eats control bytes.
|
|
36
|
+
*/
|
|
37
|
+
const ESC = String.fromCharCode(27);
|
|
38
|
+
const paint = (code) => (value) => `${ESC}[${code}m${value}${ESC}[0m`;
|
|
39
|
+
const dim = paint("2");
|
|
40
|
+
const bold = paint("1");
|
|
41
|
+
const red = paint("31");
|
|
42
|
+
const green = paint("32");
|
|
43
|
+
const accent = paint("33");
|
|
44
|
+
/*
|
|
45
|
+
The line comparison builds a table of one side's lines against the other's,
|
|
46
|
+
which is exact and quadratic. On a conflicted file — what it was written for
|
|
47
|
+
— that is a few hundred kilobytes. On this repository's own eighteen-thousand
|
|
48
|
+
line runtime it would be gigabytes, so past this a file is reported as
|
|
49
|
+
changed without its lines. Saying so beats both wedging the terminal and
|
|
50
|
+
printing a heuristic diff that quietly invents a change.
|
|
51
|
+
*/
|
|
52
|
+
const MOST_LINES_COMPARED = 4_000;
|
|
53
|
+
/** How a change reads at a glance. */
|
|
54
|
+
const MARK = {
|
|
55
|
+
added: green("+"),
|
|
56
|
+
removed: red("-"),
|
|
57
|
+
changed: accent("~"),
|
|
58
|
+
};
|
|
59
|
+
/** Print one file's lines, folded to the interesting parts. */
|
|
60
|
+
function printHunks(before, after, context) {
|
|
61
|
+
const left = (0, merge_diff_js_1.linesOf)(before);
|
|
62
|
+
const right = (0, merge_diff_js_1.linesOf)(after);
|
|
63
|
+
if (left.length > MOST_LINES_COMPARED || right.length > MOST_LINES_COMPARED) {
|
|
64
|
+
console.log(dim(` ${Math.max(left.length, right.length).toLocaleString()} lines — ` +
|
|
65
|
+
`too large to compare line by line`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const chunk of (0, merge_diff_js_1.foldUnchanged)((0, merge_diff_js_1.diffLines)(before, after), context)) {
|
|
69
|
+
if (chunk.kind === "gap") {
|
|
70
|
+
console.log(dim(` ... ${chunk.skipped} unchanged`));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const line of chunk.lines) {
|
|
74
|
+
const at = String(line.candidateLine ?? line.targetLine ?? "").padStart(5);
|
|
75
|
+
if (line.side === "same")
|
|
76
|
+
console.log(dim(`${at} ${line.text}`));
|
|
77
|
+
else if (line.side === "candidate")
|
|
78
|
+
console.log(`${dim(at)} ${green("+")} ${green(line.text)}`);
|
|
79
|
+
else
|
|
80
|
+
console.log(`${dim(at)} ${red("-")} ${red(line.text)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Resolve `12`, `v12` or a version's name against the newest-first list. */
|
|
85
|
+
function versionBy(list, reference) {
|
|
86
|
+
const bare = reference.replace(/^v/i, "");
|
|
87
|
+
if (/^[0-9]+$/.test(bare)) {
|
|
88
|
+
const found = list.find((one) => one.sequence === Number(bare));
|
|
89
|
+
return found ? { id: found.id, sequence: found.sequence } : null;
|
|
90
|
+
}
|
|
91
|
+
const named = list.find((one) => (one.name ?? "").toLowerCase() === reference.toLowerCase());
|
|
92
|
+
return named ? { id: named.id, sequence: named.sequence } : null;
|
|
93
|
+
}
|
|
94
|
+
/** Whether these bytes are something a line comparison can speak about. */
|
|
95
|
+
function isText(value) {
|
|
96
|
+
return !value.includes(String.fromCharCode(0));
|
|
97
|
+
}
|
|
98
|
+
async function commandDiff(parsed) {
|
|
99
|
+
const nameOnly = parsed.flags.has("name-only");
|
|
100
|
+
const context = Number(parsed.flags.get("context") ?? 3) || 3;
|
|
101
|
+
const only = parsed.flags.get("path");
|
|
102
|
+
const named = parsed.flags.get("project");
|
|
103
|
+
const project = await (0, project_commands_js_1.resolveProject)(typeof named === "string" ? named : undefined);
|
|
104
|
+
if (!project)
|
|
105
|
+
return 1;
|
|
106
|
+
const list = await (0, api_js_1.versions)(project.id);
|
|
107
|
+
if (!list.length) {
|
|
108
|
+
console.log("Nothing saved yet, so there is nothing to compare.");
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
const [first, second] = parsed.positional;
|
|
112
|
+
/*
|
|
113
|
+
Two saved versions, compared by the service. That is the whole point of
|
|
114
|
+
asking it rather than fetching both file lists: it walks the stored trees
|
|
115
|
+
and skips any subtree whose id has not moved, so an untouched corner of a
|
|
116
|
+
twenty-thousand-file project costs nothing.
|
|
117
|
+
*/
|
|
118
|
+
if (first && second) {
|
|
119
|
+
const from = versionBy(list, first);
|
|
120
|
+
const to = versionBy(list, second);
|
|
121
|
+
if (!from || !to) {
|
|
122
|
+
console.error(red(`No such version: ${from ? second : first}`));
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
const changes = (await (0, api_js_1.compareVersions)(project.id, from.id, to.id))
|
|
126
|
+
.filter((change) => !only || change.path.includes(String(only)))
|
|
127
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
128
|
+
console.log(`${bold(project.slug)} ${dim(`v${from.sequence} -> v${to.sequence}`)}`);
|
|
129
|
+
if (!changes.length) {
|
|
130
|
+
console.log("Nothing differs between them.");
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
for (const change of changes) {
|
|
134
|
+
console.log(`${MARK[change.kind]} ${change.path}`);
|
|
135
|
+
if (nameOnly || change.kind !== "changed")
|
|
136
|
+
continue;
|
|
137
|
+
const [before, after] = await Promise.all([
|
|
138
|
+
(0, api_js_1.fileTextAt)(project.id, from.id, change.path),
|
|
139
|
+
(0, api_js_1.fileTextAt)(project.id, to.id, change.path),
|
|
140
|
+
]);
|
|
141
|
+
if (before === null || after === null) {
|
|
142
|
+
console.log(dim(" not text"));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
printHunks(before, after, context);
|
|
146
|
+
}
|
|
147
|
+
const many = changes.length === 1 ? "" : "s";
|
|
148
|
+
console.log(dim(`\n${changes.length} file${many} differ${many ? "" : "s"}`));
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
/*
|
|
152
|
+
This folder against a saved version — the question somebody has open in
|
|
153
|
+
front of them, and the reason `cbx status` was never enough on its own: it
|
|
154
|
+
says which files changed and never what changed inside them.
|
|
155
|
+
*/
|
|
156
|
+
const link = await (0, config_js_1.readLink)(node_process_1.default.cwd());
|
|
157
|
+
if (!link) {
|
|
158
|
+
console.error(red("This folder is not linked to a project, so there is no local side."));
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
const against = first
|
|
162
|
+
? versionBy(list, first)
|
|
163
|
+
: { id: link.baseVersionId ?? list[0].id, sequence: link.sequence };
|
|
164
|
+
if (!against) {
|
|
165
|
+
console.error(red(`No such version: ${first}`));
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
const folder = node_process_1.default.cwd();
|
|
169
|
+
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
170
|
+
/*
|
|
171
|
+
Which files differ is decided here, from digests, before anything is
|
|
172
|
+
fetched.
|
|
173
|
+
|
|
174
|
+
The first version of this compared contents, which meant downloading every
|
|
175
|
+
file in the project to find out which ones had moved — on this repository
|
|
176
|
+
that is eleven hundred requests to report a handful of edits. `changedFiles`
|
|
177
|
+
already answers the question locally and keeps a digest cache while it does
|
|
178
|
+
it, so the service is asked only for the files that actually differ.
|
|
179
|
+
*/
|
|
180
|
+
const saved = link.manifest ?? {};
|
|
181
|
+
const changed = (await (0, worktree_js_1.changedFiles)(folder, rules, new Map(Object.entries(saved)), "synchronize"))
|
|
182
|
+
.filter((one) => !only || one.path.includes(String(only)))
|
|
183
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
184
|
+
console.log(`${bold(node_path_1.default.basename(folder))} ${dim(`this folder -> v${against.sequence}`)}`);
|
|
185
|
+
let differing = 0;
|
|
186
|
+
for (const one of changed) {
|
|
187
|
+
const wasSaved = Object.prototype.hasOwnProperty.call(saved, one.path);
|
|
188
|
+
const kind = one.deleted
|
|
189
|
+
? "removed"
|
|
190
|
+
: wasSaved
|
|
191
|
+
? "changed"
|
|
192
|
+
: "added";
|
|
193
|
+
differing += 1;
|
|
194
|
+
console.log(`${MARK[kind]} ${one.path}`);
|
|
195
|
+
if (nameOnly || kind !== "changed")
|
|
196
|
+
continue;
|
|
197
|
+
if (one.binary) {
|
|
198
|
+
console.log(dim(" not text"));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const [before, after] = await Promise.all([
|
|
202
|
+
(0, api_js_1.fileTextAt)(project.id, against.id, one.path),
|
|
203
|
+
(0, promises_1.readFile)(node_path_1.default.join(folder, one.path), "utf8").catch(() => null),
|
|
204
|
+
]);
|
|
205
|
+
if (before === null || after === null || !isText(after)) {
|
|
206
|
+
console.log(dim(" not text"));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
printHunks(before, after, context);
|
|
210
|
+
}
|
|
211
|
+
if (!differing) {
|
|
212
|
+
console.log("This folder matches the saved version.");
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
const many = differing === 1 ? "" : "s";
|
|
216
|
+
console.log(dim(`\n${differing} file${many} differ${many ? "" : "s"}`));
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.STARTER_TOPUP_HEADING = exports.SUGGESTED_HEADING = void 0;
|
|
7
|
+
exports.templatesFor = templatesFor;
|
|
6
8
|
exports.suggestExclusions = suggestExclusions;
|
|
7
9
|
exports.suggestBulkDirectories = suggestBulkDirectories;
|
|
8
10
|
exports.rulesFromSuggestions = rulesFromSuggestions;
|
|
@@ -121,6 +123,83 @@ const norm = (value) => value.split("\\").join("/");
|
|
|
121
123
|
* their upload will take and the biggest item answers it. Anything matching
|
|
122
124
|
* nothing is simply absent — silence is the right output for a tidy folder.
|
|
123
125
|
*/
|
|
126
|
+
/**
|
|
127
|
+
* Which of github/gitignore's templates this folder is asking for.
|
|
128
|
+
*
|
|
129
|
+
* The templates have been vendored all along — three hundred of them, taken
|
|
130
|
+
* from github/gitignore — but nothing ever chose one. A folder with no rules
|
|
131
|
+
* of its own got the generic starter instead, and for a game project that is
|
|
132
|
+
* the wrong list: it carries `build/`, `dist/`, `out/` and `models/**` from
|
|
133
|
+
* the JavaScript and Python worlds, none of which are anchored, all of which
|
|
134
|
+
* can reach into somewhere like `Assets/Art/models`.
|
|
135
|
+
*
|
|
136
|
+
* Recognised from the files a project must have to be that kind of project,
|
|
137
|
+
* rather than from anything a person configures. Order matters: an engine is
|
|
138
|
+
* decided first, because a Unity project also has a package.json somewhere
|
|
139
|
+
* and the engine's own template is the one that knows about `Assets`.
|
|
140
|
+
*/
|
|
141
|
+
function templatesFor(entries) {
|
|
142
|
+
const here = new Set(entries.map((name) => name.toLowerCase()));
|
|
143
|
+
const any = (test) => entries.some(test);
|
|
144
|
+
const picked = [];
|
|
145
|
+
const take = (key) => {
|
|
146
|
+
if (!picked.includes(key))
|
|
147
|
+
picked.push(key);
|
|
148
|
+
};
|
|
149
|
+
/* An engine first, and at most one: their templates overlap and disagree. */
|
|
150
|
+
if (here.has("assets") && here.has("projectsettings"))
|
|
151
|
+
take("unity");
|
|
152
|
+
else if (any((name) => name.toLowerCase().endsWith(".uproject"))) {
|
|
153
|
+
take("unrealengine");
|
|
154
|
+
}
|
|
155
|
+
else if (here.has("project.godot"))
|
|
156
|
+
take("godot");
|
|
157
|
+
/*
|
|
158
|
+
Cocos before Node, and this order is the whole reason it is written down.
|
|
159
|
+
|
|
160
|
+
A Cocos project carries a package.json, so recognising Node first called
|
|
161
|
+
it a Node project and handed it Node's rules — which know nothing about
|
|
162
|
+
`library` or `temp`, the caches Cocos rebuilds. Measured on the soak
|
|
163
|
+
shape: eight and a half thousand cache files went from left out to sent.
|
|
164
|
+
*/
|
|
165
|
+
else if (here.has("project.json") && here.has("assets"))
|
|
166
|
+
take("cocos");
|
|
167
|
+
/* Then the languages, of which a folder may honestly be several. */
|
|
168
|
+
if (here.has("package.json"))
|
|
169
|
+
take("node");
|
|
170
|
+
if (here.has("pyproject.toml") ||
|
|
171
|
+
here.has("requirements.txt") ||
|
|
172
|
+
here.has("setup.py")) {
|
|
173
|
+
take("python");
|
|
174
|
+
}
|
|
175
|
+
if (here.has("cargo.toml"))
|
|
176
|
+
take("rust");
|
|
177
|
+
if (here.has("go.mod"))
|
|
178
|
+
take("go");
|
|
179
|
+
if (here.has("build.gradle") || here.has("build.gradle.kts"))
|
|
180
|
+
take("gradle");
|
|
181
|
+
/*
|
|
182
|
+
An engine's own project files are not a second kind of project.
|
|
183
|
+
|
|
184
|
+
Unity writes the .sln and the .csproj itself, and every engine here does
|
|
185
|
+
something similar. Reading that as "also a Visual Studio project" and
|
|
186
|
+
stacking GitHub's template on top is not a harmless extra: that template
|
|
187
|
+
ignores `*.meta`, which in the .NET world is build output and in Unity is
|
|
188
|
+
the sidecar carrying every asset's GUID. On a real project it would have
|
|
189
|
+
dropped 13,689 of them, and a Unity project without its metas is exactly
|
|
190
|
+
the broken-materials mess this was all started by.
|
|
191
|
+
*/
|
|
192
|
+
const engine = picked.length > 0;
|
|
193
|
+
if (!engine && any((name) => name.toLowerCase().endsWith(".sln"))) {
|
|
194
|
+
take("visualstudio");
|
|
195
|
+
}
|
|
196
|
+
if (!engine && any((name) => name.toLowerCase().endsWith(".xcodeproj"))) {
|
|
197
|
+
take("objective-c");
|
|
198
|
+
}
|
|
199
|
+
if (!engine && here.has("cmakelists.txt"))
|
|
200
|
+
take("cmake");
|
|
201
|
+
return picked;
|
|
202
|
+
}
|
|
124
203
|
function suggestExclusions(files) {
|
|
125
204
|
const totals = new Map();
|
|
126
205
|
for (const entry of files) {
|
|
@@ -267,12 +346,19 @@ function suggestBulkDirectories(files) {
|
|
|
267
346
|
* looking: the detector proposes, this records a decision, and the two are
|
|
268
347
|
* only connected by somebody choosing.
|
|
269
348
|
*/
|
|
349
|
+
/**
|
|
350
|
+
* The one heading under which accepted suggestions are recorded.
|
|
351
|
+
*
|
|
352
|
+
* Exported because three places wrote three different sentences for the same
|
|
353
|
+
* idea, and a .gitignore could end up carrying all of them. The tick screen
|
|
354
|
+
* keeps its own CHOSEN_HEADING: that block is a different statement, and it is
|
|
355
|
+
* rewritten wholesale where this one is added to.
|
|
356
|
+
*/
|
|
357
|
+
exports.SUGGESTED_HEADING = "# Suggested for this folder, and accepted.";
|
|
358
|
+
/** And the one under which the starter rules are brought up to date. */
|
|
359
|
+
exports.STARTER_TOPUP_HEADING = "# Added when the starter rules were brought up to date.";
|
|
270
360
|
function rulesFromSuggestions(accepted) {
|
|
271
361
|
if (!accepted.length)
|
|
272
362
|
return "";
|
|
273
|
-
return [
|
|
274
|
-
"# Suggested when this folder was added, and accepted.",
|
|
275
|
-
...accepted.map((one) => one.pattern),
|
|
276
|
-
"",
|
|
277
|
-
].join("\n");
|
|
363
|
+
return [exports.SUGGESTED_HEADING, ...accepted.map((one) => one.pattern), ""].join("\n");
|
|
278
364
|
}
|
|
@@ -7,6 +7,7 @@ exports.parseRules = parseRules;
|
|
|
7
7
|
exports.ruleMatches = ruleMatches;
|
|
8
8
|
exports.lastMatchingRule = lastMatchingRule;
|
|
9
9
|
exports.decide = decide;
|
|
10
|
+
exports.decidingRule = decidingRule;
|
|
10
11
|
exports.excludes = excludes;
|
|
11
12
|
exports.negationReachesInto = negationReachesInto;
|
|
12
13
|
exports.isIgnored = isIgnored;
|
|
@@ -198,7 +199,21 @@ function decide(relativePath, isDirectory, layers) {
|
|
|
198
199
|
* is the dangerous direction to differ in: it uploads what somebody thought
|
|
199
200
|
* they had excluded.
|
|
200
201
|
*/
|
|
201
|
-
|
|
202
|
+
/**
|
|
203
|
+
* The rule that settles this path, counting the directories above it.
|
|
204
|
+
*
|
|
205
|
+
* A directory rule is written about the directory — `/[Ll]ibrary/` matches
|
|
206
|
+
* `Library`, not `Library/Artifacts/f1.dat` — so anything asking only about
|
|
207
|
+
* the file finds no rule and calls it included. The upload has always walked
|
|
208
|
+
* the ancestors; the filter screen asked about the file alone, and so showed
|
|
209
|
+
* a Unity project's whole import cache as going up when the save would have
|
|
210
|
+
* left every byte of it behind.
|
|
211
|
+
*
|
|
212
|
+
* Returned as the rule rather than a yes or no, because the screen names the
|
|
213
|
+
* line responsible — "Library/ — 400 files" is the useful form, and a boolean
|
|
214
|
+
* cannot say it.
|
|
215
|
+
*/
|
|
216
|
+
function decidingRule(relativePath, isDirectory, layers) {
|
|
202
217
|
/*
|
|
203
218
|
Ancestors first, outermost in. The moment one is excluded the answer is
|
|
204
219
|
settled and nothing below it can appeal.
|
|
@@ -208,9 +223,12 @@ function excludes(relativePath, isDirectory, layers) {
|
|
|
208
223
|
const ancestor = parts.slice(0, depth).join("/");
|
|
209
224
|
const above = decide(ancestor, true, layers);
|
|
210
225
|
if (above && !above.negated)
|
|
211
|
-
return
|
|
226
|
+
return above;
|
|
212
227
|
}
|
|
213
|
-
|
|
228
|
+
return decide(relativePath, isDirectory, layers);
|
|
229
|
+
}
|
|
230
|
+
function excludes(relativePath, isDirectory, layers) {
|
|
231
|
+
const rule = decidingRule(relativePath, isDirectory, layers);
|
|
214
232
|
return Boolean(rule && !rule.negated);
|
|
215
233
|
}
|
|
216
234
|
/**
|