@coderook/cli 0.29.3 → 0.29.5
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 +40 -1
- package/dist/cli/src/diff_command.js +218 -0
- package/dist/desktop-app/src/main/detect.js +107 -9
- package/dist/desktop-app/src/main/worktree.js +77 -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.29.
|
|
5
|
+
"version": "0.29.5",
|
|
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
|
@@ -40,6 +40,7 @@ const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
|
40
40
|
const faults_js_1 = require("../../desktop-app/src/main/faults.js");
|
|
41
41
|
const version_commands_js_1 = require("./version_commands.js");
|
|
42
42
|
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
43
|
+
const diff_command_js_1 = require("./diff_command.js");
|
|
43
44
|
const profile_js_1 = require("../../desktop-app/src/main/profile.js");
|
|
44
45
|
const detect_js_1 = require("../../desktop-app/src/main/detect.js");
|
|
45
46
|
const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
|
|
@@ -645,7 +646,21 @@ Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
|
|
|
645
646
|
file should be sent and the fix is to take the key out of it.
|
|
646
647
|
*/
|
|
647
648
|
const pasted = await (0, worktree_js_1.detectPastedCredentials)(folder, files.map((file) => file.path));
|
|
648
|
-
const stillPasted = pasted.filter((finding) => !exposed.includes(finding.path));
|
|
649
|
+
const stillPasted = pasted.found.filter((finding) => !exposed.includes(finding.path));
|
|
650
|
+
/*
|
|
651
|
+
Said whether anything was found or not.
|
|
652
|
+
|
|
653
|
+
The scan stops at twenty thousand files, and it used to stop in silence —
|
|
654
|
+
so a project of forty-seven thousand had twenty-seven thousand of them
|
|
655
|
+
never opened behind a clean run, and the terminal said nothing at all. A
|
|
656
|
+
check that quietly covers half of what it appears to cover is worse than
|
|
657
|
+
one that admits its limit, because the first is believed.
|
|
658
|
+
*/
|
|
659
|
+
if (!pasted.reach.complete) {
|
|
660
|
+
console.log(dim(`Checked the first ${pasted.reach.scanned.toLocaleString()} of ` +
|
|
661
|
+
`${pasted.reach.of.toLocaleString()} files for pasted keys; the ` +
|
|
662
|
+
`rest were not opened.`));
|
|
663
|
+
}
|
|
649
664
|
if (stillPasted.length) {
|
|
650
665
|
console.log(red(`
|
|
651
666
|
${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s have credentials"} inside:`));
|
|
@@ -1708,6 +1723,30 @@ const SPECS = [
|
|
|
1708
1723
|
],
|
|
1709
1724
|
run: commandSubmit,
|
|
1710
1725
|
},
|
|
1726
|
+
{
|
|
1727
|
+
name: "diff",
|
|
1728
|
+
aliases: ["changes"],
|
|
1729
|
+
group: "Working with a folder",
|
|
1730
|
+
summary: "what changed, line by line",
|
|
1731
|
+
usage: "diff [from] [to]",
|
|
1732
|
+
detail: "With nothing after it, compares this folder against the version it is\n" +
|
|
1733
|
+
"on. With one version, compares this folder against that one. With two,\n" +
|
|
1734
|
+
"compares two saved versions — which the service answers from its own\n" +
|
|
1735
|
+
"stored trees, so an untouched corner of a large project costs nothing.\n\n" +
|
|
1736
|
+
"A version can be given as a number, as v12, or by the name it was\n" +
|
|
1737
|
+
"released under.",
|
|
1738
|
+
options: [
|
|
1739
|
+
{ flags: "--name-only", description: "list the files and not their lines" },
|
|
1740
|
+
{ flags: "--path <text>", description: "only paths containing this" },
|
|
1741
|
+
{
|
|
1742
|
+
flags: "--context <n>",
|
|
1743
|
+
description: "lines of context around a change (default 3)",
|
|
1744
|
+
},
|
|
1745
|
+
{ flags: "--project <name>", description: "which project" },
|
|
1746
|
+
],
|
|
1747
|
+
examples: ["cbx diff", "cbx diff 12", "cbx diff 11 12 --name-only"],
|
|
1748
|
+
run: diff_command_js_1.commandDiff,
|
|
1749
|
+
},
|
|
1711
1750
|
{
|
|
1712
1751
|
name: "unlink",
|
|
1713
1752
|
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,7 +3,9 @@ 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;
|
|
6
7
|
exports.suggestExclusions = suggestExclusions;
|
|
8
|
+
exports.suggestBulkDirectories = suggestBulkDirectories;
|
|
7
9
|
exports.rulesFromSuggestions = rulesFromSuggestions;
|
|
8
10
|
/**
|
|
9
11
|
* Looking at a folder and saying what probably should not be uploaded.
|
|
@@ -39,6 +41,14 @@ const RULES = [
|
|
|
39
41
|
{ directory: ".next", reason: "Next.js build output", recommended: true },
|
|
40
42
|
{ directory: ".gradle", reason: "Gradle build state", recommended: true },
|
|
41
43
|
{ directory: ".terraform", reason: "Downloaded Terraform providers", recommended: true },
|
|
44
|
+
/*
|
|
45
|
+
Offered rather than recommended, and deliberately not in the starter
|
|
46
|
+
template: a Go project that vendors its dependencies often commits them on
|
|
47
|
+
purpose, so leaving them out by default would drop files the project is
|
|
48
|
+
meant to carry. Worth asking about, because it is usually most of the
|
|
49
|
+
files — 6,000 of 6,440 in the shape that found this.
|
|
50
|
+
*/
|
|
51
|
+
{ directory: "vendor", reason: "Vendored dependencies — often committed on purpose", recommended: false },
|
|
42
52
|
/*
|
|
43
53
|
Game engines, which is where the size actually is.
|
|
44
54
|
|
|
@@ -160,15 +170,96 @@ function suggestExclusions(files) {
|
|
|
160
170
|
break;
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
|
-
|
|
164
|
-
.map(([pattern, held]) => ({
|
|
173
|
+
const named = [...totals.entries()].map(([pattern, held]) => ({
|
|
165
174
|
pattern,
|
|
166
175
|
reason: held.rule.reason,
|
|
167
176
|
bytes: held.bytes,
|
|
168
177
|
files: held.files,
|
|
169
178
|
recommended: held.rule.recommended,
|
|
170
|
-
}))
|
|
171
|
-
|
|
179
|
+
}));
|
|
180
|
+
/*
|
|
181
|
+
Merged in here rather than offered through a second channel, so everything
|
|
182
|
+
that shows a suggestion keeps working unchanged. A folder already named by
|
|
183
|
+
a rule above is dropped: it has a better reason than "no source in it".
|
|
184
|
+
*/
|
|
185
|
+
const already = new Set(named.map((one) => one.pattern));
|
|
186
|
+
const bulk = suggestBulkDirectories(files).filter((one) => !already.has(one.pattern));
|
|
187
|
+
return [...named, ...bulk].sort((left, right) => right.bytes - left.bytes);
|
|
188
|
+
}
|
|
189
|
+
/*
|
|
190
|
+
Extensions that mean somebody wrote this by hand. Deliberately short: the
|
|
191
|
+
question below is only "is there any source in here at all", and a longer
|
|
192
|
+
list would answer yes for a folder holding one stray script.
|
|
193
|
+
*/
|
|
194
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
195
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".rs",
|
|
196
|
+
".java", ".kt", ".swift", ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", ".php",
|
|
197
|
+
".lua", ".sh", ".ps1", ".sql", ".css", ".scss", ".html", ".vue", ".svelte",
|
|
198
|
+
".md", ".toml", ".yaml", ".yml", ".gradle", ".cmake",
|
|
199
|
+
]);
|
|
200
|
+
/** How many files a folder must hold before it is worth asking about. */
|
|
201
|
+
const BULK_FILES = 2_000;
|
|
202
|
+
/**
|
|
203
|
+
* And how much of the upload it must be.
|
|
204
|
+
*
|
|
205
|
+
* Set high on purpose. At half, a game's 4,000-file `assets/` folder in a
|
|
206
|
+
* 6,500-file project was flagged as "no source in it" — which is true and
|
|
207
|
+
* beside the point, because the art is the work. The folders this is for are
|
|
208
|
+
* not borderline: `work/` here is 98% of the upload and a Unity `Library/`
|
|
209
|
+
* is 96%.
|
|
210
|
+
*/
|
|
211
|
+
const BULK_SHARE = 0.7;
|
|
212
|
+
/** Above this share of hand-written files it is source, not output. */
|
|
213
|
+
const BULK_SOURCE_SHARE = 0.1;
|
|
214
|
+
/**
|
|
215
|
+
* One enormous folder that no list of names could have known about.
|
|
216
|
+
*
|
|
217
|
+
* Every rule above recognises a folder by its name, which works for the dozen
|
|
218
|
+
* things every project has and not at all for the one a person invented. This
|
|
219
|
+
* repository's own `work/` is 63,210 of the 64,554 files it would otherwise
|
|
220
|
+
* send — scratch output from a test harness, named nothing in particular, and
|
|
221
|
+
* invisible to every rule in this file.
|
|
222
|
+
*
|
|
223
|
+
* So this asks about shape instead of name: one folder that is most of the
|
|
224
|
+
* upload and holds no source in it. Never recommended, because the same shape
|
|
225
|
+
* is also what a genuine dataset looks like, and a project whose work really
|
|
226
|
+
* is fifty thousand data files should be asked once and then believed.
|
|
227
|
+
*/
|
|
228
|
+
function suggestBulkDirectories(files) {
|
|
229
|
+
if (files.length < BULK_FILES)
|
|
230
|
+
return [];
|
|
231
|
+
const tally = new Map();
|
|
232
|
+
for (const entry of files) {
|
|
233
|
+
const normalised = entry.path.split("\\").join("/");
|
|
234
|
+
const top = normalised.includes("/") ? normalised.slice(0, normalised.indexOf("/")) : "";
|
|
235
|
+
/* A file at the root belongs to no folder, so it cannot be one of these. */
|
|
236
|
+
if (!top)
|
|
237
|
+
continue;
|
|
238
|
+
const held = tally.get(top) ?? { bytes: 0, files: 0, source: 0 };
|
|
239
|
+
held.bytes += entry.size;
|
|
240
|
+
held.files += 1;
|
|
241
|
+
if (SOURCE_EXTENSIONS.has(node_path_1.default.extname(normalised).toLowerCase()))
|
|
242
|
+
held.source += 1;
|
|
243
|
+
tally.set(top, held);
|
|
244
|
+
}
|
|
245
|
+
const found = [];
|
|
246
|
+
for (const [top, held] of tally) {
|
|
247
|
+
if (held.files < BULK_FILES)
|
|
248
|
+
continue;
|
|
249
|
+
if (held.files / files.length < BULK_SHARE)
|
|
250
|
+
continue;
|
|
251
|
+
if (held.source / held.files >= BULK_SOURCE_SHARE)
|
|
252
|
+
continue;
|
|
253
|
+
found.push({
|
|
254
|
+
pattern: `${top}/`,
|
|
255
|
+
reason: `${held.files.toLocaleString()} of ${files.length.toLocaleString()} files, ` +
|
|
256
|
+
`and no source in it — generated?`,
|
|
257
|
+
bytes: held.bytes,
|
|
258
|
+
files: held.files,
|
|
259
|
+
recommended: false,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return found.sort((left, right) => right.bytes - left.bytes);
|
|
172
263
|
}
|
|
173
264
|
/**
|
|
174
265
|
* The suggestions a person accepted, as lines for the rules file.
|
|
@@ -177,12 +268,19 @@ function suggestExclusions(files) {
|
|
|
177
268
|
* looking: the detector proposes, this records a decision, and the two are
|
|
178
269
|
* only connected by somebody choosing.
|
|
179
270
|
*/
|
|
271
|
+
/**
|
|
272
|
+
* The one heading under which accepted suggestions are recorded.
|
|
273
|
+
*
|
|
274
|
+
* Exported because three places wrote three different sentences for the same
|
|
275
|
+
* idea, and a .gitignore could end up carrying all of them. The tick screen
|
|
276
|
+
* keeps its own CHOSEN_HEADING: that block is a different statement, and it is
|
|
277
|
+
* rewritten wholesale where this one is added to.
|
|
278
|
+
*/
|
|
279
|
+
exports.SUGGESTED_HEADING = "# Suggested for this folder, and accepted.";
|
|
280
|
+
/** And the one under which the starter rules are brought up to date. */
|
|
281
|
+
exports.STARTER_TOPUP_HEADING = "# Added when the starter rules were brought up to date.";
|
|
180
282
|
function rulesFromSuggestions(accepted) {
|
|
181
283
|
if (!accepted.length)
|
|
182
284
|
return "";
|
|
183
|
-
return [
|
|
184
|
-
"# Suggested when this folder was added, and accepted.",
|
|
185
|
-
...accepted.map((one) => one.pattern),
|
|
186
|
-
"",
|
|
187
|
-
].join("\n");
|
|
285
|
+
return [exports.SUGGESTED_HEADING, ...accepted.map((one) => one.pattern), ""].join("\n");
|
|
188
286
|
}
|
|
@@ -3,7 +3,9 @@ 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.LOCAL_EXCLUDE_FILE = exports.IGNORE_FILE = exports.STARTER_IGNORE = exports.EVALUATION_FILE_LIMIT = void 0;
|
|
6
|
+
exports.LOCAL_EXCLUDE_FILE = exports.IGNORE_FILE = exports.STARTER_IGNORE = exports.STARTER_MARKER = exports.EVALUATION_FILE_LIMIT = void 0;
|
|
7
|
+
exports.starterAuthored = starterAuthored;
|
|
8
|
+
exports.missingStarterRules = missingStarterRules;
|
|
7
9
|
exports.isRepository = isRepository;
|
|
8
10
|
exports.migrateRetiredRules = migrateRetiredRules;
|
|
9
11
|
exports.readRules = readRules;
|
|
@@ -54,7 +56,10 @@ async function git(root, ...args) {
|
|
|
54
56
|
* file would produce an incomplete project that looked backed up, so likely
|
|
55
57
|
* secrets are warned about instead (docs/UPLOAD_POLICY.md).
|
|
56
58
|
*/
|
|
57
|
-
exports.
|
|
59
|
+
exports.STARTER_MARKER = "# CodeRook starter rules — edit freely below";
|
|
60
|
+
exports.STARTER_IGNORE = `${exports.STARTER_MARKER}
|
|
61
|
+
|
|
62
|
+
# Dependencies and generated output
|
|
58
63
|
node_modules/
|
|
59
64
|
dist/
|
|
60
65
|
build/
|
|
@@ -96,7 +101,6 @@ models/**
|
|
|
96
101
|
*.pth
|
|
97
102
|
|
|
98
103
|
# Caches and local state
|
|
99
|
-
.venv/
|
|
100
104
|
.pytest_cache/
|
|
101
105
|
.mypy_cache/
|
|
102
106
|
.ruff_cache/
|
|
@@ -120,6 +124,63 @@ Preferences
|
|
|
120
124
|
# Archives of the project, inside the project
|
|
121
125
|
*.cbx
|
|
122
126
|
`;
|
|
127
|
+
/**
|
|
128
|
+
* Lines only this application writes, used to recognise its own handiwork.
|
|
129
|
+
*
|
|
130
|
+
* `*.cbx` is the strongest of them: a CodeRook bundle inside the project is a
|
|
131
|
+
* CodeRook idea and nothing else puts that line in a .gitignore. The browser
|
|
132
|
+
* profile names are the next best, because they are an unusual thing for a
|
|
133
|
+
* person to think of and they arrive together.
|
|
134
|
+
*/
|
|
135
|
+
const STARTER_FINGERPRINT = [
|
|
136
|
+
"IndexedDB/",
|
|
137
|
+
"Local Storage/",
|
|
138
|
+
"GPUCache/",
|
|
139
|
+
"Code Cache/",
|
|
140
|
+
"blob_storage/",
|
|
141
|
+
"Local State",
|
|
142
|
+
"Preferences",
|
|
143
|
+
];
|
|
144
|
+
/** Every rule in a block of text, without comments or blank lines. */
|
|
145
|
+
function ruleLines(text) {
|
|
146
|
+
return text
|
|
147
|
+
.split(/\r?\n/)
|
|
148
|
+
.map((line) => line.trim())
|
|
149
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether this .gitignore was written by CodeRook rather than by a person.
|
|
153
|
+
*
|
|
154
|
+
* It matters because the starter template grows: engine caches were added to
|
|
155
|
+
* it long after the first folders were set up, and those folders can never
|
|
156
|
+
* benefit — a project with any .gitignore uses that file and nothing else. A
|
|
157
|
+
* Unity project set up before the Unity rules existed therefore went on
|
|
158
|
+
* sending its whole import cache for good, 45,342 files of it, with the
|
|
159
|
+
* improved template sitting unused in this same file.
|
|
160
|
+
*
|
|
161
|
+
* Newly written starters carry STARTER_MARKER, so this is exact for anything
|
|
162
|
+
* written from now on. The fingerprint is for the ones already on disk.
|
|
163
|
+
*/
|
|
164
|
+
function starterAuthored(text) {
|
|
165
|
+
if (text.includes(exports.STARTER_MARKER))
|
|
166
|
+
return true;
|
|
167
|
+
const lines = new Set(ruleLines(text));
|
|
168
|
+
if (!lines.has("*.cbx"))
|
|
169
|
+
return false;
|
|
170
|
+
const seen = STARTER_FINGERPRINT.filter((one) => lines.has(one)).length;
|
|
171
|
+
return seen >= 3;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The starter rules this file is missing, in template order.
|
|
175
|
+
*
|
|
176
|
+
* Only ever offered, never applied on its own: a person may have deleted a
|
|
177
|
+
* line on purpose, and re-adding it silently would be this application
|
|
178
|
+
* overruling them in their own file.
|
|
179
|
+
*/
|
|
180
|
+
function missingStarterRules(text) {
|
|
181
|
+
const present = new Set(ruleLines(text));
|
|
182
|
+
return ruleLines(exports.STARTER_IGNORE).filter((line) => !present.has(line));
|
|
183
|
+
}
|
|
123
184
|
/** The shared rules file, committed with the project. */
|
|
124
185
|
exports.IGNORE_FILE = ".gitignore";
|
|
125
186
|
/** Personal exclusions, which must never reach a collaborator. */
|
|
@@ -1103,7 +1164,17 @@ onScanned) {
|
|
|
1103
1164
|
}
|
|
1104
1165
|
};
|
|
1105
1166
|
await Promise.all(Array.from({ length: Math.min(LANES, wanted.length) }, () => lane()));
|
|
1106
|
-
|
|
1167
|
+
/*
|
|
1168
|
+
`stop` is also set by the findings ceiling, which is a different statement
|
|
1169
|
+
from running out of budget — forty findings is plenty to act on, and the
|
|
1170
|
+
files after it are very likely more of the same. Only a bound on the
|
|
1171
|
+
reading makes the scan incomplete in a way worth saying out loud.
|
|
1172
|
+
*/
|
|
1173
|
+
const spent = read >= MOST_SCANNED_FILES || bytes >= MOST_SCANNED_BYTES;
|
|
1174
|
+
return {
|
|
1175
|
+
found,
|
|
1176
|
+
reach: { scanned: read, of: wanted.length, complete: !spent },
|
|
1177
|
+
};
|
|
1107
1178
|
}
|
|
1108
1179
|
async function uploadConcerns(root, include,
|
|
1109
1180
|
/** Passed through so the one slow part of this can say it is working. */
|
|
@@ -1124,7 +1195,8 @@ onScanned) {
|
|
|
1124
1195
|
is two warnings for one problem, and the first one is the actionable
|
|
1125
1196
|
one.
|
|
1126
1197
|
*/
|
|
1127
|
-
pasted: pasted.filter((finding) => !secrets.includes(finding.path)),
|
|
1198
|
+
pasted: pasted.found.filter((finding) => !secrets.includes(finding.path)),
|
|
1199
|
+
reach: pasted.reach,
|
|
1128
1200
|
};
|
|
1129
1201
|
}
|
|
1130
1202
|
/** Files the flow must ask about before the first upload. */
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Showing what two people actually did to the same file.
|
|
4
|
+
*
|
|
5
|
+
* The merge screen used to offer four buttons — keep yours, keep theirs, keep
|
|
6
|
+
* both, remove it — above nothing at all. You were asked which side wins for
|
|
7
|
+
* a file you could not look at, which is not a decision so much as a guess,
|
|
8
|
+
* and the safe guess ("keep both") leaves the project holding two copies of
|
|
9
|
+
* everything anybody ever disagreed about.
|
|
10
|
+
*
|
|
11
|
+
* So the two sides are laid out line by line. Nothing here is a merge
|
|
12
|
+
* algorithm: the service already tried that and put the file on a Track
|
|
13
|
+
* precisely because the automatic answer was wrong. This is for reading.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.linesOf = linesOf;
|
|
17
|
+
exports.diffLines = diffLines;
|
|
18
|
+
exports.foldUnchanged = foldUnchanged;
|
|
19
|
+
exports.conflictDraft = conflictDraft;
|
|
20
|
+
exports.hasConflictMarkers = hasConflictMarkers;
|
|
21
|
+
/**
|
|
22
|
+
* The lines two texts share, longest run first.
|
|
23
|
+
*
|
|
24
|
+
* A plain longest-common-subsequence table. Both sides of a conflicted file
|
|
25
|
+
* are one file's worth of lines, so the quadratic table is a few hundred
|
|
26
|
+
* kilobytes at worst — and the alternative, a heuristic diff, sometimes
|
|
27
|
+
* reports a change where there is none, which on this screen would mean
|
|
28
|
+
* showing somebody a disagreement that does not exist.
|
|
29
|
+
*/
|
|
30
|
+
function commonRuns(left, right) {
|
|
31
|
+
const table = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
|
|
32
|
+
for (let a = left.length - 1; a >= 0; a -= 1) {
|
|
33
|
+
for (let b = right.length - 1; b >= 0; b -= 1) {
|
|
34
|
+
table[a][b] =
|
|
35
|
+
left[a] === right[b]
|
|
36
|
+
? table[a + 1][b + 1] + 1
|
|
37
|
+
: Math.max(table[a + 1][b], table[a][b + 1]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return table;
|
|
41
|
+
}
|
|
42
|
+
/** Split into lines without inventing a trailing empty one. */
|
|
43
|
+
function linesOf(text) {
|
|
44
|
+
if (text === "")
|
|
45
|
+
return [];
|
|
46
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
47
|
+
if (lines.length > 1 && lines[lines.length - 1] === "")
|
|
48
|
+
lines.pop();
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* How the saved file and the uploaded file differ, in order.
|
|
53
|
+
*
|
|
54
|
+
* `target` is what the project already held and `candidate` is what was
|
|
55
|
+
* uploaded, named the way the service names them rather than "old" and "new"
|
|
56
|
+
* — neither side is older, which is the whole reason there is a decision to
|
|
57
|
+
* make.
|
|
58
|
+
*/
|
|
59
|
+
function diffLines(target, candidate) {
|
|
60
|
+
const left = linesOf(target);
|
|
61
|
+
const right = linesOf(candidate);
|
|
62
|
+
const table = commonRuns(left, right);
|
|
63
|
+
const out = [];
|
|
64
|
+
let a = 0;
|
|
65
|
+
let b = 0;
|
|
66
|
+
while (a < left.length && b < right.length) {
|
|
67
|
+
if (left[a] === right[b]) {
|
|
68
|
+
out.push({
|
|
69
|
+
side: "same",
|
|
70
|
+
targetLine: a + 1,
|
|
71
|
+
candidateLine: b + 1,
|
|
72
|
+
text: left[a],
|
|
73
|
+
});
|
|
74
|
+
a += 1;
|
|
75
|
+
b += 1;
|
|
76
|
+
}
|
|
77
|
+
else if (table[a + 1][b] >= table[a][b + 1]) {
|
|
78
|
+
out.push({ side: "target", targetLine: a + 1, candidateLine: null, text: left[a] });
|
|
79
|
+
a += 1;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
out.push({
|
|
83
|
+
side: "candidate",
|
|
84
|
+
targetLine: null,
|
|
85
|
+
candidateLine: b + 1,
|
|
86
|
+
text: right[b],
|
|
87
|
+
});
|
|
88
|
+
b += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
while (a < left.length) {
|
|
92
|
+
out.push({ side: "target", targetLine: a + 1, candidateLine: null, text: left[a] });
|
|
93
|
+
a += 1;
|
|
94
|
+
}
|
|
95
|
+
while (b < right.length) {
|
|
96
|
+
out.push({
|
|
97
|
+
side: "candidate",
|
|
98
|
+
targetLine: null,
|
|
99
|
+
candidateLine: b + 1,
|
|
100
|
+
text: right[b],
|
|
101
|
+
});
|
|
102
|
+
b += 1;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function foldUnchanged(lines, context = 3) {
|
|
107
|
+
const interesting = new Set();
|
|
108
|
+
lines.forEach((line, index) => {
|
|
109
|
+
if (line.side === "same")
|
|
110
|
+
return;
|
|
111
|
+
for (let at = Math.max(0, index - context); at <= Math.min(lines.length - 1, index + context); at += 1) {
|
|
112
|
+
interesting.add(at);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
/*
|
|
116
|
+
A file with no differences at all is still worth showing — a conflict can
|
|
117
|
+
be about two people creating the same path with identical content, or
|
|
118
|
+
about a file one side deleted, and folding the whole thing away would
|
|
119
|
+
leave the screen empty with no explanation.
|
|
120
|
+
*/
|
|
121
|
+
if (!interesting.size)
|
|
122
|
+
return [{ kind: "lines", lines }];
|
|
123
|
+
const chunks = [];
|
|
124
|
+
let run = [];
|
|
125
|
+
let skipped = 0;
|
|
126
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
127
|
+
if (interesting.has(index)) {
|
|
128
|
+
if (skipped) {
|
|
129
|
+
chunks.push({ kind: "gap", skipped });
|
|
130
|
+
skipped = 0;
|
|
131
|
+
}
|
|
132
|
+
run.push(lines[index]);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
if (run.length) {
|
|
136
|
+
chunks.push({ kind: "lines", lines: run });
|
|
137
|
+
run = [];
|
|
138
|
+
}
|
|
139
|
+
skipped += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (run.length)
|
|
143
|
+
chunks.push({ kind: "lines", lines: run });
|
|
144
|
+
if (skipped)
|
|
145
|
+
chunks.push({ kind: "gap", skipped });
|
|
146
|
+
return chunks;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A starting point for somebody who wants to edit rather than choose.
|
|
150
|
+
*
|
|
151
|
+
* Both sides, marked the way every merge tool has marked them since RCS, so
|
|
152
|
+
* the text is immediately familiar and every editor already highlights it.
|
|
153
|
+
* Agreed lines appear once; the rest are stacked with the markers between
|
|
154
|
+
* them, and it is the person's job to delete the markers along with whatever
|
|
155
|
+
* they decided against.
|
|
156
|
+
*/
|
|
157
|
+
function conflictDraft(target, candidate, labels = {
|
|
158
|
+
target: "saved on the project",
|
|
159
|
+
candidate: "your upload",
|
|
160
|
+
}) {
|
|
161
|
+
const out = [];
|
|
162
|
+
let held = { target: [], candidate: [] };
|
|
163
|
+
const flush = () => {
|
|
164
|
+
if (!held.target.length && !held.candidate.length)
|
|
165
|
+
return;
|
|
166
|
+
out.push(`<<<<<<< ${labels.target}`);
|
|
167
|
+
out.push(...held.target);
|
|
168
|
+
out.push("=======");
|
|
169
|
+
out.push(...held.candidate);
|
|
170
|
+
out.push(`>>>>>>> ${labels.candidate}`);
|
|
171
|
+
held = { target: [], candidate: [] };
|
|
172
|
+
};
|
|
173
|
+
for (const line of diffLines(target, candidate)) {
|
|
174
|
+
if (line.side === "same") {
|
|
175
|
+
flush();
|
|
176
|
+
out.push(line.text);
|
|
177
|
+
}
|
|
178
|
+
else if (line.side === "target") {
|
|
179
|
+
held.target.push(line.text);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
held.candidate.push(line.text);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
flush();
|
|
186
|
+
return out.join("\n");
|
|
187
|
+
}
|
|
188
|
+
/** Whether any conflict marker is still in the text somebody is about to send. */
|
|
189
|
+
function hasConflictMarkers(text) {
|
|
190
|
+
return /^(?:<{7}|={7}|>{7})(?:\s|$)/m.test(text);
|
|
191
|
+
}
|