cawdev-cli 1.1.1 → 1.2.1
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/lib/finish-check.mjs +9 -1
- package/lib/gate-condition.mjs +11 -0
- package/lib/stage-tools.mjs +33 -2
- package/lib/version-files.mjs +99 -0
- package/mcp/server.mjs +40 -0
- package/package.json +1 -1
- package/runner/README.md +260 -32
- package/runner/attach.mjs +1692 -110
- package/runner/card-import.mjs +115 -0
- package/runner/cawdev.mjs +70 -1
- package/runner/diff-view.mjs +252 -0
- package/runner/git-lock.mjs +73 -0
- package/runner/runner.mjs +415 -29
- package/runner/session-cards.mjs +448 -0
- package/runner/shots.mjs +214 -0
- package/runner/sign-in.mjs +29 -2
- package/runner/split.mjs +272 -64
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Cards from a JSON file — `cawdev import <file>` and `/import` in the
|
|
2
|
+
// terminal, over `POST /api/projects/{slug}/roadmap/import`.
|
|
3
|
+
//
|
|
4
|
+
// The file is either a bare array of cards or `{ "cards": [...] }`, each card
|
|
5
|
+
// `{ title, body?, kind?, severity?, priority?, status?, section?, tags?,
|
|
6
|
+
// assignee? }`. A card with a severity and no kind is an issue. What a card
|
|
7
|
+
// may hold is the server's to say, card by card (`CardImportService`): this
|
|
8
|
+
// side only refuses what is not a list of objects at all, because a file that
|
|
9
|
+
// is not JSON is a sentence the person should read before anything is sent.
|
|
10
|
+
//
|
|
11
|
+
// Zero dependencies, like everything in tools/.
|
|
12
|
+
|
|
13
|
+
/** The most cards one call takes — `CardImportService.MAX_CARDS`. */
|
|
14
|
+
export const MAX_CARDS = 200;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The cards in a file's text, or a thrown sentence saying what is wrong with
|
|
18
|
+
* it. Pure: the console's dialog spells the same rule in `board/card-import.ts`.
|
|
19
|
+
*/
|
|
20
|
+
export function cardsFrom(text) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(text);
|
|
24
|
+
} catch (failure) {
|
|
25
|
+
throw new Error(`not JSON: ${failure.message}`);
|
|
26
|
+
}
|
|
27
|
+
const cards = Array.isArray(parsed) ? parsed : parsed?.cards;
|
|
28
|
+
if (!Array.isArray(cards)) {
|
|
29
|
+
throw new Error('expected a list of cards, or an object with a "cards" list');
|
|
30
|
+
}
|
|
31
|
+
if (!cards.length) {
|
|
32
|
+
throw new Error('the file holds no cards');
|
|
33
|
+
}
|
|
34
|
+
if (cards.length > MAX_CARDS) {
|
|
35
|
+
throw new Error(`at most ${MAX_CARDS} cards in one import — the file holds ${cards.length}`);
|
|
36
|
+
}
|
|
37
|
+
const notCards = cards.findIndex((card) => !card || typeof card !== 'object' || Array.isArray(card));
|
|
38
|
+
if (notCards !== -1) {
|
|
39
|
+
throw new Error(`card ${notCards + 1} is not an object`);
|
|
40
|
+
}
|
|
41
|
+
return cards;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One call. `dryRun` files nothing and says what would be. A card whose title
|
|
46
|
+
* is taken (on the board, or earlier in the file) is skipped and listed in
|
|
47
|
+
* `duplicates` unless `allowDuplicates`.
|
|
48
|
+
*/
|
|
49
|
+
export function importCards(session, slug, cards, { dryRun = false, status, allowDuplicates = false } = {}) {
|
|
50
|
+
return session.request(
|
|
51
|
+
`/api/projects/${encodeURIComponent(slug)}/roadmap/import?dryRun=${dryRun}`,
|
|
52
|
+
{
|
|
53
|
+
method: 'POST',
|
|
54
|
+
body: { cards, ...(status ? { status } : {}), ...(allowDuplicates ? { allowDuplicates: true } : {}) },
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How many of each kind, in words: `3 issues and 1 roadmap card`. */
|
|
60
|
+
export function countWords(filed) {
|
|
61
|
+
const issues = filed.filter((card) => card.kind === 'ISSUE').length;
|
|
62
|
+
const roadmap = filed.length - issues;
|
|
63
|
+
const parts = [];
|
|
64
|
+
if (issues) parts.push(`${issues} issue${issues === 1 ? '' : 's'}`);
|
|
65
|
+
if (roadmap) parts.push(`${roadmap} roadmap card${roadmap === 1 ? '' : 's'}`);
|
|
66
|
+
return parts.join(' and ') || 'no card';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The lines a result is told in. Every refusal is named with its place in the
|
|
71
|
+
* file (from 1, as an editor counts) and its title, so it can be found and
|
|
72
|
+
* fixed; a real run lists the refs it filed. A duplicate names what it
|
|
73
|
+
* repeats: the project's card, or the file's own earlier one.
|
|
74
|
+
*/
|
|
75
|
+
export function resultLines(result, slug, ink) {
|
|
76
|
+
const lines = [];
|
|
77
|
+
const filed = result.filed ?? [];
|
|
78
|
+
const refused = result.refused ?? [];
|
|
79
|
+
const duplicates = result.duplicates ?? [];
|
|
80
|
+
const tail = (refused.length ? ink.warn(` · ${refused.length} refused`) : '')
|
|
81
|
+
+ (duplicates.length ? ink.muted(` · ${duplicates.length} skipped as duplicates`) : '');
|
|
82
|
+
if (result.dryRun) {
|
|
83
|
+
lines.push(` ${ink.text(`${countWords(filed)} would be filed on ${slug}`)}${tail}`);
|
|
84
|
+
} else {
|
|
85
|
+
lines.push(` ${ink.success(`filed ${countWords(filed)} on ${slug}`)}${tail}`);
|
|
86
|
+
for (const card of filed) {
|
|
87
|
+
lines.push(` ${ink.accent(card.ref ?? '?')} ${ink.muted(card.title)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const card of refused) {
|
|
91
|
+
lines.push(` ${ink.danger(`#${card.index + 1}`)} ${ink.muted(card.title ?? '(no title)')} — ${card.reason}`);
|
|
92
|
+
}
|
|
93
|
+
for (const card of duplicates) {
|
|
94
|
+
const of = card.ref ? `already ${card.ref}` : `same as #${card.sameAs + 1}`;
|
|
95
|
+
lines.push(` ${ink.muted(`#${card.index + 1} ${card.title} — ${of}`)}`);
|
|
96
|
+
}
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which project, when the command did not name one: the project whose checkout
|
|
102
|
+
* the directory stands in (the deepest, when checkouts nest), else the one
|
|
103
|
+
* project this machine serves. Null when it cannot be told, and the command
|
|
104
|
+
* asks for `--project`. `pathsOf` and `absolute` are configure's and paths'.
|
|
105
|
+
*/
|
|
106
|
+
export function projectFor(projects, cwd, { pathsOf, absolute }) {
|
|
107
|
+
const slugs = Object.keys(projects ?? {});
|
|
108
|
+
const inside = (path) => cwd === path || cwd.startsWith(`${path.replace(/\/+$/, '')}/`);
|
|
109
|
+
const here = slugs
|
|
110
|
+
.flatMap((slug) => pathsOf(projects[slug]).map((path) => [slug, absolute(path)]))
|
|
111
|
+
.filter(([, path]) => inside(path))
|
|
112
|
+
.sort((left, right) => right[1].length - left[1].length);
|
|
113
|
+
if (here.length) return here[0][0];
|
|
114
|
+
return slugs.length === 1 ? slugs[0] : null;
|
|
115
|
+
}
|
package/runner/cawdev.mjs
CHANGED
|
@@ -30,8 +30,10 @@ import { asker, askOne, mintForThisMachine, setUpThisMachine } from './bootstrap
|
|
|
30
30
|
import { liveSockets, probeSocket, socketPathFor } from './control.mjs';
|
|
31
31
|
import {
|
|
32
32
|
addProject, addWorkspace, readConfigFile, reloadIfRunning, removeProject, setAcceptsRulesFromConsole,
|
|
33
|
-
setMaxPlanningRuns, setQuality, setAgentEnabled, thisRunnerId,
|
|
33
|
+
setMaxPlanningRuns, setQuality, setAgentEnabled, thisRunnerId, pathsOf,
|
|
34
34
|
} from './configure.mjs';
|
|
35
|
+
import { cardsFrom, importCards, projectFor, resultLines } from './card-import.mjs';
|
|
36
|
+
import { absolute, expandHome } from './paths.mjs';
|
|
35
37
|
import { restartNeededFor, runnerNameOf } from '../lib/runner-config.mjs';
|
|
36
38
|
import { Select } from './select.mjs';
|
|
37
39
|
import { storedSession } from './sign-in.mjs';
|
|
@@ -72,6 +74,16 @@ const USAGE = `
|
|
|
72
74
|
cawdev config planning-limit <n> how many planning runs (PLAN, SCOPE, AUDIT, REVIEW) run here at once; 0 is no limit
|
|
73
75
|
cawdev config show this machine's config, token redacted
|
|
74
76
|
|
|
77
|
+
cawdev import <file.json> file the cards of a JSON file: a list
|
|
78
|
+
of {title, body, severity, tags, …},
|
|
79
|
+
or {"cards": [...]}. Checks first, then
|
|
80
|
+
asks. --project <slug> (else the
|
|
81
|
+
checkout you stand in), --status <S>
|
|
82
|
+
where cards naming none land,
|
|
83
|
+
--dry-run to only check, --yes to not ask.
|
|
84
|
+
A card whose title is taken is skipped;
|
|
85
|
+
--allow-duplicates files it anyway
|
|
86
|
+
|
|
75
87
|
On a machine with no config, cawdev sets one up: it signs you in through the
|
|
76
88
|
browser, asks which projects this machine should run agents for, clones them,
|
|
77
89
|
and mints its own runner token. No token is ever typed. \`cawdev config\` is
|
|
@@ -568,6 +580,58 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
|
|
|
568
580
|
throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
|
|
569
581
|
}
|
|
570
582
|
|
|
583
|
+
/**
|
|
584
|
+
* `cawdev import <file>` — the cards of a JSON file, filed as you. A dry run
|
|
585
|
+
* goes first and is shown, so what would be refused is read before anything
|
|
586
|
+
* is written; then it asks, unless `--yes`. Running it twice files the cards
|
|
587
|
+
* twice, which is why it asks.
|
|
588
|
+
*/
|
|
589
|
+
export async function runImport(args, ink, { cwd = process.cwd(), say = (line) => console.log(line) } = {}) {
|
|
590
|
+
const takesValue = new Set(['--project', '--status', '--config', '--url', '--runner']);
|
|
591
|
+
const path = args.find((arg, at) => !arg.startsWith('--') && !takesValue.has(args[at - 1]));
|
|
592
|
+
if (!path) {
|
|
593
|
+
throw new Error('Name the file: cawdev import <file.json> [--project <slug>] [--dry-run]');
|
|
594
|
+
}
|
|
595
|
+
const cards = cardsFrom(await readFile(resolve(cwd, expandHome(path)), 'utf8').catch((failure) => {
|
|
596
|
+
throw new Error(`Could not read ${path}: ${failure.message}`);
|
|
597
|
+
}));
|
|
598
|
+
|
|
599
|
+
const configPath = await findConfig(args, process.env, cwd);
|
|
600
|
+
const file = configPath ? await readConfigFile(configPath) : null;
|
|
601
|
+
const slug = valueOf(args, '--project') ?? projectFor(file?.projects, cwd, { pathsOf, absolute });
|
|
602
|
+
if (!slug) {
|
|
603
|
+
throw new Error('Which project? Name it: cawdev import <file.json> --project <slug>');
|
|
604
|
+
}
|
|
605
|
+
const session = await storedSession(signInUrl(file, args));
|
|
606
|
+
if (!session.signedIn) {
|
|
607
|
+
throw new Error('Not signed in on this machine. Run `cawdev` and /login, or `cawdev --setup`.');
|
|
608
|
+
}
|
|
609
|
+
const status = valueOf(args, '--status') ?? undefined;
|
|
610
|
+
const allowDuplicates = args.includes('--allow-duplicates');
|
|
611
|
+
|
|
612
|
+
say(` ${ink.muted(`${path}: ${cards.length} card${cards.length === 1 ? '' : 's'}, checking against ${slug}…`)}`);
|
|
613
|
+
const checked = await importCards(session, slug, cards, { dryRun: true, status, allowDuplicates });
|
|
614
|
+
for (const line of resultLines(checked, slug, ink)) say(line);
|
|
615
|
+
if (args.includes('--dry-run') || !checked.filed?.length) {
|
|
616
|
+
return checked;
|
|
617
|
+
}
|
|
618
|
+
if (!args.includes('--yes')) {
|
|
619
|
+
const ask = asker();
|
|
620
|
+
try {
|
|
621
|
+
const answer = (await ask.line(` File ${checked.filed.length} on ${slug}? [y/N] `)).trim();
|
|
622
|
+
if (!/^y(es)?$/i.test(answer)) {
|
|
623
|
+
say(` ${ink.muted('Nothing filed.')}`);
|
|
624
|
+
return checked;
|
|
625
|
+
}
|
|
626
|
+
} finally {
|
|
627
|
+
ask.close();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const done = await importCards(session, slug, cards, { status, allowDuplicates });
|
|
631
|
+
for (const line of resultLines(done, slug, ink)) say(line);
|
|
632
|
+
return done;
|
|
633
|
+
}
|
|
634
|
+
|
|
571
635
|
/** The config as somebody would want to read it back — never the secret. */
|
|
572
636
|
export function printConfig(file, ink, say) {
|
|
573
637
|
const shown = { ...file };
|
|
@@ -592,6 +656,11 @@ async function main() {
|
|
|
592
656
|
return;
|
|
593
657
|
}
|
|
594
658
|
|
|
659
|
+
if (argv[0] === 'import') {
|
|
660
|
+
await runImport(argv.slice(1), ink);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
595
664
|
const url = await launchUrl(argv);
|
|
596
665
|
|
|
597
666
|
if (argv.includes('--version')) {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// A branch's diff, in the terminal — `/review` (attach.mjs).
|
|
2
|
+
//
|
|
3
|
+
// The diff comes from local git, never from the platform: this terminal runs
|
|
4
|
+
// on a machine with a checkout of the project, `git fetch` moves refs and
|
|
5
|
+
// touches no file, and `git diff base...branch` reads objects only, so it is
|
|
6
|
+
// safe even in a checkout somebody is working in. No endpoint carries a diff,
|
|
7
|
+
// and it is the same for GitHub, GitLab and Bitbucket.
|
|
8
|
+
//
|
|
9
|
+
// Two halves. `branchDiff` is the plumbing (git, a bounded buffer). `parseDiff`
|
|
10
|
+
// and `layoutDiff` are pure: text in, files out; files in, rows out — the rows
|
|
11
|
+
// already wrapped to the width, so the reader's row numbers are the file
|
|
12
|
+
// headers' and `n`/`p` can jump to them.
|
|
13
|
+
//
|
|
14
|
+
// Zero dependencies, like everything in tools/.
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { wrap } from '../lib/ansi.mjs';
|
|
18
|
+
|
|
19
|
+
/** The most diff text read; past it the diff is cut and says so. */
|
|
20
|
+
export const MAX_DIFF_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** A file this long opens folded: a review reads it on purpose, not by scrolling past it. */
|
|
23
|
+
export const FOLD_OVER_LINES = 400;
|
|
24
|
+
|
|
25
|
+
/** Generated files nobody reviews line by line. */
|
|
26
|
+
const GENERATED = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|[^/]*\.lock|go\.sum)$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Text from a diff, made safe to draw: a file's bytes can hold escape
|
|
30
|
+
* sequences, and a diff printed raw could move the cursor, retitle the window
|
|
31
|
+
* or worse. Tabs become spaces so the width is what is drawn.
|
|
32
|
+
*/
|
|
33
|
+
export function printableDiffLine(text) {
|
|
34
|
+
return String(text)
|
|
35
|
+
.replace(/\t/g, ' ')
|
|
36
|
+
// eslint-disable-next-line no-control-regex
|
|
37
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '�');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `a/x` → `x`, and git's quoted form of a path with odd bytes. */
|
|
41
|
+
function pathOf(side) {
|
|
42
|
+
let text = side.trim();
|
|
43
|
+
if (text.startsWith('"') && text.endsWith('"')) {
|
|
44
|
+
text = text.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
45
|
+
}
|
|
46
|
+
return text.replace(/^[ab]\//, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `git diff` output → files. Each file: `path` (the new one; the old one for a
|
|
51
|
+
* deletion), `oldPath` when renamed, `status` (added, deleted, renamed,
|
|
52
|
+
* modified), `binary`, `additions`, `deletions`, and `lines`: hunk headers and
|
|
53
|
+
* the lines under them, each `{ kind: 'hunk' | 'add' | 'del' | 'ctx' | 'note', text }`.
|
|
54
|
+
*/
|
|
55
|
+
export function parseDiff(text) {
|
|
56
|
+
const files = [];
|
|
57
|
+
let file = null;
|
|
58
|
+
for (const raw of String(text).split('\n')) {
|
|
59
|
+
if (raw.startsWith('diff --git ')) {
|
|
60
|
+
const match = /^diff --git (\S+|"[^"]*") (\S+|"[^"]*")$/.exec(raw);
|
|
61
|
+
file = {
|
|
62
|
+
path: match ? pathOf(match[2]) : raw.slice(11),
|
|
63
|
+
oldPath: null,
|
|
64
|
+
status: 'modified',
|
|
65
|
+
binary: false,
|
|
66
|
+
additions: 0,
|
|
67
|
+
deletions: 0,
|
|
68
|
+
lines: [],
|
|
69
|
+
};
|
|
70
|
+
files.push(file);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!file) continue;
|
|
74
|
+
if (raw.startsWith('@@')) {
|
|
75
|
+
file.lines.push({ kind: 'hunk', text: raw });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const inHunk = file.lines.length > 0;
|
|
79
|
+
if (!inHunk) {
|
|
80
|
+
// The header between `diff --git` and the first hunk.
|
|
81
|
+
if (raw.startsWith('new file mode')) file.status = 'added';
|
|
82
|
+
else if (raw.startsWith('deleted file mode')) file.status = 'deleted';
|
|
83
|
+
else if (raw.startsWith('rename from ')) {
|
|
84
|
+
file.status = 'renamed';
|
|
85
|
+
file.oldPath = raw.slice(12);
|
|
86
|
+
} else if (raw.startsWith('rename to ')) file.path = raw.slice(10);
|
|
87
|
+
else if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') file.binary = true;
|
|
88
|
+
else if (raw.startsWith('--- ') && file.status === 'deleted') file.path = pathOf(raw.slice(4));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (raw.startsWith('+')) {
|
|
92
|
+
file.additions++;
|
|
93
|
+
file.lines.push({ kind: 'add', text: raw });
|
|
94
|
+
} else if (raw.startsWith('-')) {
|
|
95
|
+
file.deletions++;
|
|
96
|
+
file.lines.push({ kind: 'del', text: raw });
|
|
97
|
+
} else if (raw.startsWith('\\')) {
|
|
98
|
+
file.lines.push({ kind: 'note', text: raw });
|
|
99
|
+
} else if (raw !== '' || file.lines.length) {
|
|
100
|
+
file.lines.push({ kind: 'ctx', text: raw });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// The split leaves a last empty context line where the text ended in \n.
|
|
104
|
+
for (const each of files) {
|
|
105
|
+
while (each.lines.length && each.lines.at(-1).kind === 'ctx' && each.lines.at(-1).text === '') {
|
|
106
|
+
each.lines.pop();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return files;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a file opens folded: generated, binary, or too long to read by scrolling. */
|
|
113
|
+
export function foldedByDefault(file) {
|
|
114
|
+
return file.binary || GENERATED.test(file.path) || file.lines.length > FOLD_OVER_LINES;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The rows the reader draws, wrapped to `width`, and where each file starts.
|
|
119
|
+
*
|
|
120
|
+
* @param review `{ files, folded: Set<path>, branch, base, cut, stale }`
|
|
121
|
+
* @returns `{ rows, fileRows }` — `fileRows[i]` is the row of `files[i]`'s header.
|
|
122
|
+
*/
|
|
123
|
+
export function layoutDiff(review, width, ink) {
|
|
124
|
+
const { files, folded = new Set(), branch, base, cut = false, stale = false } = review;
|
|
125
|
+
const room = Math.max(10, width);
|
|
126
|
+
const rows = [];
|
|
127
|
+
const push = (line) => rows.push(...wrap(line, room));
|
|
128
|
+
const additions = files.reduce((sum, file) => sum + file.additions, 0);
|
|
129
|
+
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
|
|
130
|
+
|
|
131
|
+
push(`${ink.bold(branch ?? '')} ${ink.muted(`into ${base ?? '?'}`)} `
|
|
132
|
+
+ `${ink.muted(`${files.length} file${files.length === 1 ? '' : 's'}`)} `
|
|
133
|
+
+ `${ink.success(`+${additions}`)} ${ink.danger(`−${deletions}`)}`);
|
|
134
|
+
if (stale) push(ink.warn('origin could not be fetched: this is the checkout\'s copy, and may be behind'));
|
|
135
|
+
if (cut) push(ink.warn('the diff was cut: it is longer than the terminal reads'));
|
|
136
|
+
if (!files.length) {
|
|
137
|
+
push(ink.muted('no change between the two: nothing to review'));
|
|
138
|
+
return { rows, fileRows: [] };
|
|
139
|
+
}
|
|
140
|
+
push('');
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
push(` ${statusMark(file, ink)} ${printableDiffLine(file.path)} `
|
|
143
|
+
+ `${ink.success(`+${file.additions}`)} ${ink.danger(`−${file.deletions}`)}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const fileRows = [];
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
push('');
|
|
149
|
+
fileRows.push(rows.length);
|
|
150
|
+
const open = !folded.has(file.path);
|
|
151
|
+
const named = file.oldPath ? `${file.oldPath} → ${file.path}` : file.path;
|
|
152
|
+
push(`${ink.bold(`${open ? '▾' : '▸'} ${printableDiffLine(named)}`)} `
|
|
153
|
+
+ `${ink.success(`+${file.additions}`)} ${ink.danger(`−${file.deletions}`)} ${ink.muted(file.status)}`);
|
|
154
|
+
if (file.binary) {
|
|
155
|
+
push(ink.muted(' binary: not drawn'));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!open) {
|
|
159
|
+
push(ink.muted(` folded: ${file.lines.length} lines · f opens it`));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
for (const line of file.lines) {
|
|
163
|
+
const text = printableDiffLine(line.text);
|
|
164
|
+
if (line.kind === 'hunk') push(ink.accent(text));
|
|
165
|
+
else if (line.kind === 'add') push(ink.success(text));
|
|
166
|
+
else if (line.kind === 'del') push(ink.danger(text));
|
|
167
|
+
else push(ink.muted(text));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { rows, fileRows };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function statusMark(file, ink) {
|
|
174
|
+
if (file.status === 'added') return ink.success('A');
|
|
175
|
+
if (file.status === 'deleted') return ink.danger('D');
|
|
176
|
+
if (file.status === 'renamed') return ink.accent('R');
|
|
177
|
+
return ink.muted('M');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Which file the row is in: the last header at or above it, or -1 above the first. */
|
|
181
|
+
export function fileAt(fileRows, row) {
|
|
182
|
+
let at = -1;
|
|
183
|
+
fileRows.forEach((start, i) => {
|
|
184
|
+
if (start <= row) at = i;
|
|
185
|
+
});
|
|
186
|
+
return at;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- the plumbing ------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** One git command in `cwd`; stdout capped at `max` bytes. Never throws. */
|
|
192
|
+
export function git(cwd, args, { max = MAX_DIFF_BYTES } = {}) {
|
|
193
|
+
return new Promise((done) => {
|
|
194
|
+
let child;
|
|
195
|
+
try {
|
|
196
|
+
child = spawn('git', ['-C', cwd, ...args], {
|
|
197
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
198
|
+
// A fetch never asks a person: the terminal is drawing over the tty.
|
|
199
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o BatchMode=yes' },
|
|
200
|
+
});
|
|
201
|
+
} catch (failure) {
|
|
202
|
+
done({ code: -1, out: '', err: failure.message, cut: false });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const chunks = [];
|
|
206
|
+
let size = 0;
|
|
207
|
+
let cut = false;
|
|
208
|
+
let err = '';
|
|
209
|
+
child.stdout.on('data', (chunk) => {
|
|
210
|
+
if (cut) return;
|
|
211
|
+
if (size + chunk.length > max) {
|
|
212
|
+
chunks.push(chunk.subarray(0, max - size));
|
|
213
|
+
cut = true;
|
|
214
|
+
child.kill();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
chunks.push(chunk);
|
|
218
|
+
size += chunk.length;
|
|
219
|
+
});
|
|
220
|
+
child.stderr.on('data', (chunk) => {
|
|
221
|
+
err += chunk;
|
|
222
|
+
});
|
|
223
|
+
child.on('error', (failure) => done({ code: -1, out: '', err: failure.message, cut: false }));
|
|
224
|
+
child.on('close', (code) => done({ code: cut ? 0 : code, out: Buffer.concat(chunks).toString('utf8'), err: err.trim(), cut }));
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The diff of `branch` against `base` in the checkout at `cwd`: what the
|
|
230
|
+
* branch adds since it left the base (three dots, from the merge base).
|
|
231
|
+
* Fetches both first; a fetch that fails (offline, a branch never pushed)
|
|
232
|
+
* falls back to what the checkout already has, and says so in `stale`.
|
|
233
|
+
* Throws a sentence when neither ref can be found.
|
|
234
|
+
*/
|
|
235
|
+
export async function branchDiff(cwd, { branch, base }, run = git) {
|
|
236
|
+
const fetched = await run(cwd, ['fetch', '--quiet', 'origin', base, branch], { max: 1 << 20 });
|
|
237
|
+
const ref = async (name) => {
|
|
238
|
+
for (const candidate of [`origin/${name}`, name]) {
|
|
239
|
+
const found = await run(cwd, ['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], { max: 4096 });
|
|
240
|
+
if (found.code === 0 && found.out.trim()) return candidate;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
};
|
|
244
|
+
const head = await ref(branch);
|
|
245
|
+
if (!head) throw new Error(`${branch} is not in ${cwd}, on origin or here`);
|
|
246
|
+
const from = await ref(base);
|
|
247
|
+
if (!from) throw new Error(`${base} is not in ${cwd}, on origin or here`);
|
|
248
|
+
const diff = await run(cwd, ['-c', 'core.quotepath=off', 'diff', '--no-color', '--no-ext-diff', '-M',
|
|
249
|
+
`${from}...${head}`]);
|
|
250
|
+
if (diff.code !== 0) throw new Error(`git diff failed: ${diff.err || `exit ${diff.code}`}`);
|
|
251
|
+
return { files: parseDiff(diff.out), cut: diff.cut, stale: fetched.code !== 0, head, from };
|
|
252
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A git lock a crash left behind, and the `/clean` that removes it.
|
|
3
|
+
*
|
|
4
|
+
* When the machine dies mid-git (a crash, a power cut), git leaves
|
|
5
|
+
* `.git/index.lock` behind and refuses every later command in that checkout
|
|
6
|
+
* with "Unable to create '…/index.lock': File exists". Every session that
|
|
7
|
+
* prepares its branch there fails the same way, and `/continue` alone only
|
|
8
|
+
* fails again. `/clean` removes the lock; `/continue` then picks the session up.
|
|
9
|
+
*
|
|
10
|
+
* <strong>The path is read from the failure, then checked against the run's
|
|
11
|
+
* own checkout.</strong> The failure text travels through the platform, so it
|
|
12
|
+
* is trusted no further than a lock file inside `<workspace>/.git/` (or the
|
|
13
|
+
* reading worktree's admin directory beside it). Anything else is refused, and
|
|
14
|
+
* the path is named so a person can judge it.
|
|
15
|
+
*
|
|
16
|
+
* <strong>Young locks are left alone.</strong> A lock a live git holds is
|
|
17
|
+
* seconds old at most, because the daemon never opens an editor; one older
|
|
18
|
+
* than `MIN_AGE_MS` is a leftover. Refusing a fresh one is the direction
|
|
19
|
+
* `tool-rules.mjs` fails in: unsure means held.
|
|
20
|
+
*/
|
|
21
|
+
import { rm, stat } from 'node:fs/promises';
|
|
22
|
+
import { basename, sep } from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** How old a lock must be before `/clean` calls it a leftover. */
|
|
25
|
+
export const MIN_AGE_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
const LOCK_SAID = /Unable to create '([^']+\.lock)': File exists/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The stale lock a session stopped on, or null. `said` is the run's
|
|
31
|
+
* `exitSummary`; `workspace` the checkout it ran in. Pure.
|
|
32
|
+
*/
|
|
33
|
+
export function staleLockOf(said, workspace) {
|
|
34
|
+
const path = LOCK_SAID.exec(String(said ?? ''))?.[1];
|
|
35
|
+
if (!path || !workspace || path.includes(`${sep}..${sep}`)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const root = workspace.endsWith(sep) ? workspace.slice(0, -1) : workspace;
|
|
39
|
+
const own = path.startsWith(`${root}${sep}.git${sep}`);
|
|
40
|
+
// The reading copy (`<first>-read`) is a worktree: its lock lives in the
|
|
41
|
+
// main checkout's `.git/worktrees/<name>/`.
|
|
42
|
+
const worktree = path.includes(`${sep}.git${sep}worktrees${sep}${basename(root)}${sep}`);
|
|
43
|
+
return own || worktree ? path : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Removes the lock if it is old enough to be a leftover. Resolves to what was
|
|
48
|
+
* done, in words the terminal prints: `{ removed, said }`. Never throws.
|
|
49
|
+
*/
|
|
50
|
+
export async function removeStaleLock(path, { now = Date.now(), minAgeMs = MIN_AGE_MS } = {}) {
|
|
51
|
+
let info;
|
|
52
|
+
try {
|
|
53
|
+
info = await stat(path);
|
|
54
|
+
} catch (failure) {
|
|
55
|
+
if (failure.code === 'ENOENT') {
|
|
56
|
+
return { removed: false, said: `${path} is already gone — /continue picks it back up` };
|
|
57
|
+
}
|
|
58
|
+
return { removed: false, said: `could not read ${path}: ${failure.message}` };
|
|
59
|
+
}
|
|
60
|
+
const age = now - info.mtimeMs;
|
|
61
|
+
if (age < minAgeMs) {
|
|
62
|
+
return {
|
|
63
|
+
removed: false,
|
|
64
|
+
said: `${path} is ${Math.max(0, Math.round(age / 1000))}s old — a git may still hold it; try again in a minute`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await rm(path);
|
|
69
|
+
} catch (failure) {
|
|
70
|
+
return { removed: false, said: `could not remove ${path}: ${failure.message}` };
|
|
71
|
+
}
|
|
72
|
+
return { removed: true, said: `removed ${path}` };
|
|
73
|
+
}
|