praxis-agent 0.60.1 → 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 -4
- 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 +88 -0
- package/dist/application/managed-worktree.js +1129 -106
- package/dist/application/subagent-service.d.ts +3 -1
- package/dist/application/subagent-service.js +117 -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 +3 -0
- package/dist/application/workflow-worktree.js +4 -0
- package/dist/cli/tui/doctor-dashboard.js +1 -0
- package/dist/cli-runtime.js +5 -0
- package/dist/hooks/claude-hooks.js +16 -2
- 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');
|
|
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');
|
|
206
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,18 +1110,40 @@ 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;
|
|
1129
|
+
if (options.hooks) {
|
|
1130
|
+
let outcome;
|
|
1131
|
+
try {
|
|
1132
|
+
outcome = await options.hooks.afterCreate({
|
|
1133
|
+
worktreePath,
|
|
1134
|
+
worktreeKind: record.kind,
|
|
1135
|
+
worktreeId: record.worktreeId,
|
|
1136
|
+
ownerId: record.ownerId,
|
|
1137
|
+
baseCommit: record.baseCommit,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
throw new Error(`WorktreeCreate hook failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1142
|
+
}
|
|
1143
|
+
if (outcome.blockedReason) {
|
|
1144
|
+
throw new Error(`WorktreeCreate hook blocked: ${outcome.blockedReason}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
363
1147
|
await store.update(nextRecord(record, 'active'));
|
|
364
1148
|
created = true;
|
|
365
1149
|
}
|
|
@@ -379,6 +1163,9 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
379
1163
|
throw new Error('created worktree HEAD no longer matches its base');
|
|
380
1164
|
}
|
|
381
1165
|
await git(repositoryRoot, ['worktree', 'remove', worktreePath]);
|
|
1166
|
+
if (record.branch !== null) {
|
|
1167
|
+
await removeOwnedBranch(repositoryRoot, record.branch, record.baseCommit);
|
|
1168
|
+
}
|
|
382
1169
|
}
|
|
383
1170
|
catch (removeError) {
|
|
384
1171
|
rollbackError = removeError;
|
|
@@ -396,6 +1183,20 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
396
1183
|
throw pathError;
|
|
397
1184
|
});
|
|
398
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
|
+
}
|
|
399
1200
|
}
|
|
400
1201
|
catch {
|
|
401
1202
|
uncertainArtifact = true;
|
|
@@ -424,37 +1225,14 @@ export async function createOwnedManagedWorktree(options) {
|
|
|
424
1225
|
if (!created)
|
|
425
1226
|
await lease.release();
|
|
426
1227
|
}
|
|
427
|
-
|
|
428
|
-
let removedResult;
|
|
429
|
-
let cleanupInFlight;
|
|
430
|
-
return {
|
|
431
|
-
cwd: worktreePath,
|
|
432
|
-
cleanup: async () => {
|
|
433
|
-
if (removedResult)
|
|
434
|
-
return removedResult;
|
|
435
|
-
if (cleanupInFlight)
|
|
436
|
-
return cleanupInFlight;
|
|
437
|
-
const heldLease = executionLease;
|
|
438
|
-
executionLease = undefined;
|
|
439
|
-
cleanupInFlight = cleanupOwnedManagedWorktree(store, options, record, heldLease);
|
|
440
|
-
try {
|
|
441
|
-
const result = await cleanupInFlight;
|
|
442
|
-
if (!result.retained)
|
|
443
|
-
removedResult = result;
|
|
444
|
-
return result;
|
|
445
|
-
}
|
|
446
|
-
finally {
|
|
447
|
-
cleanupInFlight = undefined;
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
|
-
};
|
|
1228
|
+
return createManagedWorktreeHandle(store, options, record, lease);
|
|
451
1229
|
}
|
|
452
|
-
async function
|
|
1230
|
+
async function settleOwnedManagedWorktree(store, options, original, mode, heldLease) {
|
|
453
1231
|
const lease = heldLease ?? (await store.acquireLease());
|
|
454
1232
|
if (!lease)
|
|
455
1233
|
return {
|
|
456
1234
|
retained: true,
|
|
457
|
-
reason: `${options.label} worktree cleanup is already in progress`,
|
|
1235
|
+
reason: `${options.label} worktree ${mode === 'release' ? 'release' : 'cleanup'} is already in progress`,
|
|
458
1236
|
};
|
|
459
1237
|
try {
|
|
460
1238
|
let record;
|
|
@@ -483,7 +1261,8 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
483
1261
|
reason: `${options.label} worktree is not releasable`,
|
|
484
1262
|
};
|
|
485
1263
|
}
|
|
486
|
-
|
|
1264
|
+
const explicitDurableRelease = mode === 'release' && record.policy === 'durable';
|
|
1265
|
+
if (mode === 'cleanup' && record.policy === 'durable') {
|
|
487
1266
|
return retain(store, record, `${options.label} worktree uses durable retention policy`);
|
|
488
1267
|
}
|
|
489
1268
|
const releasing = nextRecord(record, 'releasing');
|
|
@@ -510,6 +1289,18 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
510
1289
|
if (registered.has(resolve(record.worktreePath))) {
|
|
511
1290
|
return retain(store, releasing, `${options.label} worktree is missing but remains registered at ${record.worktreePath}`);
|
|
512
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
|
+
}
|
|
513
1304
|
try {
|
|
514
1305
|
await store.update(nextRecord(releasing, 'released'));
|
|
515
1306
|
return { retained: false };
|
|
@@ -529,25 +1320,79 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
529
1320
|
const reason = `Could not verify ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
|
|
530
1321
|
return retain(store, releasing, reason);
|
|
531
1322
|
}
|
|
532
|
-
if (inspection.status.length > 0) {
|
|
1323
|
+
if (!explicitDurableRelease && inspection.status.length > 0) {
|
|
533
1324
|
const reason = `${options.label} worktree has uncommitted changes and was retained at ${record.worktreePath}`;
|
|
534
1325
|
return retain(store, releasing, reason);
|
|
535
1326
|
}
|
|
536
|
-
if (inspection.head !== record.baseCommit) {
|
|
1327
|
+
if (!explicitDurableRelease && inspection.head !== record.baseCommit) {
|
|
537
1328
|
const reason = `${options.label} worktree has commits and was retained at ${record.worktreePath}`;
|
|
538
1329
|
return retain(store, releasing, reason);
|
|
539
1330
|
}
|
|
1331
|
+
if (options.hooks) {
|
|
1332
|
+
let outcome;
|
|
1333
|
+
try {
|
|
1334
|
+
outcome = await options.hooks.beforeRemove({
|
|
1335
|
+
worktreePath: record.worktreePath,
|
|
1336
|
+
worktreeKind: record.kind,
|
|
1337
|
+
worktreeId: record.worktreeId,
|
|
1338
|
+
ownerId: record.ownerId,
|
|
1339
|
+
baseCommit: record.baseCommit,
|
|
1340
|
+
reason: 'normal',
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
catch (error) {
|
|
1344
|
+
return retain(store, releasing, `WorktreeRemove hook failed for ${record.worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1345
|
+
}
|
|
1346
|
+
if (outcome.blockedReason) {
|
|
1347
|
+
return retain(store, releasing, `WorktreeRemove hook blocked for ${record.worktreePath}: ${outcome.blockedReason}`);
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
const postHookRegistered = await registeredWorktrees(record.repositoryRoot);
|
|
1351
|
+
const postHookInspection = await inspectOwnedCheckout(record, postHookRegistered);
|
|
1352
|
+
inspection = postHookInspection;
|
|
1353
|
+
if (!explicitDurableRelease && postHookInspection.status.length > 0) {
|
|
1354
|
+
return retain(store, releasing, `WorktreeRemove hook left uncommitted changes in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
|
|
1355
|
+
}
|
|
1356
|
+
if (!explicitDurableRelease &&
|
|
1357
|
+
postHookInspection.head !== record.baseCommit) {
|
|
1358
|
+
return retain(store, releasing, `WorktreeRemove hook created commits in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
catch (error) {
|
|
1362
|
+
return retain(store, releasing, `WorktreeRemove hook left worktree unsafe at ${record.worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
540
1365
|
try {
|
|
541
|
-
|
|
542
|
-
'
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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]);
|
|
546
1373
|
}
|
|
547
1374
|
catch (error) {
|
|
548
1375
|
const reason = `Could not remove ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
|
|
549
1376
|
return retain(store, releasing, reason);
|
|
550
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
|
+
}
|
|
551
1396
|
try {
|
|
552
1397
|
await store.update(nextRecord(releasing, 'released'));
|
|
553
1398
|
}
|
|
@@ -563,6 +1408,181 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
|
|
|
563
1408
|
await lease.release();
|
|
564
1409
|
}
|
|
565
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
|
+
}
|
|
566
1586
|
export async function createManagedWorktree(options) {
|
|
567
1587
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/u.test(options.directoryName)) {
|
|
568
1588
|
throw new Error(`Invalid ${options.label.toLowerCase()} worktree name`);
|
|
@@ -614,63 +1634,56 @@ export async function createManagedWorktree(options) {
|
|
|
614
1634
|
throw new Error(`Could not create ${options.label.toLowerCase()} worktree: ${error.message}`);
|
|
615
1635
|
}
|
|
616
1636
|
}
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
});
|
|
619
1677
|
return {
|
|
620
1678
|
cwd: path,
|
|
621
|
-
cleanup
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
try {
|
|
630
|
-
;
|
|
631
|
-
[status, head] = await Promise.all([
|
|
632
|
-
git(path, ['status', '--porcelain']),
|
|
633
|
-
git(path, ['rev-parse', 'HEAD']),
|
|
634
|
-
]);
|
|
635
|
-
}
|
|
636
|
-
catch (error) {
|
|
637
|
-
return {
|
|
638
|
-
retained: true,
|
|
639
|
-
reason: `Could not inspect ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
if (status.length > 0) {
|
|
643
|
-
return {
|
|
644
|
-
retained: true,
|
|
645
|
-
reason: `${options.label} worktree has uncommitted changes and was retained at ${path}`,
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
if (head !== initialHead) {
|
|
649
|
-
return {
|
|
650
|
-
retained: true,
|
|
651
|
-
reason: `${options.label} worktree has commits and was retained at ${path}`,
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
|
-
try {
|
|
655
|
-
await git(root, ['worktree', 'remove', path]);
|
|
656
|
-
return { retained: false };
|
|
657
|
-
}
|
|
658
|
-
catch (error) {
|
|
659
|
-
return {
|
|
660
|
-
retained: true,
|
|
661
|
-
reason: `Could not remove ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
})();
|
|
665
|
-
try {
|
|
666
|
-
const result = await cleanupInFlight;
|
|
667
|
-
if (!result.retained)
|
|
668
|
-
removedResult = result;
|
|
669
|
-
return result;
|
|
670
|
-
}
|
|
671
|
-
finally {
|
|
672
|
-
cleanupInFlight = undefined;
|
|
673
|
-
}
|
|
1679
|
+
cleanup,
|
|
1680
|
+
release: cleanup,
|
|
1681
|
+
retain: async (reason) => {
|
|
1682
|
+
assertRetentionReason(reason);
|
|
1683
|
+
return settle(async () => ({
|
|
1684
|
+
retained: true,
|
|
1685
|
+
reason,
|
|
1686
|
+
}));
|
|
674
1687
|
},
|
|
675
1688
|
};
|
|
676
1689
|
}
|
|
@@ -724,6 +1737,16 @@ export async function restoreManagedWorktree(options) {
|
|
|
724
1737
|
reason: `${options.label} worktree was restored and retained at ${path}`,
|
|
725
1738
|
};
|
|
726
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
|
+
},
|
|
727
1750
|
};
|
|
728
1751
|
}
|
|
729
1752
|
//# sourceMappingURL=managed-worktree.js.map
|