cawdev-cli 1.2.0 → 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.
@@ -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
+ }