praxis-agent 0.61.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -6
- package/dist/application/agent-worktree-owner.d.ts +12 -0
- package/dist/application/agent-worktree-owner.js +35 -0
- package/dist/application/agent-worktree.d.ts +19 -0
- package/dist/application/agent-worktree.js +96 -0
- package/dist/application/managed-worktree-hooks.d.ts +11 -0
- package/dist/application/managed-worktree-hooks.js +23 -0
- package/dist/application/managed-worktree.d.ts +70 -0
- package/dist/application/managed-worktree.js +1081 -108
- package/dist/application/subagent-service.d.ts +3 -1
- package/dist/application/subagent-service.js +106 -23
- package/dist/application/team-capability.d.ts +4 -0
- package/dist/application/team-capability.js +20 -0
- package/dist/application/team-manager.d.ts +2 -0
- package/dist/application/team-manager.js +207 -52
- package/dist/application/team-observability.d.ts +5 -0
- package/dist/application/team-observability.js +152 -32
- package/dist/application/team-workspace.d.ts +18 -1
- package/dist/application/team-workspace.js +217 -71
- package/dist/application/workflow-worktree.d.ts +2 -8
- package/dist/application/workflow-worktree.js +2 -23
- package/dist/cli/tui/doctor-dashboard.js +1 -0
- package/dist/cli-runtime.js +5 -0
- package/dist/maintenance/doctor.d.ts +1 -1
- package/dist/maintenance/doctor.js +55 -0
- package/dist/persistence/managed-worktree-store.d.ts +16 -1
- package/dist/persistence/managed-worktree-store.js +61 -21
- package/dist/platform/exclusive-file-lease.d.ts +6 -1
- package/dist/platform/exclusive-file-lease.js +48 -7
- package/package.json +1 -1
|
@@ -5,11 +5,18 @@ import { lstat, mkdir, open, realpath } from 'node:fs/promises';
|
|
|
5
5
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
7
|
import { promisify } from 'node:util';
|
|
8
|
-
import { ManagedWorktreeStore, } from '../persistence/managed-worktree-store.js';
|
|
8
|
+
import { ManagedWorktreeStore, inspectManagedWorktreeRegistry, } from '../persistence/managed-worktree-store.js';
|
|
9
9
|
import { resolveProjectIdentity } from '../platform/project-identity.js';
|
|
10
|
+
import { sanitizeProjectPath } from '../platform/project-path-key.js';
|
|
10
11
|
import { writeFileAtomically } from '../platform/atomic-write.js';
|
|
11
12
|
import { ExclusiveFileLease, } from '../platform/exclusive-file-lease.js';
|
|
13
|
+
import { isTerminalLifecycleState } from '../core/agent-orchestration.js';
|
|
14
|
+
import { SubagentLifecycleStore } from '../persistence/subagent-lifecycle-store.js';
|
|
15
|
+
import { parseAgentWorktreeOwner } from './agent-worktree-owner.js';
|
|
16
|
+
import { isWorkflowAgentId, isWorkflowRunId } from '../native/workflow.js';
|
|
17
|
+
import { parseTeamId } from '../core/team-ownership.js';
|
|
12
18
|
const execFileAsync = promisify(execFile);
|
|
19
|
+
const reconciliationCache = new Map();
|
|
13
20
|
async function gitRaw(cwd, args) {
|
|
14
21
|
return (await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8' })).stdout;
|
|
15
22
|
}
|
|
@@ -26,6 +33,52 @@ async function gitOptional(cwd, args) {
|
|
|
26
33
|
throw error;
|
|
27
34
|
}
|
|
28
35
|
}
|
|
36
|
+
async function ownedBranchRef(repositoryRoot, branch, baseCommit) {
|
|
37
|
+
try {
|
|
38
|
+
const checked = await git(repositoryRoot, [
|
|
39
|
+
'check-ref-format',
|
|
40
|
+
'--branch',
|
|
41
|
+
branch,
|
|
42
|
+
]);
|
|
43
|
+
if (checked !== branch)
|
|
44
|
+
return 'ambiguous';
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return 'ambiguous';
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const value = await git(repositoryRoot, [
|
|
51
|
+
'rev-parse',
|
|
52
|
+
'--verify',
|
|
53
|
+
'--quiet',
|
|
54
|
+
`refs/heads/${branch}`,
|
|
55
|
+
]);
|
|
56
|
+
return value === baseCommit ? 'match' : 'ambiguous';
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return Number(error.code) === 1
|
|
60
|
+
? 'absent'
|
|
61
|
+
: 'ambiguous';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function removeOwnedBranch(repositoryRoot, branch, baseCommit) {
|
|
65
|
+
const ref = await ownedBranchRef(repositoryRoot, branch, baseCommit);
|
|
66
|
+
if (ref === 'absent')
|
|
67
|
+
return;
|
|
68
|
+
if (ref === 'ambiguous')
|
|
69
|
+
throw new Error('owned branch moved or is ambiguous');
|
|
70
|
+
try {
|
|
71
|
+
await git(repositoryRoot, [
|
|
72
|
+
'update-ref',
|
|
73
|
+
'-d',
|
|
74
|
+
`refs/heads/${branch}`,
|
|
75
|
+
baseCommit,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new Error(`could not safely remove owned branch: ${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
29
82
|
async function registeredWorktrees(root) {
|
|
30
83
|
const output = await gitRaw(root, ['worktree', 'list', '--porcelain', '-z']);
|
|
31
84
|
return new Set(output
|
|
@@ -49,6 +102,19 @@ function validOwnerId(ownerId) {
|
|
|
49
102
|
return code <= 0x1f || code === 0x7f;
|
|
50
103
|
}));
|
|
51
104
|
}
|
|
105
|
+
function validRetentionReason(reason) {
|
|
106
|
+
return (typeof reason === 'string' &&
|
|
107
|
+
reason.trim().length > 0 &&
|
|
108
|
+
reason.length <= 1024 &&
|
|
109
|
+
!Array.from(reason).some((character) => {
|
|
110
|
+
const code = character.codePointAt(0) ?? 0;
|
|
111
|
+
return code <= 0x1f || code === 0x7f;
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
function assertRetentionReason(reason) {
|
|
115
|
+
if (!validRetentionReason(reason))
|
|
116
|
+
throw new Error('Managed worktree retention reason is invalid');
|
|
117
|
+
}
|
|
52
118
|
function worktreeError(options, message) {
|
|
53
119
|
return new Error(`${options.label} worktree ${message}`);
|
|
54
120
|
}
|
|
@@ -186,6 +252,13 @@ async function writeMarker(path, record) {
|
|
|
186
252
|
throw new Error('Managed worktree marker already exists');
|
|
187
253
|
}
|
|
188
254
|
async function readMarker(worktreePath, record) {
|
|
255
|
+
const marker = await readMarkerIdentity(worktreePath);
|
|
256
|
+
if (marker.worktreeId !== record.worktreeId ||
|
|
257
|
+
marker.repositoryRoot !== record.repositoryRoot) {
|
|
258
|
+
throw new Error('worktree marker does not match ownership record');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function readMarkerIdentity(worktreePath) {
|
|
189
262
|
const markerPath = join(await linkedGitDirectory(worktreePath), 'PRAXIS_WORKTREE');
|
|
190
263
|
let marker;
|
|
191
264
|
try {
|
|
@@ -200,10 +273,20 @@ async function readMarker(worktreePath, record) {
|
|
|
200
273
|
Object.keys(marker).length !== MARKER_FIELDS.size ||
|
|
201
274
|
Object.keys(marker).some((key) => !MARKER_FIELDS.has(key)) ||
|
|
202
275
|
marker.version !== 1 ||
|
|
203
|
-
marker.worktreeId !==
|
|
204
|
-
marker.repositoryRoot !==
|
|
205
|
-
throw new Error('worktree marker
|
|
276
|
+
typeof marker.worktreeId !== 'string' ||
|
|
277
|
+
typeof marker.repositoryRoot !== 'string') {
|
|
278
|
+
throw new Error('worktree marker is invalid');
|
|
206
279
|
}
|
|
280
|
+
const fields = marker;
|
|
281
|
+
if (!/^[a-f0-9]{32,64}$/u.test(String(fields.worktreeId)) ||
|
|
282
|
+
!isAbsolute(String(fields.repositoryRoot)) ||
|
|
283
|
+
resolve(String(fields.repositoryRoot)) !== String(fields.repositoryRoot)) {
|
|
284
|
+
throw new Error('worktree marker is invalid');
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
worktreeId: fields.worktreeId,
|
|
288
|
+
repositoryRoot: fields.repositoryRoot,
|
|
289
|
+
};
|
|
207
290
|
}
|
|
208
291
|
async function inspectOwnedCheckout(record, registered) {
|
|
209
292
|
const registrations = registered ?? (await registeredWorktrees(record.repositoryRoot));
|
|
@@ -238,8 +321,13 @@ async function inspectOwnedCheckout(record, registered) {
|
|
|
238
321
|
'--short',
|
|
239
322
|
'HEAD',
|
|
240
323
|
]);
|
|
241
|
-
if (branch)
|
|
242
|
-
|
|
324
|
+
if (record.branch === null) {
|
|
325
|
+
if (branch)
|
|
326
|
+
throw new Error('worktree is not detached');
|
|
327
|
+
}
|
|
328
|
+
else if (branch !== record.branch) {
|
|
329
|
+
throw new Error('worktree branch does not match ownership record');
|
|
330
|
+
}
|
|
243
331
|
return { status, head };
|
|
244
332
|
}
|
|
245
333
|
function sameOwnership(left, right) {
|
|
@@ -275,7 +363,650 @@ async function retain(store, record, reason) {
|
|
|
275
363
|
};
|
|
276
364
|
}
|
|
277
365
|
}
|
|
366
|
+
async function reconciliationRetain(store, record, base, reason) {
|
|
367
|
+
const result = await retain(store, record, reason);
|
|
368
|
+
return {
|
|
369
|
+
...base,
|
|
370
|
+
disposition: 'retained',
|
|
371
|
+
reason: result.reason ?? reason,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
async function reconcileAgentLifecycle(stateRoot, record) {
|
|
375
|
+
const owner = parseAgentWorktreeOwner(record.ownerId);
|
|
376
|
+
if (!owner)
|
|
377
|
+
return { safe: false, reason: 'Agent owner ID is malformed' };
|
|
378
|
+
try {
|
|
379
|
+
const lifecycleStore = new SubagentLifecycleStore(stateRoot, owner.sessionId, owner.agentId);
|
|
380
|
+
const ownership = await lifecycleStore.reconcileOwnerLoss();
|
|
381
|
+
if (ownership.owned)
|
|
382
|
+
return { safe: false, reason: 'Agent lifecycle owner is live' };
|
|
383
|
+
const lifecycle = await lifecycleStore.read();
|
|
384
|
+
const classification = classifyAgentLifecycle(lifecycle, owner);
|
|
385
|
+
return classification.status === 'safely-releasable'
|
|
386
|
+
? { safe: true }
|
|
387
|
+
: { safe: false, reason: classification.reason };
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
return {
|
|
391
|
+
safe: false,
|
|
392
|
+
reason: `Agent lifecycle state is unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function classifyAgentLifecycle(lifecycle, owner) {
|
|
397
|
+
if (!lifecycle)
|
|
398
|
+
return { status: 'unsafe', reason: 'Agent lifecycle state is missing' };
|
|
399
|
+
const snapshot = lifecycle.lifecycle;
|
|
400
|
+
const matchingToken = isTerminalLifecycleState(snapshot.state)
|
|
401
|
+
? snapshot.previousOwnerToken === owner.executionToken
|
|
402
|
+
: snapshot.owner?.token === owner.executionToken;
|
|
403
|
+
if (!matchingToken)
|
|
404
|
+
return {
|
|
405
|
+
status: 'unsafe',
|
|
406
|
+
reason: 'Agent lifecycle owner token does not match worktree owner',
|
|
407
|
+
};
|
|
408
|
+
if (snapshot.state === 'failed' || snapshot.state === 'cancelled')
|
|
409
|
+
return {
|
|
410
|
+
status: 'retained',
|
|
411
|
+
reason: `Agent lifecycle is ${snapshot.state}; evidence was retained`,
|
|
412
|
+
};
|
|
413
|
+
if (snapshot.state === 'orphaned')
|
|
414
|
+
return {
|
|
415
|
+
status: 'retained',
|
|
416
|
+
reason: 'Agent lifecycle is orphaned; evidence was retained',
|
|
417
|
+
};
|
|
418
|
+
if (!isTerminalLifecycleState(snapshot.state))
|
|
419
|
+
return {
|
|
420
|
+
status: 'unsafe',
|
|
421
|
+
reason: `Agent lifecycle remains ${snapshot.state}; owner state is unavailable`,
|
|
422
|
+
};
|
|
423
|
+
if (snapshot.state !== 'completed')
|
|
424
|
+
return { status: 'unsafe', reason: `Agent lifecycle is ${snapshot.state}` };
|
|
425
|
+
return {
|
|
426
|
+
status: 'safely-releasable',
|
|
427
|
+
reason: 'ownership and Git state are clean',
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
async function inspectAgentLifecycle(stateRoot, record) {
|
|
431
|
+
const owner = parseAgentWorktreeOwner(record.ownerId);
|
|
432
|
+
if (!owner)
|
|
433
|
+
return { status: 'unsafe', reason: 'Agent owner ID is malformed' };
|
|
434
|
+
let lifecycle;
|
|
435
|
+
try {
|
|
436
|
+
lifecycle = await new SubagentLifecycleStore(stateRoot, owner.sessionId, owner.agentId).read();
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return {
|
|
440
|
+
status: 'unsafe',
|
|
441
|
+
reason: 'Agent lifecycle state is unavailable',
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
return classifyAgentLifecycle(lifecycle, owner);
|
|
445
|
+
}
|
|
446
|
+
function healthEntry(recordPath, record, present, status, reason) {
|
|
447
|
+
return {
|
|
448
|
+
recordPath,
|
|
449
|
+
worktreeId: record.worktreeId,
|
|
450
|
+
kind: record.kind,
|
|
451
|
+
policy: record.policy,
|
|
452
|
+
recordState: record.state,
|
|
453
|
+
worktreePath: record.worktreePath,
|
|
454
|
+
branch: record.branch,
|
|
455
|
+
present,
|
|
456
|
+
status,
|
|
457
|
+
reason: boundedHealthReason(reason),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const HEALTH_STABLE_REASONS = new Set([
|
|
461
|
+
'managed worktree path must not be a symlink',
|
|
462
|
+
'managed worktree path must be a directory',
|
|
463
|
+
'worktree path is not a real directory',
|
|
464
|
+
'worktree path is not canonical',
|
|
465
|
+
'worktree is not registered',
|
|
466
|
+
'registered worktree root does not match',
|
|
467
|
+
'repository identity does not match',
|
|
468
|
+
'worktree marker does not match ownership record',
|
|
469
|
+
'worktree marker is invalid',
|
|
470
|
+
'worktree is not detached',
|
|
471
|
+
'worktree branch does not match ownership record',
|
|
472
|
+
]);
|
|
473
|
+
function boundedHealthReason(reason) {
|
|
474
|
+
const codePoints = Array.from(reason);
|
|
475
|
+
return codePoints.length <= 256 ? reason : codePoints.slice(0, 256).join('');
|
|
476
|
+
}
|
|
477
|
+
function healthErrorReason(error, fallback) {
|
|
478
|
+
const message = error instanceof Error ? error.message : '';
|
|
479
|
+
return HEALTH_STABLE_REASONS.has(message) ? message : fallback;
|
|
480
|
+
}
|
|
481
|
+
function validHealthOwner(record) {
|
|
482
|
+
const parts = record.ownerId.split(':');
|
|
483
|
+
if (record.kind === 'workflow')
|
|
484
|
+
return (parts.length === 3 &&
|
|
485
|
+
parts[0] === 'workflow' &&
|
|
486
|
+
isWorkflowRunId(parts[1] ?? '') &&
|
|
487
|
+
isWorkflowAgentId(parts[2] ?? ''));
|
|
488
|
+
if (record.kind === 'agent')
|
|
489
|
+
return parseAgentWorktreeOwner(record.ownerId) !== null;
|
|
490
|
+
if (parts.length !== 5 || parts[0] !== 'team')
|
|
491
|
+
return false;
|
|
492
|
+
try {
|
|
493
|
+
parseTeamId(parts[1]);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
return (/^[1-9]\d*$/u.test(parts[2] ?? '') &&
|
|
499
|
+
Number.isSafeInteger(Number(parts[2])) &&
|
|
500
|
+
Number(parts[2]) > 0 &&
|
|
501
|
+
/^[a-f0-9]{24}$/u.test(parts[3] ?? '') &&
|
|
502
|
+
/^[A-Za-z0-9_-]{1,128}$/u.test(parts[4] ?? ''));
|
|
503
|
+
}
|
|
504
|
+
async function preflightHealthPath(record) {
|
|
505
|
+
let entry;
|
|
506
|
+
try {
|
|
507
|
+
entry = await lstat(record.worktreePath);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
if (error.code !== 'ENOENT')
|
|
511
|
+
throw error;
|
|
512
|
+
await assertRealDirectory(record.worktreePath, 'managed worktree path');
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
if (entry.isSymbolicLink() || !entry.isDirectory())
|
|
516
|
+
throw new Error('worktree path is not a real directory');
|
|
517
|
+
await assertRealDirectory(record.worktreePath, 'managed worktree path');
|
|
518
|
+
if ((await realpath(record.worktreePath)) !== resolve(record.worktreePath))
|
|
519
|
+
throw new Error('worktree path is not canonical');
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
function classifyCleanHealthRecord(record, absent, agentLifecycle) {
|
|
523
|
+
if (record.state === 'retained')
|
|
524
|
+
return {
|
|
525
|
+
status: 'retained',
|
|
526
|
+
reason: record.retentionReason ?? 'record is retained',
|
|
527
|
+
};
|
|
528
|
+
if (record.policy === 'durable' || record.kind === 'team')
|
|
529
|
+
return {
|
|
530
|
+
status: 'retained',
|
|
531
|
+
reason: record.policy === 'durable'
|
|
532
|
+
? 'durable retention policy'
|
|
533
|
+
: 'team worktree is retained',
|
|
534
|
+
};
|
|
535
|
+
if (record.kind === 'agent')
|
|
536
|
+
return (agentLifecycle ?? {
|
|
537
|
+
status: 'unsafe',
|
|
538
|
+
reason: 'Agent lifecycle state is missing',
|
|
539
|
+
});
|
|
540
|
+
return {
|
|
541
|
+
status: 'safely-releasable',
|
|
542
|
+
reason: absent
|
|
543
|
+
? 'checkout is absent and remaining ownership evidence is releasable'
|
|
544
|
+
: 'ownership and Git state are clean',
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
export async function inspectManagedWorktreeHealth(options) {
|
|
548
|
+
const limit = options.limit ?? 64;
|
|
549
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64)
|
|
550
|
+
throw new Error('Managed worktree health limit must be an integer from 1 to 64');
|
|
551
|
+
const repositoryRoot = await resolveProjectIdentity(options.cwd);
|
|
552
|
+
const registryDirectory = resolve(options.stateRoot, 'managed-worktrees', sanitizeProjectPath(repositoryRoot));
|
|
553
|
+
const stateRoot = resolve(options.stateRoot);
|
|
554
|
+
const managedRoot = resolve(stateRoot, 'managed-worktrees');
|
|
555
|
+
for (const [path, description] of [
|
|
556
|
+
[stateRoot, 'managed worktree state root'],
|
|
557
|
+
[managedRoot, 'managed worktree registry root'],
|
|
558
|
+
]) {
|
|
559
|
+
try {
|
|
560
|
+
const info = await lstat(path);
|
|
561
|
+
if (info.isSymbolicLink())
|
|
562
|
+
throw new Error(`${description} must not be a symlink`);
|
|
563
|
+
if (!info.isDirectory())
|
|
564
|
+
throw new Error(`${description} must be a directory`);
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
if (error.code !== 'ENOENT')
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
const info = await lstat(registryDirectory);
|
|
573
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
574
|
+
throw new Error('managed worktree registry must be a real directory');
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
if (error.code !== 'ENOENT')
|
|
578
|
+
throw error;
|
|
579
|
+
return {
|
|
580
|
+
repositoryRoot,
|
|
581
|
+
inspected: 0,
|
|
582
|
+
truncated: false,
|
|
583
|
+
counts: {
|
|
584
|
+
active: 0,
|
|
585
|
+
retained: 0,
|
|
586
|
+
safelyReleasable: 0,
|
|
587
|
+
released: 0,
|
|
588
|
+
unsafe: 0,
|
|
589
|
+
},
|
|
590
|
+
entries: [],
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const snapshot = await inspectManagedWorktreeRegistry({
|
|
594
|
+
stateRoot: options.stateRoot,
|
|
595
|
+
repositoryRoot,
|
|
596
|
+
limit,
|
|
597
|
+
});
|
|
598
|
+
const entries = [];
|
|
599
|
+
for (const item of snapshot.entries) {
|
|
600
|
+
if ('error' in item) {
|
|
601
|
+
entries.push({
|
|
602
|
+
recordPath: item.path,
|
|
603
|
+
worktreeId: null,
|
|
604
|
+
kind: null,
|
|
605
|
+
policy: null,
|
|
606
|
+
recordState: null,
|
|
607
|
+
worktreePath: null,
|
|
608
|
+
branch: null,
|
|
609
|
+
present: null,
|
|
610
|
+
status: 'unsafe',
|
|
611
|
+
reason: 'managed worktree record is invalid',
|
|
612
|
+
});
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const original = item.record;
|
|
616
|
+
if (!validHealthOwner(original)) {
|
|
617
|
+
entries.push(healthEntry(item.path, original, null, 'unsafe', `${original.kind[0]?.toUpperCase() ?? ''}${original.kind.slice(1)} owner ID is malformed`));
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
let preflightPresent;
|
|
621
|
+
try {
|
|
622
|
+
preflightPresent = await preflightHealthPath(original);
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
entries.push(healthEntry(item.path, original, true, 'unsafe', healthErrorReason(error, 'worktree path could not be inspected safely')));
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
const base = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, original.worktreeId);
|
|
629
|
+
const lease = await base.acquireLease();
|
|
630
|
+
if (!lease) {
|
|
631
|
+
const unavailableStatus = original.state === 'released'
|
|
632
|
+
? 'unsafe'
|
|
633
|
+
: ['creating', 'active', 'releasing'].includes(original.state)
|
|
634
|
+
? 'active'
|
|
635
|
+
: 'retained';
|
|
636
|
+
entries.push(healthEntry(item.path, original, preflightPresent, unavailableStatus, original.state === 'released'
|
|
637
|
+
? 'released record has a live or unavailable lease'
|
|
638
|
+
: unavailableStatus === 'active'
|
|
639
|
+
? 'worktree lease is live or unavailable'
|
|
640
|
+
: (original.retentionReason ?? 'record is retained')));
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
let record;
|
|
645
|
+
try {
|
|
646
|
+
record = await base.read();
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
entries.push(healthEntry(item.path, original, null, 'unsafe', 'managed worktree record could not be reread safely'));
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (!sameOwnership(record, original)) {
|
|
653
|
+
entries.push(healthEntry(item.path, record, null, 'unsafe', 'ownership record changed during inspection'));
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const registered = await registeredWorktrees(record.repositoryRoot).catch(() => null);
|
|
657
|
+
if (!registered) {
|
|
658
|
+
entries.push(healthEntry(item.path, record, null, 'unsafe', 'could not inspect registered worktrees'));
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
let present = false;
|
|
662
|
+
try {
|
|
663
|
+
const info = await lstat(record.worktreePath);
|
|
664
|
+
present = true;
|
|
665
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
666
|
+
throw new Error('worktree path is not a real directory');
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
if (error.code !== 'ENOENT') {
|
|
670
|
+
entries.push(healthEntry(item.path, record, true, 'unsafe', healthErrorReason(error, 'worktree ownership and Git state could not be validated')));
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const registeredPath = registered.has(resolve(record.worktreePath));
|
|
675
|
+
if (record.state === 'released') {
|
|
676
|
+
let branchEvidence = 'absent';
|
|
677
|
+
if (record.branch !== null)
|
|
678
|
+
branchEvidence = await ownedBranchRef(record.repositoryRoot, record.branch, record.baseCommit);
|
|
679
|
+
if (present || registeredPath || branchEvidence !== 'absent') {
|
|
680
|
+
entries.push(healthEntry(item.path, record, present, 'unsafe', 'released record has contradictory checkout or branch evidence'));
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
entries.push(healthEntry(item.path, record, false, 'released', 'record is released'));
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (!present) {
|
|
688
|
+
if (registeredPath) {
|
|
689
|
+
entries.push(healthEntry(item.path, record, false, 'unsafe', 'worktree is missing but remains registered'));
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (record.branch !== null) {
|
|
693
|
+
const branchEvidence = await ownedBranchRef(record.repositoryRoot, record.branch, record.baseCommit);
|
|
694
|
+
if (branchEvidence === 'ambiguous') {
|
|
695
|
+
entries.push(healthEntry(item.path, record, false, 'unsafe', 'owned branch is missing or moved'));
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const agentLifecycle = record.kind === 'agent'
|
|
700
|
+
? await inspectAgentLifecycle(options.stateRoot, record)
|
|
701
|
+
: null;
|
|
702
|
+
if (agentLifecycle?.status === 'unsafe') {
|
|
703
|
+
entries.push(healthEntry(item.path, record, false, 'unsafe', agentLifecycle.reason));
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
const clean = classifyCleanHealthRecord(record, true, agentLifecycle);
|
|
707
|
+
entries.push(healthEntry(item.path, record, false, clean.status, clean.reason));
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
let inspection;
|
|
711
|
+
try {
|
|
712
|
+
inspection = await inspectOwnedCheckout(record, registered);
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
entries.push(healthEntry(item.path, record, true, 'unsafe', healthErrorReason(error, 'worktree ownership and Git state could not be validated')));
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const agentLifecycle = record.kind === 'agent'
|
|
719
|
+
? await inspectAgentLifecycle(options.stateRoot, record)
|
|
720
|
+
: null;
|
|
721
|
+
if (agentLifecycle?.status === 'unsafe') {
|
|
722
|
+
entries.push(healthEntry(item.path, record, true, 'unsafe', agentLifecycle.reason));
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (agentLifecycle?.status === 'retained') {
|
|
726
|
+
entries.push(healthEntry(item.path, record, true, 'retained', agentLifecycle.reason));
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (inspection.status) {
|
|
730
|
+
entries.push(healthEntry(item.path, record, true, 'retained', 'worktree has uncommitted changes'));
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (inspection.head !== record.baseCommit) {
|
|
734
|
+
entries.push(healthEntry(item.path, record, true, 'retained', 'worktree HEAD does not match base commit'));
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const clean = classifyCleanHealthRecord(record, false, agentLifecycle);
|
|
738
|
+
entries.push(healthEntry(item.path, record, true, clean.status, clean.reason));
|
|
739
|
+
}
|
|
740
|
+
finally {
|
|
741
|
+
await lease.release();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
const counts = {
|
|
745
|
+
active: entries.filter((entry) => entry.status === 'active').length,
|
|
746
|
+
retained: entries.filter((entry) => entry.status === 'retained').length,
|
|
747
|
+
safelyReleasable: entries.filter((entry) => entry.status === 'safely-releasable').length,
|
|
748
|
+
released: entries.filter((entry) => entry.status === 'released').length,
|
|
749
|
+
unsafe: entries.filter((entry) => entry.status === 'unsafe').length,
|
|
750
|
+
};
|
|
751
|
+
return {
|
|
752
|
+
repositoryRoot,
|
|
753
|
+
inspected: snapshot.entries.length,
|
|
754
|
+
truncated: snapshot.truncated,
|
|
755
|
+
counts,
|
|
756
|
+
entries,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
export async function reconcileManagedWorktrees(options) {
|
|
760
|
+
const repositoryRoot = await resolveProjectIdentity(options.cwd);
|
|
761
|
+
const snapshot = await inspectManagedWorktreeRegistry({
|
|
762
|
+
stateRoot: options.stateRoot,
|
|
763
|
+
repositoryRoot,
|
|
764
|
+
limit: 64,
|
|
765
|
+
});
|
|
766
|
+
const entries = [];
|
|
767
|
+
for (const item of snapshot.entries) {
|
|
768
|
+
if ('error' in item) {
|
|
769
|
+
entries.push({
|
|
770
|
+
recordPath: item.path,
|
|
771
|
+
disposition: 'invalid',
|
|
772
|
+
reason: item.error,
|
|
773
|
+
});
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const original = item.record;
|
|
777
|
+
const baseEntry = { recordPath: item.path, worktreeId: original.worktreeId };
|
|
778
|
+
const store = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, original.worktreeId);
|
|
779
|
+
const lease = await store.acquireLease();
|
|
780
|
+
if (!lease) {
|
|
781
|
+
entries.push({
|
|
782
|
+
...baseEntry,
|
|
783
|
+
disposition: 'skipped',
|
|
784
|
+
reason: 'worktree lease is live or unavailable',
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
let record;
|
|
790
|
+
try {
|
|
791
|
+
record = await store.read();
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
entries.push({
|
|
795
|
+
...baseEntry,
|
|
796
|
+
disposition: 'invalid',
|
|
797
|
+
reason: `record could not be reread: ${error instanceof Error ? error.message : String(error)}`,
|
|
798
|
+
});
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (!sameOwnership(record, original)) {
|
|
802
|
+
entries.push({
|
|
803
|
+
...baseEntry,
|
|
804
|
+
disposition: 'retained',
|
|
805
|
+
reason: 'ownership record changed during reconciliation',
|
|
806
|
+
});
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (record.state === 'released') {
|
|
810
|
+
entries.push({
|
|
811
|
+
...baseEntry,
|
|
812
|
+
disposition: 'skipped',
|
|
813
|
+
reason: 'already released',
|
|
814
|
+
});
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
if (record.state === 'retained') {
|
|
818
|
+
entries.push({
|
|
819
|
+
...baseEntry,
|
|
820
|
+
disposition: 'retained',
|
|
821
|
+
reason: record.retentionReason ?? 'record is retained',
|
|
822
|
+
});
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (record.policy === 'durable' || record.kind === 'team') {
|
|
826
|
+
entries.push(await reconciliationRetain(store, record, baseEntry, record.policy === 'durable'
|
|
827
|
+
? 'durable retention policy'
|
|
828
|
+
: 'team worktree is retained'));
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (!['workflow', 'agent'].includes(record.kind) ||
|
|
832
|
+
!record.ownerId.startsWith(`${record.kind}:`) ||
|
|
833
|
+
!['creating', 'active', 'releasing'].includes(record.state)) {
|
|
834
|
+
entries.push(await reconciliationRetain(store, record, baseEntry, 'record is not an interrupted ephemeral owner'));
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (record.kind === 'agent') {
|
|
838
|
+
const lifecycle = await reconcileAgentLifecycle(options.stateRoot, record);
|
|
839
|
+
if (!lifecycle.safe) {
|
|
840
|
+
entries.push(await reconciliationRetain(store, record, baseEntry, lifecycle.reason));
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const releasing = record.state === 'creating' ? record : nextRecord(record, 'releasing');
|
|
845
|
+
if (record.state !== 'creating') {
|
|
846
|
+
try {
|
|
847
|
+
await store.update(releasing);
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
entries.push({
|
|
851
|
+
...baseEntry,
|
|
852
|
+
disposition: 'retained',
|
|
853
|
+
reason: `could not mark releasing: ${error instanceof Error ? error.message : String(error)}`,
|
|
854
|
+
});
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const registered = await registeredWorktrees(repositoryRoot).catch(() => null);
|
|
859
|
+
if (!registered) {
|
|
860
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, 'could not inspect registered worktrees'));
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
let exists = false;
|
|
864
|
+
try {
|
|
865
|
+
const info = await lstat(record.worktreePath);
|
|
866
|
+
exists = true;
|
|
867
|
+
if (info.isSymbolicLink())
|
|
868
|
+
throw new Error('worktree path is a symlink');
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
if (error.code !== 'ENOENT') {
|
|
872
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, error.message));
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (!exists) {
|
|
877
|
+
if (registered.has(resolve(record.worktreePath))) {
|
|
878
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, 'worktree is missing but remains registered'));
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (record.branch !== null) {
|
|
882
|
+
const ref = await ownedBranchRef(repositoryRoot, record.branch, record.baseCommit);
|
|
883
|
+
if (ref === 'ambiguous') {
|
|
884
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, 'owned branch is missing or moved'));
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
if (ref === 'match')
|
|
889
|
+
await removeOwnedBranch(repositoryRoot, record.branch, record.baseCommit);
|
|
890
|
+
}
|
|
891
|
+
catch (error) {
|
|
892
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, `could not remove owned branch: ${error.message}`));
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
await store.update(nextRecord(releasing, 'released'));
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
entries.push({
|
|
901
|
+
...baseEntry,
|
|
902
|
+
disposition: 'released',
|
|
903
|
+
reason: `released; state finalization warning: ${error.message}`,
|
|
904
|
+
});
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
entries.push({
|
|
908
|
+
...baseEntry,
|
|
909
|
+
disposition: 'released',
|
|
910
|
+
reason: 'checkout already absent',
|
|
911
|
+
});
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
let inspection;
|
|
915
|
+
try {
|
|
916
|
+
inspection = await inspectOwnedCheckout(record, registered);
|
|
917
|
+
}
|
|
918
|
+
catch (error) {
|
|
919
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, error.message));
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
if (inspection.status || inspection.head !== record.baseCommit) {
|
|
923
|
+
const reason = inspection.status
|
|
924
|
+
? 'worktree has uncommitted changes'
|
|
925
|
+
: 'worktree HEAD does not match base commit';
|
|
926
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (options.hooks) {
|
|
930
|
+
try {
|
|
931
|
+
const outcome = await options.hooks.beforeRemove({
|
|
932
|
+
worktreePath: record.worktreePath,
|
|
933
|
+
worktreeKind: record.kind,
|
|
934
|
+
worktreeId: record.worktreeId,
|
|
935
|
+
ownerId: record.ownerId,
|
|
936
|
+
baseCommit: record.baseCommit,
|
|
937
|
+
reason: 'reconcile',
|
|
938
|
+
});
|
|
939
|
+
if (outcome.blockedReason)
|
|
940
|
+
throw new Error(`hook blocked: ${outcome.blockedReason}`);
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
const reason = `WorktreeRemove hook failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
944
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
const post = await inspectOwnedCheckout(record);
|
|
949
|
+
if (post.status || post.head !== record.baseCommit)
|
|
950
|
+
throw new Error('hook left worktree unsafe');
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
const reason = error.message;
|
|
954
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
if (record.branch !== null &&
|
|
960
|
+
(await ownedBranchRef(repositoryRoot, record.branch, record.baseCommit)) === 'ambiguous')
|
|
961
|
+
throw new Error('owned branch moved');
|
|
962
|
+
await git(repositoryRoot, ['worktree', 'remove', record.worktreePath]);
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
const reason = error.message;
|
|
966
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
if (record.branch !== null)
|
|
971
|
+
await removeOwnedBranch(repositoryRoot, record.branch, record.baseCommit);
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
const reason = `checkout removed but owned branch was preserved: ${error.message}`;
|
|
975
|
+
entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
await store.update(nextRecord(releasing, 'released'));
|
|
980
|
+
entries.push({
|
|
981
|
+
...baseEntry,
|
|
982
|
+
disposition: 'released',
|
|
983
|
+
reason: 'reconciled abandoned worktree',
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
entries.push({
|
|
988
|
+
...baseEntry,
|
|
989
|
+
disposition: 'released',
|
|
990
|
+
reason: `released; state finalization warning: ${error.message}`,
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
finally {
|
|
995
|
+
await lease.release();
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
repositoryRoot,
|
|
1000
|
+
inspected: snapshot.entries.length,
|
|
1001
|
+
truncated: snapshot.truncated,
|
|
1002
|
+
entries,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
278
1005
|
export async function createOwnedManagedWorktree(options) {
|
|
1006
|
+
if (options.parentDirectoryName !== undefined &&
|
|
1007
|
+
!COMPONENT_PATTERN.test(options.parentDirectoryName)) {
|
|
1008
|
+
throw worktreeError(options, 'parent name is invalid');
|
|
1009
|
+
}
|
|
279
1010
|
if (!COMPONENT_PATTERN.test(options.directoryName)) {
|
|
280
1011
|
throw worktreeError(options, 'name is invalid');
|
|
281
1012
|
}
|
|
@@ -297,8 +1028,36 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
297
1028
|
catch {
|
|
298
1029
|
throw worktreeError(options, 'isolation requires a Git repository');
|
|
299
1030
|
}
|
|
1031
|
+
if (options.branch !== undefined) {
|
|
1032
|
+
try {
|
|
1033
|
+
const checked = await git(repositoryRoot, [
|
|
1034
|
+
'check-ref-format',
|
|
1035
|
+
'--branch',
|
|
1036
|
+
options.branch,
|
|
1037
|
+
]);
|
|
1038
|
+
if (checked !== options.branch)
|
|
1039
|
+
throw new Error('branch was normalized');
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
throw worktreeError(options, 'branch name is invalid');
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const reconciliationKey = repositoryRoot;
|
|
1046
|
+
let reconciliation = reconciliationCache.get(reconciliationKey);
|
|
1047
|
+
if (!reconciliation) {
|
|
1048
|
+
reconciliation = reconcileManagedWorktrees({
|
|
1049
|
+
cwd: repositoryRoot,
|
|
1050
|
+
stateRoot: options.stateRoot,
|
|
1051
|
+
...(options.hooks ? { hooks: options.hooks } : {}),
|
|
1052
|
+
});
|
|
1053
|
+
reconciliationCache.set(reconciliationKey, reconciliation);
|
|
1054
|
+
}
|
|
1055
|
+
await reconciliation;
|
|
300
1056
|
const kindRoot = join(repositoryRoot, '.praxis', 'worktrees', options.kind);
|
|
301
|
-
const
|
|
1057
|
+
const worktreeParent = options.parentDirectoryName
|
|
1058
|
+
? join(kindRoot, options.parentDirectoryName)
|
|
1059
|
+
: kindRoot;
|
|
1060
|
+
const worktreePath = join(worktreeParent, options.directoryName);
|
|
302
1061
|
const rootRelative = relative(kindRoot, worktreePath);
|
|
303
1062
|
if (!rootRelative ||
|
|
304
1063
|
rootRelative === '..' ||
|
|
@@ -309,6 +1068,8 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
309
1068
|
await assertRealDirectory(join(repositoryRoot, '.praxis'), 'managed worktree parent');
|
|
310
1069
|
await assertRealDirectory(join(repositoryRoot, '.praxis', 'worktrees'), 'managed worktree parent');
|
|
311
1070
|
await assertRealDirectory(kindRoot, 'managed worktree kind root');
|
|
1071
|
+
if (options.parentDirectoryName)
|
|
1072
|
+
await assertRealDirectory(worktreeParent, 'managed worktree parent');
|
|
312
1073
|
await ensureManagedRootIgnored(repositoryRoot);
|
|
313
1074
|
const worktreeId = managedWorktreeId(repositoryRoot, options.kind, options.ownerId);
|
|
314
1075
|
const store = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, worktreeId);
|
|
@@ -324,7 +1085,7 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
324
1085
|
ownerId: options.ownerId,
|
|
325
1086
|
repositoryRoot,
|
|
326
1087
|
worktreePath,
|
|
327
|
-
branch: null,
|
|
1088
|
+
branch: options.branch ?? null,
|
|
328
1089
|
baseCommit,
|
|
329
1090
|
state: 'creating',
|
|
330
1091
|
createdAt: now,
|
|
@@ -333,6 +1094,7 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
333
1094
|
let recordCreated = false;
|
|
334
1095
|
let gitCreated = false;
|
|
335
1096
|
let markerCreated = false;
|
|
1097
|
+
let branchBeforeAttempt;
|
|
336
1098
|
let created = false;
|
|
337
1099
|
try {
|
|
338
1100
|
await store.create(record);
|
|
@@ -348,15 +1110,19 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
348
1110
|
if (error.code !== 'ENOENT')
|
|
349
1111
|
throw error;
|
|
350
1112
|
}
|
|
351
|
-
await mkdir(
|
|
352
|
-
await assertRealDirectory(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
1113
|
+
await mkdir(worktreeParent, { recursive: true });
|
|
1114
|
+
await assertRealDirectory(worktreeParent, 'managed worktree parent');
|
|
1115
|
+
if (options.branch !== undefined) {
|
|
1116
|
+
branchBeforeAttempt = await gitOptional(repositoryRoot, [
|
|
1117
|
+
'rev-parse',
|
|
1118
|
+
'--verify',
|
|
1119
|
+
'--quiet',
|
|
1120
|
+
`refs/heads/${options.branch}`,
|
|
1121
|
+
]);
|
|
1122
|
+
}
|
|
1123
|
+
await git(repositoryRoot, options.branch === undefined
|
|
1124
|
+
? ['worktree', 'add', '--detach', worktreePath, baseCommit]
|
|
1125
|
+
: ['worktree', 'add', '-b', options.branch, worktreePath, baseCommit]);
|
|
360
1126
|
gitCreated = true;
|
|
361
1127
|
await writeMarker(worktreePath, record);
|
|
362
1128
|
markerCreated = true;
|
|
@@ -397,6 +1163,9 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
397
1163
|
throw new Error('created worktree HEAD no longer matches its base');
|
|
398
1164
|
}
|
|
399
1165
|
await git(repositoryRoot, ['worktree', 'remove', worktreePath]);
|
|
1166
|
+
if (record.branch !== null) {
|
|
1167
|
+
await removeOwnedBranch(repositoryRoot, record.branch, record.baseCommit);
|
|
1168
|
+
}
|
|
400
1169
|
}
|
|
401
1170
|
catch (removeError) {
|
|
402
1171
|
rollbackError = removeError;
|
|
@@ -414,6 +1183,20 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
414
1183
|
throw pathError;
|
|
415
1184
|
});
|
|
416
1185
|
uncertainArtifact = pathExists || registered.has(resolve(worktreePath));
|
|
1186
|
+
if (record.branch !== null) {
|
|
1187
|
+
const branchAfterAttempt = await gitOptional(repositoryRoot, [
|
|
1188
|
+
'rev-parse',
|
|
1189
|
+
'--verify',
|
|
1190
|
+
'--quiet',
|
|
1191
|
+
`refs/heads/${record.branch}`,
|
|
1192
|
+
]);
|
|
1193
|
+
if (branchBeforeAttempt === undefined ||
|
|
1194
|
+
(branchBeforeAttempt === null
|
|
1195
|
+
? branchAfterAttempt !== null
|
|
1196
|
+
: branchAfterAttempt !== branchBeforeAttempt)) {
|
|
1197
|
+
uncertainArtifact = true;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
417
1200
|
}
|
|
418
1201
|
catch {
|
|
419
1202
|
uncertainArtifact = true;
|
|
@@ -442,37 +1225,14 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
442
1225
|
if (!created)
|
|
443
1226
|
await lease.release();
|
|
444
1227
|
}
|
|
445
|
-
|
|
446
|
-
let removedResult;
|
|
447
|
-
let cleanupInFlight;
|
|
448
|
-
return {
|
|
449
|
-
cwd: worktreePath,
|
|
450
|
-
cleanup: async () => {
|
|
451
|
-
if (removedResult)
|
|
452
|
-
return removedResult;
|
|
453
|
-
if (cleanupInFlight)
|
|
454
|
-
return cleanupInFlight;
|
|
455
|
-
const heldLease = executionLease;
|
|
456
|
-
executionLease = undefined;
|
|
457
|
-
cleanupInFlight = cleanupOwnedManagedWorktree(store, options, record, heldLease);
|
|
458
|
-
try {
|
|
459
|
-
const result = await cleanupInFlight;
|
|
460
|
-
if (!result.retained)
|
|
461
|
-
removedResult = result;
|
|
462
|
-
return result;
|
|
463
|
-
}
|
|
464
|
-
finally {
|
|
465
|
-
cleanupInFlight = undefined;
|
|
466
|
-
}
|
|
467
|
-
},
|
|
468
|
-
};
|
|
1228
|
+
return createManagedWorktreeHandle(store, options, record, lease);
|
|
469
1229
|
}
|
|
470
|
-
async function
|
|
1230
|
+
async function settleOwnedManagedWorktree(store, options, original, mode, heldLease) {
|
|
471
1231
|
const lease = heldLease ?? (await store.acquireLease());
|
|
472
1232
|
if (!lease)
|
|
473
1233
|
return {
|
|
474
1234
|
retained: true,
|
|
475
|
-
reason: `${options.label} worktree cleanup is already in progress`,
|
|
1235
|
+
reason: `${options.label} worktree ${mode === 'release' ? 'release' : 'cleanup'} is already in progress`,
|
|
476
1236
|
};
|
|
477
1237
|
try {
|
|
478
1238
|
let record;
|
|
@@ -501,7 +1261,8 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
501
1261
|
reason: `${options.label} worktree is not releasable`,
|
|
502
1262
|
};
|
|
503
1263
|
}
|
|
504
|
-
|
|
1264
|
+
const explicitDurableRelease = mode === 'release' && record.policy === 'durable';
|
|
1265
|
+
if (mode === 'cleanup' && record.policy === 'durable') {
|
|
505
1266
|
return retain(store, record, `${options.label} worktree uses durable retention policy`);
|
|
506
1267
|
}
|
|
507
1268
|
const releasing = nextRecord(record, 'releasing');
|
|
@@ -528,6 +1289,18 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
528
1289
|
if (registered.has(resolve(record.worktreePath))) {
|
|
529
1290
|
return retain(store, releasing, `${options.label} worktree is missing but remains registered at ${record.worktreePath}`);
|
|
530
1291
|
}
|
|
1292
|
+
if (record.branch !== null) {
|
|
1293
|
+
const ref = await ownedBranchRef(record.repositoryRoot, record.branch, record.baseCommit);
|
|
1294
|
+
if (ref === 'ambiguous')
|
|
1295
|
+
return retain(store, releasing, `${options.label} worktree owned branch moved or is ambiguous`);
|
|
1296
|
+
try {
|
|
1297
|
+
if (ref === 'match')
|
|
1298
|
+
await removeOwnedBranch(record.repositoryRoot, record.branch, record.baseCommit);
|
|
1299
|
+
}
|
|
1300
|
+
catch (error) {
|
|
1301
|
+
return retain(store, releasing, `${options.label} worktree owned branch could not be removed: ${error.message}`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
531
1304
|
try {
|
|
532
1305
|
await store.update(nextRecord(releasing, 'released'));
|
|
533
1306
|
return { retained: false };
|
|
@@ -547,11 +1320,11 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
547
1320
|
const reason = `Could not verify ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
|
|
548
1321
|
return retain(store, releasing, reason);
|
|
549
1322
|
}
|
|
550
|
-
if (inspection.status.length > 0) {
|
|
1323
|
+
if (!explicitDurableRelease && inspection.status.length > 0) {
|
|
551
1324
|
const reason = `${options.label} worktree has uncommitted changes and was retained at ${record.worktreePath}`;
|
|
552
1325
|
return retain(store, releasing, reason);
|
|
553
1326
|
}
|
|
554
|
-
if (inspection.head !== record.baseCommit) {
|
|
1327
|
+
if (!explicitDurableRelease && inspection.head !== record.baseCommit) {
|
|
555
1328
|
const reason = `${options.label} worktree has commits and was retained at ${record.worktreePath}`;
|
|
556
1329
|
return retain(store, releasing, reason);
|
|
557
1330
|
}
|
|
@@ -576,10 +1349,12 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
576
1349
|
try {
|
|
577
1350
|
const postHookRegistered = await registeredWorktrees(record.repositoryRoot);
|
|
578
1351
|
const postHookInspection = await inspectOwnedCheckout(record, postHookRegistered);
|
|
579
|
-
|
|
1352
|
+
inspection = postHookInspection;
|
|
1353
|
+
if (!explicitDurableRelease && postHookInspection.status.length > 0) {
|
|
580
1354
|
return retain(store, releasing, `WorktreeRemove hook left uncommitted changes in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
|
|
581
1355
|
}
|
|
582
|
-
if (
|
|
1356
|
+
if (!explicitDurableRelease &&
|
|
1357
|
+
postHookInspection.head !== record.baseCommit) {
|
|
583
1358
|
return retain(store, releasing, `WorktreeRemove hook created commits in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
|
|
584
1359
|
}
|
|
585
1360
|
}
|
|
@@ -588,16 +1363,36 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
588
1363
|
}
|
|
589
1364
|
}
|
|
590
1365
|
try {
|
|
591
|
-
|
|
592
|
-
'
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1366
|
+
if (record.branch !== null &&
|
|
1367
|
+
(await ownedBranchRef(record.repositoryRoot, record.branch, explicitDurableRelease ? inspection.head : record.baseCommit)) === 'ambiguous') {
|
|
1368
|
+
return retain(store, releasing, `${options.label} worktree owned branch moved or is ambiguous`);
|
|
1369
|
+
}
|
|
1370
|
+
await git(record.repositoryRoot, explicitDurableRelease
|
|
1371
|
+
? ['worktree', 'remove', '--force', record.worktreePath]
|
|
1372
|
+
: ['worktree', 'remove', record.worktreePath]);
|
|
596
1373
|
}
|
|
597
1374
|
catch (error) {
|
|
598
1375
|
const reason = `Could not remove ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
|
|
599
1376
|
return retain(store, releasing, reason);
|
|
600
1377
|
}
|
|
1378
|
+
if (record.branch !== null) {
|
|
1379
|
+
try {
|
|
1380
|
+
await removeOwnedBranch(record.repositoryRoot, record.branch, explicitDurableRelease ? inspection.head : record.baseCommit);
|
|
1381
|
+
}
|
|
1382
|
+
catch (error) {
|
|
1383
|
+
let stateWarning = '';
|
|
1384
|
+
try {
|
|
1385
|
+
await store.update(nextRecord(releasing, 'released'));
|
|
1386
|
+
}
|
|
1387
|
+
catch (stateError) {
|
|
1388
|
+
stateWarning = `; could not finalize release state: ${stateError instanceof Error ? stateError.message : String(stateError)}`;
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
retained: false,
|
|
1392
|
+
reason: `checkout removed but owned branch was preserved: ${error.message}${stateWarning}`,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
601
1396
|
try {
|
|
602
1397
|
await store.update(nextRecord(releasing, 'released'));
|
|
603
1398
|
}
|
|
@@ -613,6 +1408,181 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
613
1408
|
await lease.release();
|
|
614
1409
|
}
|
|
615
1410
|
}
|
|
1411
|
+
async function cleanupOwnedManagedWorktree(store, options, original, heldLease) {
|
|
1412
|
+
return settleOwnedManagedWorktree(store, options, original, 'cleanup', heldLease);
|
|
1413
|
+
}
|
|
1414
|
+
async function releaseOwnedManagedWorktree(store, options, original, heldLease) {
|
|
1415
|
+
return settleOwnedManagedWorktree(store, options, original, 'release', heldLease);
|
|
1416
|
+
}
|
|
1417
|
+
async function retainOwnedManagedWorktree(store, options, original, reason, heldLease) {
|
|
1418
|
+
assertRetentionReason(reason);
|
|
1419
|
+
const lease = heldLease ?? (await store.acquireLease());
|
|
1420
|
+
if (!lease)
|
|
1421
|
+
return {
|
|
1422
|
+
retained: true,
|
|
1423
|
+
reason: `${options.label} worktree retention is already in progress`,
|
|
1424
|
+
};
|
|
1425
|
+
try {
|
|
1426
|
+
let record;
|
|
1427
|
+
try {
|
|
1428
|
+
record = await store.read();
|
|
1429
|
+
}
|
|
1430
|
+
catch (error) {
|
|
1431
|
+
return {
|
|
1432
|
+
retained: true,
|
|
1433
|
+
reason: `Could not inspect ${options.label.toLowerCase()} worktree record: ${error instanceof Error ? error.message : String(error)}`,
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
if (!sameOwnership(record, original))
|
|
1437
|
+
return {
|
|
1438
|
+
retained: true,
|
|
1439
|
+
reason: `${options.label} worktree ownership record does not match`,
|
|
1440
|
+
};
|
|
1441
|
+
if (record.state === 'released')
|
|
1442
|
+
return { retained: false };
|
|
1443
|
+
try {
|
|
1444
|
+
await store.update(nextRecord(record, 'retained', reason));
|
|
1445
|
+
}
|
|
1446
|
+
catch (error) {
|
|
1447
|
+
return {
|
|
1448
|
+
retained: true,
|
|
1449
|
+
reason: `${reason}; could not persist retention state: ${error instanceof Error ? error.message : String(error)}`,
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
return { retained: true, reason };
|
|
1453
|
+
}
|
|
1454
|
+
finally {
|
|
1455
|
+
await lease.release();
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
function createManagedWorktreeSettlement(initialLease) {
|
|
1459
|
+
let executionLease = initialLease;
|
|
1460
|
+
let removedResult;
|
|
1461
|
+
let operationInFlight;
|
|
1462
|
+
return (operation) => {
|
|
1463
|
+
if (removedResult)
|
|
1464
|
+
return Promise.resolve(removedResult);
|
|
1465
|
+
if (operationInFlight)
|
|
1466
|
+
return operationInFlight;
|
|
1467
|
+
const heldLease = executionLease;
|
|
1468
|
+
executionLease = undefined;
|
|
1469
|
+
operationInFlight = operation(heldLease);
|
|
1470
|
+
return operationInFlight
|
|
1471
|
+
.then((result) => {
|
|
1472
|
+
if (!result.retained)
|
|
1473
|
+
removedResult = result;
|
|
1474
|
+
return result;
|
|
1475
|
+
})
|
|
1476
|
+
.finally(() => {
|
|
1477
|
+
operationInFlight = undefined;
|
|
1478
|
+
});
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
function createManagedWorktreeHandle(store, options, record, lease) {
|
|
1482
|
+
const settle = createManagedWorktreeSettlement(lease);
|
|
1483
|
+
return {
|
|
1484
|
+
cwd: record.worktreePath,
|
|
1485
|
+
cleanup: () => settle((heldLease) => cleanupOwnedManagedWorktree(store, options, record, heldLease)),
|
|
1486
|
+
retain: async (reason) => {
|
|
1487
|
+
assertRetentionReason(reason);
|
|
1488
|
+
return settle((heldLease) => retainOwnedManagedWorktree(store, options, record, reason, heldLease));
|
|
1489
|
+
},
|
|
1490
|
+
release: () => settle((heldLease) => releaseOwnedManagedWorktree(store, options, record, heldLease)),
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
/** Restore a checkout only when its complete managed ownership proof matches. */
|
|
1494
|
+
export async function restoreOwnedManagedWorktree(options) {
|
|
1495
|
+
if (options.parentDirectoryName !== undefined &&
|
|
1496
|
+
!COMPONENT_PATTERN.test(options.parentDirectoryName)) {
|
|
1497
|
+
throw worktreeError(options, 'parent name is invalid');
|
|
1498
|
+
}
|
|
1499
|
+
if (!COMPONENT_PATTERN.test(options.directoryName)) {
|
|
1500
|
+
throw worktreeError(options, 'name is invalid');
|
|
1501
|
+
}
|
|
1502
|
+
if (!options.path ||
|
|
1503
|
+
!isAbsolute(options.path) ||
|
|
1504
|
+
options.path.includes('\0')) {
|
|
1505
|
+
throw worktreeError(options, 'path is invalid');
|
|
1506
|
+
}
|
|
1507
|
+
if (!options.ownerPrefix || !validOwnerId(options.ownerPrefix)) {
|
|
1508
|
+
throw worktreeError(options, 'owner prefix is invalid');
|
|
1509
|
+
}
|
|
1510
|
+
if (options.ownerId !== undefined && !validOwnerId(options.ownerId)) {
|
|
1511
|
+
throw worktreeError(options, 'owner ID is invalid');
|
|
1512
|
+
}
|
|
1513
|
+
let repositoryRoot;
|
|
1514
|
+
try {
|
|
1515
|
+
repositoryRoot = await resolveProjectIdentity(options.cwd);
|
|
1516
|
+
}
|
|
1517
|
+
catch {
|
|
1518
|
+
throw worktreeError(options, 'restore requires a Git repository');
|
|
1519
|
+
}
|
|
1520
|
+
if (options.branch !== undefined) {
|
|
1521
|
+
try {
|
|
1522
|
+
const checked = await git(repositoryRoot, [
|
|
1523
|
+
'check-ref-format',
|
|
1524
|
+
'--branch',
|
|
1525
|
+
options.branch,
|
|
1526
|
+
]);
|
|
1527
|
+
if (checked !== options.branch)
|
|
1528
|
+
throw new Error('branch was normalized');
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
throw worktreeError(options, 'branch name is invalid');
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const kindRoot = join(repositoryRoot, '.praxis', 'worktrees', options.kind);
|
|
1535
|
+
const expectedParent = options.parentDirectoryName
|
|
1536
|
+
? join(kindRoot, options.parentDirectoryName)
|
|
1537
|
+
: kindRoot;
|
|
1538
|
+
const expectedPath = join(expectedParent, options.directoryName);
|
|
1539
|
+
const path = resolve(options.path);
|
|
1540
|
+
if (path !== expectedPath) {
|
|
1541
|
+
throw worktreeError(options, 'path does not match its managed checkout');
|
|
1542
|
+
}
|
|
1543
|
+
try {
|
|
1544
|
+
const entry = await lstat(path);
|
|
1545
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
1546
|
+
throw new Error('worktree path is not a real directory');
|
|
1547
|
+
}
|
|
1548
|
+
if ((await realpath(path)) !== path) {
|
|
1549
|
+
throw new Error('worktree path is not canonical');
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
catch (error) {
|
|
1553
|
+
throw worktreeError(options, `could not inspect checkout: ${error.message}`);
|
|
1554
|
+
}
|
|
1555
|
+
const marker = await readMarkerIdentity(path);
|
|
1556
|
+
if (marker.repositoryRoot !== repositoryRoot) {
|
|
1557
|
+
throw worktreeError(options, 'marker repository identity does not match');
|
|
1558
|
+
}
|
|
1559
|
+
const store = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, marker.worktreeId);
|
|
1560
|
+
const lease = await store.acquireLease();
|
|
1561
|
+
if (!lease)
|
|
1562
|
+
throw worktreeError(options, 'restore is already owned');
|
|
1563
|
+
let accepted = false;
|
|
1564
|
+
try {
|
|
1565
|
+
const record = await store.read();
|
|
1566
|
+
if (record.repositoryRoot !== repositoryRoot ||
|
|
1567
|
+
record.worktreeId !== marker.worktreeId ||
|
|
1568
|
+
record.worktreePath !== expectedPath ||
|
|
1569
|
+
record.kind !== options.kind ||
|
|
1570
|
+
record.policy !== options.policy ||
|
|
1571
|
+
!['active', 'retained', 'releasing'].includes(record.state) ||
|
|
1572
|
+
!record.ownerId.startsWith(options.ownerPrefix) ||
|
|
1573
|
+
(options.ownerId !== undefined && record.ownerId !== options.ownerId) ||
|
|
1574
|
+
(options.branch !== undefined && record.branch !== options.branch)) {
|
|
1575
|
+
throw worktreeError(options, 'ownership evidence does not match');
|
|
1576
|
+
}
|
|
1577
|
+
await inspectOwnedCheckout(record);
|
|
1578
|
+
accepted = true;
|
|
1579
|
+
return createManagedWorktreeHandle(store, options, record, lease);
|
|
1580
|
+
}
|
|
1581
|
+
finally {
|
|
1582
|
+
if (!accepted)
|
|
1583
|
+
await lease.release();
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
616
1586
|
export async function createManagedWorktree(options) {
|
|
617
1587
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/u.test(options.directoryName)) {
|
|
618
1588
|
throw new Error(`Invalid ${options.label.toLowerCase()} worktree name`);
|
|
@@ -664,63 +1634,56 @@ export async function createManagedWorktree(options) {
|
|
|
664
1634
|
throw new Error(`Could not create ${options.label.toLowerCase()} worktree: ${error.message}`);
|
|
665
1635
|
}
|
|
666
1636
|
}
|
|
667
|
-
|
|
668
|
-
|
|
1637
|
+
const settle = createManagedWorktreeSettlement();
|
|
1638
|
+
const cleanup = () => settle(async () => {
|
|
1639
|
+
let status;
|
|
1640
|
+
let head;
|
|
1641
|
+
try {
|
|
1642
|
+
;
|
|
1643
|
+
[status, head] = await Promise.all([
|
|
1644
|
+
git(path, ['status', '--porcelain']),
|
|
1645
|
+
git(path, ['rev-parse', 'HEAD']),
|
|
1646
|
+
]);
|
|
1647
|
+
}
|
|
1648
|
+
catch (error) {
|
|
1649
|
+
return {
|
|
1650
|
+
retained: true,
|
|
1651
|
+
reason: `Could not inspect ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
if (status.length > 0) {
|
|
1655
|
+
return {
|
|
1656
|
+
retained: true,
|
|
1657
|
+
reason: `${options.label} worktree has uncommitted changes and was retained at ${path}`,
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
if (head !== initialHead) {
|
|
1661
|
+
return {
|
|
1662
|
+
retained: true,
|
|
1663
|
+
reason: `${options.label} worktree has commits and was retained at ${path}`,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
try {
|
|
1667
|
+
await git(root, ['worktree', 'remove', path]);
|
|
1668
|
+
return { retained: false };
|
|
1669
|
+
}
|
|
1670
|
+
catch (error) {
|
|
1671
|
+
return {
|
|
1672
|
+
retained: true,
|
|
1673
|
+
reason: `Could not remove ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
669
1677
|
return {
|
|
670
1678
|
cwd: path,
|
|
671
|
-
cleanup
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
try {
|
|
680
|
-
;
|
|
681
|
-
[status, head] = await Promise.all([
|
|
682
|
-
git(path, ['status', '--porcelain']),
|
|
683
|
-
git(path, ['rev-parse', 'HEAD']),
|
|
684
|
-
]);
|
|
685
|
-
}
|
|
686
|
-
catch (error) {
|
|
687
|
-
return {
|
|
688
|
-
retained: true,
|
|
689
|
-
reason: `Could not inspect ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
if (status.length > 0) {
|
|
693
|
-
return {
|
|
694
|
-
retained: true,
|
|
695
|
-
reason: `${options.label} worktree has uncommitted changes and was retained at ${path}`,
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
if (head !== initialHead) {
|
|
699
|
-
return {
|
|
700
|
-
retained: true,
|
|
701
|
-
reason: `${options.label} worktree has commits and was retained at ${path}`,
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
try {
|
|
705
|
-
await git(root, ['worktree', 'remove', path]);
|
|
706
|
-
return { retained: false };
|
|
707
|
-
}
|
|
708
|
-
catch (error) {
|
|
709
|
-
return {
|
|
710
|
-
retained: true,
|
|
711
|
-
reason: `Could not remove ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
|
|
712
|
-
};
|
|
713
|
-
}
|
|
714
|
-
})();
|
|
715
|
-
try {
|
|
716
|
-
const result = await cleanupInFlight;
|
|
717
|
-
if (!result.retained)
|
|
718
|
-
removedResult = result;
|
|
719
|
-
return result;
|
|
720
|
-
}
|
|
721
|
-
finally {
|
|
722
|
-
cleanupInFlight = undefined;
|
|
723
|
-
}
|
|
1679
|
+
cleanup,
|
|
1680
|
+
release: cleanup,
|
|
1681
|
+
retain: async (reason) => {
|
|
1682
|
+
assertRetentionReason(reason);
|
|
1683
|
+
return settle(async () => ({
|
|
1684
|
+
retained: true,
|
|
1685
|
+
reason,
|
|
1686
|
+
}));
|
|
724
1687
|
},
|
|
725
1688
|
};
|
|
726
1689
|
}
|
|
@@ -774,6 +1737,16 @@ export async function restoreManagedWorktree(options) {
|
|
|
774
1737
|
reason: `${options.label} worktree was restored and retained at ${path}`,
|
|
775
1738
|
};
|
|
776
1739
|
},
|
|
1740
|
+
async release() {
|
|
1741
|
+
return {
|
|
1742
|
+
retained: true,
|
|
1743
|
+
reason: `${options.label} worktree was restored and retained at ${path}`,
|
|
1744
|
+
};
|
|
1745
|
+
},
|
|
1746
|
+
async retain(reason) {
|
|
1747
|
+
assertRetentionReason(reason);
|
|
1748
|
+
return { retained: true, reason };
|
|
1749
|
+
},
|
|
777
1750
|
};
|
|
778
1751
|
}
|
|
779
1752
|
//# sourceMappingURL=managed-worktree.js.map
|