wendkeep 0.76.9 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,712 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import {
4
+ appendFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ } from 'node:fs';
10
+ import { dirname, join, resolve } from 'node:path';
11
+
12
+ import {
13
+ discoverWorktreeRepository,
14
+ mutateWorktreeRegistry,
15
+ readWorktreeRegistry,
16
+ withWorktreeRegistryLock,
17
+ } from '../packages/vault/src/worktree-metadata.mjs';
18
+ import { readSessionRegistry } from '../hooks/obsidian-common.mjs';
19
+ import { mutateActiveContext } from '../hooks/active-context-store.mjs';
20
+
21
+ const RECEIPT_REL = 'wendkeep/worktree-cleanup-receipts-v1.jsonl';
22
+
23
+ function cleanupError(code, message, details = {}) {
24
+ const error = new Error(message);
25
+ error.code = code;
26
+ Object.assign(error, details);
27
+ return error;
28
+ }
29
+
30
+ function git(cwd, args, { ok = true, spawn = spawnSync } = {}) {
31
+ const result = spawn('git', args, {
32
+ cwd,
33
+ encoding: 'utf8',
34
+ windowsHide: true,
35
+ });
36
+ if (ok && result.status !== 0) {
37
+ throw cleanupError(
38
+ 'WENDKEEP_WORKTREE_GIT_FAILED',
39
+ String(result.stderr || result.error?.message || `git ${args[0]} falhou`).trim(),
40
+ { gitArgs: [...args], status: result.status },
41
+ );
42
+ }
43
+ return result;
44
+ }
45
+
46
+ function comparablePath(value) {
47
+ const normalized = resolve(String(value || '')).replaceAll('\\', '/');
48
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
49
+ }
50
+
51
+ function githubRepository(value) {
52
+ const text = String(value || '').trim();
53
+ const repository = text.match(/^https?:\/\/github\.com\/([^/]+\/[^/#?]+)(?:[/?#]|$)/i)?.[1] || '';
54
+ return repository.replace(/\.git$/i, '').toLowerCase();
55
+ }
56
+
57
+ function originRepository(startDir, spawn) {
58
+ const result = git(startDir, ['remote', 'get-url', 'origin'], { ok: false, spawn });
59
+ if (result.status !== 0) return '';
60
+ const value = String(result.stdout || '').trim();
61
+ const repository = value.match(/github\.com[/:]([^/]+\/[^/?#]+)$/i)?.[1] || '';
62
+ return repository.replace(/\.git$/i, '').toLowerCase();
63
+ }
64
+
65
+ function requiredEntry(repository, slug) {
66
+ const { registry } = readWorktreeRegistry(repository);
67
+ const entry = registry?.entries?.[slug];
68
+ if (!entry) {
69
+ throw cleanupError('WENDKEEP_WORKTREE_NOT_FOUND', `Worktree gerenciada não encontrada: "${slug}".`);
70
+ }
71
+ return { registry, entry };
72
+ }
73
+
74
+ function normalizePullRequest(value) {
75
+ const text = String(value || '').trim();
76
+ const number = text.match(/^\d+$/)?.[0]
77
+ || text.match(/\/pull\/(\d+)(?:[/#?]|$)/)?.[1]
78
+ || '';
79
+ if (!number || Number(number) < 1) {
80
+ throw cleanupError('WENDKEEP_WORKTREE_PR_INVALID', 'Referência de Pull Request inválida ou ausente.');
81
+ }
82
+ return { reference: text, number: Number(number) };
83
+ }
84
+
85
+ async function defaultGithub({ cwd, pullRequest }) {
86
+ const result = spawnSync('gh', [
87
+ 'pr', 'view', String(pullRequest.number),
88
+ '--json', 'number,url,state,mergedAt,headRefName,headRefOid,baseRefName,mergeCommit,isCrossRepository',
89
+ ], { cwd, encoding: 'utf8', windowsHide: true });
90
+ if (result.status !== 0) {
91
+ throw cleanupError(
92
+ 'WENDKEEP_WORKTREE_PR_UNAVAILABLE',
93
+ String(result.stderr || result.error?.message || 'GitHub indisponível.').trim(),
94
+ );
95
+ }
96
+ const value = JSON.parse(result.stdout || '{}');
97
+ return { ...value, mergeCommitOid: value.mergeCommit?.oid || '' };
98
+ }
99
+
100
+ export async function verifyMergedPullRequest({
101
+ startDir = process.cwd(),
102
+ entry,
103
+ pullRequest,
104
+ github = defaultGithub,
105
+ spawn = spawnSync,
106
+ } = {}) {
107
+ if (!entry?.branch) {
108
+ throw cleanupError('WENDKEEP_WORKTREE_PR_MISMATCH', 'Worktree não possui branch comprovada.');
109
+ }
110
+ const normalized = normalizePullRequest(pullRequest);
111
+ const referencedRepository = githubRepository(normalized.reference);
112
+ const localRepository = originRepository(startDir, spawn);
113
+ if (referencedRepository && localRepository && referencedRepository !== localRepository) {
114
+ throw cleanupError('WENDKEEP_WORKTREE_PR_MISMATCH', 'PR pertence a outro repositório.');
115
+ }
116
+ let value;
117
+ try {
118
+ value = await github({ cwd: startDir, pullRequest: normalized, entry: structuredClone(entry) });
119
+ } catch (error) {
120
+ if (error?.code) throw error;
121
+ throw cleanupError('WENDKEEP_WORKTREE_PR_UNAVAILABLE', error?.message || 'GitHub indisponível.');
122
+ }
123
+ const returnedUrl = String(value?.url || '').trim();
124
+ const returnedRepository = githubRepository(returnedUrl);
125
+ let returnedPullRequest = null;
126
+ try { returnedPullRequest = normalizePullRequest(returnedUrl); } catch { /* fail closed below */ }
127
+ if (!returnedRepository
128
+ || returnedPullRequest?.number !== normalized.number
129
+ || Number(value?.number) !== normalized.number
130
+ || (referencedRepository && referencedRepository !== returnedRepository)
131
+ || (localRepository && localRepository !== returnedRepository)) {
132
+ throw cleanupError('WENDKEEP_WORKTREE_PR_MISMATCH', 'PR pertence a outro repositório.');
133
+ }
134
+ const mergeCommitOid = String(value?.mergeCommitOid || value?.mergeCommit?.oid || '').trim();
135
+ if (value?.state !== 'MERGED' || !value?.mergedAt || !mergeCommitOid) {
136
+ throw cleanupError('WENDKEEP_WORKTREE_PR_NOT_MERGED', `PR #${normalized.number} não está merged.`);
137
+ }
138
+ if (value?.isCrossRepository === true || value?.headRefName !== entry.branch) {
139
+ throw cleanupError('WENDKEEP_WORKTREE_PR_MISMATCH', 'PR não corresponde à branch da worktree.');
140
+ }
141
+ const baseRefName = String(value?.baseRefName || entry.base || '').trim();
142
+ if (!baseRefName || git(startDir, [
143
+ 'merge-base', '--is-ancestor', mergeCommitOid, baseRefName,
144
+ ], { ok: false, spawn }).status !== 0) {
145
+ throw cleanupError(
146
+ 'WENDKEEP_WORKTREE_PR_MERGE_UNREACHABLE',
147
+ 'O merge commit do PR não está alcançável pela base local.',
148
+ );
149
+ }
150
+ return {
151
+ number: Number(value.number || normalized.number),
152
+ url: String(value.url || normalized.reference),
153
+ state: 'MERGED',
154
+ mergedAt: String(value.mergedAt),
155
+ headRefName: String(value.headRefName),
156
+ headRefOid: String(value.headRefOid || ''),
157
+ baseRefName,
158
+ mergeCommitOid,
159
+ mergeMode: String(value.mergeMode || 'github'),
160
+ };
161
+ }
162
+
163
+ function outboxEntries(vaultBase) {
164
+ const path = join(vaultBase, '.brain', 'memory-outbox');
165
+ try {
166
+ return readdirSync(path, { withFileTypes: true })
167
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
168
+ .map((entry) => {
169
+ const file = join(path, entry.name);
170
+ let event = null;
171
+ try { event = JSON.parse(readFileSync(file, 'utf8')); } catch { /* corrupt is pending */ }
172
+ return { name: entry.name, event };
173
+ });
174
+ } catch { return []; }
175
+ }
176
+
177
+ function contextsForWorktree(registry, entry) {
178
+ return Object.entries(registry?.active_contexts || {})
179
+ .filter(([, context]) => context?.state === 'active' && context?.worktree_id === entry.worktreeId);
180
+ }
181
+
182
+ export function inspectWorktreeCleanup({
183
+ startDir = process.cwd(), slug, spawn = spawnSync,
184
+ } = {}) {
185
+ const repository = discoverWorktreeRepository({ startDir, spawn });
186
+ const { registry, entry } = requiredEntry(repository, slug);
187
+ const blockers = [];
188
+ const pathExists = existsSync(entry.path);
189
+ const resumesAfterPathRemoval = !pathExists
190
+ && ['cleaning', 'failed'].includes(String(entry.cleanup?.state || ''))
191
+ && ['finish', 'remove'].includes(String(entry.cleanup?.mode || ''))
192
+ && Boolean(String(entry.cleanup?.operationId || '').trim());
193
+ if (pathExists) {
194
+ const status = git(entry.path, ['status', '--porcelain=v1', '--untracked-files=all'], {
195
+ ok: false, spawn,
196
+ });
197
+ if (status.status !== 0 || String(status.stdout || '').trim()) {
198
+ blockers.push({
199
+ code: 'WENDKEEP_WORKTREE_DIRTY',
200
+ recovery: `limpe o checkout e rode wendkeep worktree finish ${slug} novamente`,
201
+ });
202
+ }
203
+ }
204
+ const sessionRegistry = readSessionRegistry(registry.vaultPath);
205
+ const contexts = contextsForWorktree(sessionRegistry, entry);
206
+ const workSessions = new Set(contexts.map(([, context]) => String(context.work_session_id || '')));
207
+ const activeSessions = Object.entries(sessionRegistry.sessions || {}).filter(([, session]) => (
208
+ session?.status === 'active'
209
+ && (workSessions.has(String(session.work_session_id || ''))
210
+ || (String(session.project_scope?.repoRoot || '').trim()
211
+ && comparablePath(session.project_scope.repoRoot) === comparablePath(entry.path)))
212
+ ));
213
+ if (activeSessions.length && !resumesAfterPathRemoval) {
214
+ blockers.push({
215
+ code: 'WENDKEEP_WORKTREE_ACTIVE_SESSION',
216
+ sessions: activeSessions.map(([id]) => id).sort(),
217
+ recovery: 'finalize ou mova as sessões ativas antes do cleanup',
218
+ });
219
+ }
220
+ if (!resumesAfterPathRemoval
221
+ && contexts.some(([, context]) => String(context.delivery_id || '').trim())) {
222
+ blockers.push({
223
+ code: 'WENDKEEP_WORKTREE_ACTIVE_DELIVERY',
224
+ recovery: 'finalize ou abandone a delivery ativa antes do cleanup',
225
+ });
226
+ }
227
+ const outbox = outboxEntries(registry.vaultPath);
228
+ if (outbox.length) {
229
+ blockers.push({
230
+ code: 'WENDKEEP_WORKTREE_OUTBOX_PENDING',
231
+ count: outbox.length,
232
+ recovery: 'publique ou recupere o memory outbox antes do cleanup',
233
+ });
234
+ }
235
+ if (outbox.some(({ event }) => (
236
+ event?.memory_key === 'handoff.latest' || event?.memoryKey === 'handoff.latest'
237
+ ))) {
238
+ blockers.push({
239
+ code: 'WENDKEEP_WORKTREE_HANDOFF_PENDING',
240
+ recovery: 'publique o handoff pendente antes do cleanup',
241
+ });
242
+ }
243
+ return {
244
+ ok: blockers.length === 0,
245
+ blockers,
246
+ repository,
247
+ registry,
248
+ entry: structuredClone(entry),
249
+ contexts: contexts.map(([key, context]) => ({ key, context: structuredClone(context) })),
250
+ };
251
+ }
252
+
253
+ export function cleanupReceiptPath(repository) {
254
+ return join(repository.commonDir, ...RECEIPT_REL.split('/'));
255
+ }
256
+
257
+ function readReceipts(repository) {
258
+ const path = cleanupReceiptPath(repository);
259
+ if (!existsSync(path)) return [];
260
+ return readFileSync(path, 'utf8').split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
261
+ }
262
+
263
+ function receiptId(repositoryId, slug, mode, authority) {
264
+ return createHash('sha256')
265
+ .update(`${repositoryId}\n${slug}\n${mode}\n${authority}\n`)
266
+ .digest('hex').slice(0, 32);
267
+ }
268
+
269
+ function appendReceipt(repository, receipt) {
270
+ const path = cleanupReceiptPath(repository);
271
+ return withWorktreeRegistryLock(path, () => {
272
+ const existing = readReceipts(repository).find((item) => item.id === receipt.id);
273
+ if (existing) return existing;
274
+ mkdirSync(dirname(path), { recursive: true });
275
+ appendFileSync(path, `${JSON.stringify(receipt)}\n`, { encoding: 'utf8', flag: 'a' });
276
+ return receipt;
277
+ });
278
+ }
279
+
280
+ function blockerError(report) {
281
+ const first = report.blockers[0];
282
+ return cleanupError(first.code, first.recovery, { blockers: report.blockers });
283
+ }
284
+
285
+ function reserve(repository, slug, { mode, authority, proof, reason, now }) {
286
+ const operationId = randomUUID();
287
+ let previous = null;
288
+ let resumed = false;
289
+ mutateWorktreeRegistry(repository, (registry) => {
290
+ const entry = registry.entries?.[slug];
291
+ if (!entry) throw cleanupError('WENDKEEP_WORKTREE_NOT_FOUND', `Worktree "${slug}" ausente.`);
292
+ if (entry.state === 'cleaned') {
293
+ previous = entry;
294
+ return registry;
295
+ }
296
+ if (entry.cleanup?.state === 'cleaning') {
297
+ if (existsSync(entry.path)
298
+ || entry.cleanup.mode !== mode
299
+ || entry.cleanup.authority !== authority) {
300
+ throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', `Cleanup de "${slug}" já está em andamento.`);
301
+ }
302
+ previous = entry;
303
+ resumed = true;
304
+ return registry;
305
+ }
306
+ registry.entries[slug] = {
307
+ ...entry,
308
+ state: 'cleaning',
309
+ ...(proof ? { pullRequest: proof } : {}),
310
+ cleanup: {
311
+ schemaVersion: 1,
312
+ operationId,
313
+ state: 'cleaning',
314
+ mode,
315
+ authority,
316
+ ...(reason ? { reason } : {}),
317
+ startedAt: now,
318
+ },
319
+ updatedAt: now,
320
+ };
321
+ return registry;
322
+ });
323
+ return {
324
+ operationId: resumed ? previous.cleanup.operationId : operationId,
325
+ previous,
326
+ resumed,
327
+ };
328
+ }
329
+
330
+ function associatePullRequest(repository, slug, proof, now) {
331
+ mutateWorktreeRegistry(repository, (registry) => {
332
+ const entry = registry.entries?.[slug];
333
+ if (!entry) throw cleanupError('WENDKEEP_WORKTREE_NOT_FOUND', `Worktree "${slug}" ausente.`);
334
+ if (entry.cleanup?.state === 'cleaning') {
335
+ const authority = proof.url || `pr:${proof.number}`;
336
+ if (entry.cleanup.authority !== authority) {
337
+ throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', `Cleanup de "${slug}" já está em andamento.`);
338
+ }
339
+ return registry;
340
+ }
341
+ registry.entries[slug] = { ...entry, pullRequest: proof, updatedAt: now };
342
+ return registry;
343
+ });
344
+ }
345
+
346
+ function failReservation(repository, slug, error, now) {
347
+ mutateWorktreeRegistry(repository, (registry) => {
348
+ const entry = registry.entries?.[slug];
349
+ if (!entry || entry.cleanup?.state !== 'cleaning') return registry;
350
+ registry.entries[slug] = {
351
+ ...entry,
352
+ state: 'cleanup-failed',
353
+ cleanup: {
354
+ ...entry.cleanup,
355
+ state: 'failed',
356
+ failedAt: now,
357
+ error: {
358
+ code: String(error?.code || 'WENDKEEP_WORKTREE_CLEANUP_FAILED'),
359
+ message: String(error?.message || 'Cleanup falhou.'),
360
+ },
361
+ },
362
+ updatedAt: now,
363
+ };
364
+ return registry;
365
+ });
366
+ }
367
+
368
+ function closeContexts(vaultBase, contexts, now) {
369
+ for (const { context } of contexts) {
370
+ mutateActiveContext(vaultBase, {
371
+ projectId: context.project_id,
372
+ repositoryId: context.repository_id,
373
+ worktreeId: context.worktree_id,
374
+ workSessionId: context.work_session_id,
375
+ branch: context.branch,
376
+ headSha: context.head_sha,
377
+ }, (current) => ({
378
+ ...current,
379
+ state: 'closed',
380
+ delivery_id: '',
381
+ }), { expectedRevision: context.revision, now });
382
+ }
383
+ }
384
+
385
+ function removePath(repository, entry, spawn) {
386
+ if (existsSync(entry.path)) {
387
+ git(repository.mainWorktree, ['worktree', 'remove', entry.path], { spawn });
388
+ }
389
+ git(repository.mainWorktree, ['worktree', 'prune'], { spawn });
390
+ }
391
+
392
+ function branchHead(repository, branch, spawn) {
393
+ const result = git(repository.mainWorktree, [
394
+ 'rev-parse', '--verify', `refs/heads/${branch}`,
395
+ ], { ok: false, spawn });
396
+ return result.status === 0 ? String(result.stdout || '').trim() : '';
397
+ }
398
+
399
+ function deleteLocalBranch(repository, branch, expectedHead, spawn) {
400
+ if (git(repository.mainWorktree, [
401
+ 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`,
402
+ ], { ok: false, spawn }).status !== 0) return false;
403
+ if (!expectedHead) {
404
+ throw cleanupError('WENDKEEP_WORKTREE_BRANCH_UNPROVEN', 'Não foi possível provar o head da branch local.');
405
+ }
406
+ git(repository.mainWorktree, ['update-ref', '-d', `refs/heads/${branch}`, expectedHead], { spawn });
407
+ return true;
408
+ }
409
+
410
+ function deleteRemoteBranch(repository, branch, expectedHead, spawn) {
411
+ const remote = git(repository.mainWorktree, [
412
+ 'ls-remote', '--heads', 'origin', `refs/heads/${branch}`,
413
+ ], { ok: false, spawn });
414
+ if (remote.status !== 0) {
415
+ throw cleanupError('WENDKEEP_WORKTREE_REMOTE_UNAVAILABLE', 'Não foi possível consultar a branch remota.');
416
+ }
417
+ const remoteHead = String(remote.stdout || '').trim().split(/\s+/)[0] || '';
418
+ if (!remoteHead) return false;
419
+ if (!expectedHead || remoteHead !== expectedHead) {
420
+ throw cleanupError(
421
+ 'WENDKEEP_WORKTREE_REMOTE_DIVERGED',
422
+ 'A branch remota divergiu do head comprovado; nenhuma exclusão remota foi feita.',
423
+ );
424
+ }
425
+ git(repository.mainWorktree, ['push', 'origin', '--delete', branch], { spawn });
426
+ return true;
427
+ }
428
+
429
+ function finalize(repository, slug, { receipt, now }) {
430
+ mutateWorktreeRegistry(repository, (registry) => {
431
+ const entry = registry.entries[slug];
432
+ registry.entries[slug] = {
433
+ ...entry,
434
+ state: 'cleaned',
435
+ cleanup: {
436
+ ...entry.cleanup,
437
+ state: 'completed',
438
+ receiptId: receipt.id,
439
+ finishedAt: now,
440
+ },
441
+ updatedAt: now,
442
+ };
443
+ return registry;
444
+ });
445
+ }
446
+
447
+ function existingCompletion(repository, entry) {
448
+ if (entry?.state !== 'cleaned') return null;
449
+ const receipt = readReceipts(repository).find((item) => item.id === entry.cleanup?.receiptId)
450
+ || readReceipts(repository).find((item) => item.slug === entry.slug);
451
+ return receipt ? { state: 'completed', idempotent: true, receipt } : null;
452
+ }
453
+
454
+ export async function finishManagedWorktree({
455
+ startDir = process.cwd(),
456
+ slug,
457
+ pullRequest,
458
+ deleteRemote = false,
459
+ github = defaultGithub,
460
+ spawn = spawnSync,
461
+ now = () => new Date().toISOString(),
462
+ } = {}) {
463
+ const repository = discoverWorktreeRepository({ startDir, spawn });
464
+ const initial = requiredEntry(repository, slug);
465
+ const completed = existingCompletion(repository, initial.entry);
466
+ if (completed) return completed;
467
+ const pullRequestReference = pullRequest
468
+ || initial.entry.pullRequest?.number
469
+ || initial.entry.pullRequest?.url;
470
+ if (git(repository.mainWorktree, ['remote', 'get-url', 'origin'], {
471
+ ok: false, spawn,
472
+ }).status === 0) {
473
+ git(repository.mainWorktree, ['fetch', '--prune', 'origin'], { spawn });
474
+ }
475
+ const proof = await verifyMergedPullRequest({
476
+ startDir: repository.mainWorktree,
477
+ entry: initial.entry,
478
+ pullRequest: pullRequestReference,
479
+ github,
480
+ spawn,
481
+ });
482
+ const at = String(now());
483
+ associatePullRequest(repository, slug, proof, at);
484
+ const report = inspectWorktreeCleanup({ startDir: repository.mainWorktree, slug, spawn });
485
+ if (!report.ok) throw blockerError(report);
486
+ const authority = proof.url || `pr:${proof.number}`;
487
+ const reservation = reserve(repository, slug, {
488
+ mode: 'finish', authority, proof, now: at,
489
+ });
490
+ if (reservation.previous?.state === 'cleaned') {
491
+ return existingCompletion(repository, requiredEntry(repository, slug).entry);
492
+ }
493
+ const { operationId } = reservation;
494
+ try {
495
+ const expectedHead = branchHead(repository, report.entry.branch, spawn);
496
+ removePath(repository, report.entry, spawn);
497
+ closeContexts(report.registry.vaultPath, report.contexts, at);
498
+ const remoteBranchDeleted = deleteRemote
499
+ ? deleteRemoteBranch(repository, report.entry.branch, expectedHead, spawn)
500
+ : false;
501
+ const localBranchDeleted = deleteLocalBranch(
502
+ repository, report.entry.branch, expectedHead, spawn,
503
+ );
504
+ const receipt = appendReceipt(repository, {
505
+ schemaVersion: 1,
506
+ id: receiptId(report.registry.repositoryId, slug, 'finish', authority),
507
+ operationId,
508
+ slug,
509
+ mode: 'finish',
510
+ outcome: 'completed',
511
+ branch: report.entry.branch,
512
+ head: expectedHead || report.entry.head,
513
+ pull_request: proof,
514
+ local_branch_deleted: localBranchDeleted,
515
+ remote_branch_deleted: remoteBranchDeleted,
516
+ finished_at: at,
517
+ });
518
+ finalize(repository, slug, { receipt, now: at });
519
+ return { state: 'completed', idempotent: false, receipt };
520
+ } catch (error) {
521
+ failReservation(repository, slug, error, String(now()));
522
+ throw error;
523
+ }
524
+ }
525
+
526
+ export async function removeManagedWorktree({
527
+ startDir = process.cwd(),
528
+ slug,
529
+ reason,
530
+ spawn = spawnSync,
531
+ now = () => new Date().toISOString(),
532
+ } = {}) {
533
+ const normalizedReason = String(reason || '').trim();
534
+ if (!normalizedReason) {
535
+ throw cleanupError('WENDKEEP_WORKTREE_REASON_REQUIRED', '`worktree remove` exige --reason.');
536
+ }
537
+ const repository = discoverWorktreeRepository({ startDir, spawn });
538
+ const initial = requiredEntry(repository, slug);
539
+ const completed = existingCompletion(repository, initial.entry);
540
+ if (completed) return completed;
541
+ const report = inspectWorktreeCleanup({ startDir: repository.mainWorktree, slug, spawn });
542
+ if (!report.ok) throw blockerError(report);
543
+ const at = String(now());
544
+ const authority = `reason:${normalizedReason}`;
545
+ const { operationId } = reserve(repository, slug, {
546
+ mode: 'remove', authority, reason: normalizedReason, now: at,
547
+ });
548
+ try {
549
+ removePath(repository, report.entry, spawn);
550
+ closeContexts(report.registry.vaultPath, report.contexts, at);
551
+ const receipt = appendReceipt(repository, {
552
+ schemaVersion: 1,
553
+ id: receiptId(report.registry.repositoryId, slug, 'remove', authority),
554
+ operationId,
555
+ slug,
556
+ mode: 'remove',
557
+ outcome: 'completed',
558
+ branch: report.entry.branch,
559
+ head: report.entry.head,
560
+ reason: normalizedReason,
561
+ local_branch_deleted: false,
562
+ remote_branch_deleted: false,
563
+ finished_at: at,
564
+ });
565
+ finalize(repository, slug, { receipt, now: at });
566
+ return { state: 'completed', idempotent: false, receipt };
567
+ } catch (error) {
568
+ failReservation(repository, slug, error, String(now()));
569
+ throw error;
570
+ }
571
+ }
572
+
573
+ export async function cleanupMergedWorktrees({
574
+ startDir = process.cwd(),
575
+ apply = false,
576
+ github = defaultGithub,
577
+ spawn = spawnSync,
578
+ now = () => new Date().toISOString(),
579
+ } = {}) {
580
+ const repository = discoverWorktreeRepository({ startDir, spawn });
581
+ const { registry } = readWorktreeRegistry(repository);
582
+ const actions = [];
583
+ for (const slug of Object.keys(registry.entries || {}).sort()) {
584
+ const entry = registry.entries[slug];
585
+ if (entry.state === 'cleaned') continue;
586
+ const pullRequest = entry.pullRequest?.number || entry.pullRequest?.url;
587
+ if (!pullRequest) {
588
+ actions.push({
589
+ slug, outcome: 'blocked', blockers: ['WENDKEEP_WORKTREE_PR_UNASSOCIATED'],
590
+ });
591
+ continue;
592
+ }
593
+ try {
594
+ const proof = await verifyMergedPullRequest({
595
+ startDir: repository.mainWorktree, entry, pullRequest, github, spawn,
596
+ });
597
+ const report = inspectWorktreeCleanup({
598
+ startDir: repository.mainWorktree, slug, spawn,
599
+ });
600
+ if (!report.ok) {
601
+ actions.push({ slug, outcome: 'blocked', blockers: report.blockers.map((item) => item.code) });
602
+ } else if (!apply) {
603
+ actions.push({ slug, outcome: 'would-finish', pullRequest: proof.number });
604
+ } else {
605
+ const result = await finishManagedWorktree({
606
+ startDir: repository.mainWorktree,
607
+ slug,
608
+ pullRequest,
609
+ github,
610
+ spawn,
611
+ now,
612
+ });
613
+ actions.push({ slug, outcome: result.state, receipt: result.receipt });
614
+ }
615
+ } catch (error) {
616
+ actions.push({ slug, outcome: 'blocked', blockers: [String(error?.code || 'WENDKEEP_WORKTREE_UNPROVEN')] });
617
+ }
618
+ }
619
+ return { ok: actions.every((item) => item.outcome !== 'blocked'), dryRun: !apply, actions };
620
+ }
621
+
622
+ export function pruneManagedWorktrees({
623
+ startDir = process.cwd(), apply = false, spawn = spawnSync,
624
+ } = {}) {
625
+ const repository = discoverWorktreeRepository({ startDir, spawn });
626
+ const { registry } = readWorktreeRegistry(repository);
627
+ const listed = git(repository.mainWorktree, ['worktree', 'list', '--porcelain'], { spawn });
628
+ const registeredPaths = new Set(String(listed.stdout || '').split(/\r?\n/)
629
+ .filter((line) => line.startsWith('worktree '))
630
+ .map((line) => comparablePath(line.slice('worktree '.length))));
631
+ const actions = Object.values(registry.entries || {})
632
+ .filter((entry) => (
633
+ entry?.path
634
+ && !existsSync(entry.path)
635
+ && registeredPaths.has(comparablePath(entry.path))
636
+ ))
637
+ .map((entry) => ({ slug: entry.slug, path: entry.path, action: 'prune-git-metadata' }))
638
+ .sort((left, right) => left.slug.localeCompare(right.slug));
639
+ if (apply) git(repository.mainWorktree, ['worktree', 'prune'], { spawn });
640
+ return { dryRun: !apply, actions };
641
+ }
642
+
643
+ function cleanupRecovery(entry) {
644
+ if (entry.cleanup?.mode === 'remove') {
645
+ const reason = String(entry.cleanup.reason || 'confirme o abandono').replaceAll('"', '\\"');
646
+ return `wendkeep worktree remove ${entry.slug} --reason "${reason}"`;
647
+ }
648
+ const pullRequest = entry.pullRequest?.number || entry.pullRequest?.url || '<PR>';
649
+ return `wendkeep worktree finish ${entry.slug} --pr ${pullRequest}`;
650
+ }
651
+
652
+ export function diagnoseManagedWorktreeCleanups({
653
+ startDir = process.cwd(), spawn = spawnSync,
654
+ } = {}) {
655
+ let repository;
656
+ let registry;
657
+ try {
658
+ repository = discoverWorktreeRepository({ startDir, spawn });
659
+ ({ registry } = readWorktreeRegistry(repository));
660
+ } catch (error) {
661
+ if (error?.code === 'WENDKEEP_WORKTREE_REGISTRY_MISSING'
662
+ || error?.code === 'WENDKEEP_WORKTREE_GIT_FAILED') {
663
+ return { initialized: false, issues: [] };
664
+ }
665
+ throw error;
666
+ }
667
+ let receipts = [];
668
+ try {
669
+ receipts = readReceipts(repository);
670
+ } catch {
671
+ return {
672
+ initialized: true,
673
+ issues: [{
674
+ slug: '*',
675
+ state: 'receipt-invalid',
676
+ errorCode: 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_INVALID',
677
+ repair: 'revise o receipt store append-only antes de retomar o cleanup',
678
+ }],
679
+ };
680
+ }
681
+ const receiptIds = new Set(receipts.map((receipt) => receipt.id));
682
+ const issues = [];
683
+ for (const slug of Object.keys(registry.entries || {}).sort()) {
684
+ const entry = registry.entries[slug];
685
+ if (entry.cleanup?.state === 'cleaning') {
686
+ issues.push({
687
+ slug,
688
+ state: entry.state,
689
+ errorCode: existsSync(entry.path)
690
+ ? 'WENDKEEP_WORKTREE_CLEANUP_INCOMPLETE'
691
+ : 'WENDKEEP_WORKTREE_CLEANUP_INTERRUPTED',
692
+ repair: cleanupRecovery(entry),
693
+ });
694
+ } else if (entry.cleanup?.state === 'failed') {
695
+ issues.push({
696
+ slug,
697
+ state: entry.state,
698
+ errorCode: entry.cleanup.error?.code || 'WENDKEEP_WORKTREE_CLEANUP_FAILED',
699
+ repair: cleanupRecovery(entry),
700
+ });
701
+ } else if (entry.state === 'cleaned'
702
+ && (!entry.cleanup?.receiptId || !receiptIds.has(entry.cleanup.receiptId))) {
703
+ issues.push({
704
+ slug,
705
+ state: entry.state,
706
+ errorCode: 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_MISSING',
707
+ repair: 'revise o registry e o receipt store; não invente um receipt retroativo',
708
+ });
709
+ }
710
+ }
711
+ return { initialized: true, issues };
712
+ }