@ferueda/grove 0.1.2 → 0.3.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/dist/pool.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { join, basename, dirname, isAbsolute, relative } from "node:path";
2
2
  import { mkdir, rm } from "node:fs/promises";
3
- import { getDefaultBranch, addWorktree, resetWorktree, removeWorktree, isDirty, fetchOrigin, } from "./git/index.js";
3
+ import { existsSync } from "node:fs";
4
+ import { getDefaultBranch, addWorktree, resetWorktree, removeWorktree, isDirty, fetchOrigin, checkoutBranch, checkoutDetached, getHeadSha, resolveRef, deleteBranch, } from "./git/index.js";
4
5
  import { withStateLock } from "./lock.js";
5
6
  import { readState, writeState, healState } from "./state.js";
6
7
  import { reserveOwner, ownerAlive, isWorktreeInUse, findInWorktree } from "./process/detect.js";
7
8
  import { runHooks } from "./hooks.js";
8
- import { GroveExhaustedError, WorktreeDestroyingError, WorktreeInUseError, WorktreeNotManagedError, } from "./errors.js";
9
+ import { GroveExhaustedError, WorktreeDestroyingError, WorktreeNotManagedError, LeaseNotFoundError, LeaseConflictError, LeaseAlreadyExistsError, LeaseQuarantinedError, UnsafeCleanupError, PathOutsidePoolError, BranchDeleteFailedError, } from "./errors.js";
10
+ import { entryToLease, listWorktrees, listLeases, inspectLease } from "./queries.js";
9
11
  export class Grove {
10
12
  poolDir;
11
13
  config;
@@ -13,248 +15,444 @@ export class Grove {
13
15
  this.poolDir = poolDir;
14
16
  this.config = config;
15
17
  }
16
- async acquire() {
18
+ async runHook(hookNames, workDir, env = {}) {
19
+ if (!hookNames || hookNames.length === 0)
20
+ return;
21
+ try {
22
+ await runHooks(hookNames, workDir, {
23
+ stdout: process.stderr,
24
+ stderr: process.stderr,
25
+ timeoutMs: this.config.hookTimeoutMs,
26
+ env,
27
+ onFailure: this.config.onHookFailure,
28
+ });
29
+ }
30
+ catch (err) {
31
+ if (err.code === "HOOK_FAILED") {
32
+ throw err;
33
+ }
34
+ }
35
+ }
36
+ async findOrAllocateSlot(state, defaultBranch) {
37
+ for (const wt of state.worktrees) {
38
+ if (wt.destroying || wt.state === "destroying" || wt.state === "releasing" || wt.state === "quarantined" || wt.leaseId)
39
+ continue;
40
+ const { inUse } = await isWorktreeInUse(wt.path);
41
+ const alive = await ownerAlive(wt);
42
+ if (inUse || alive)
43
+ continue;
44
+ const dirty = await isDirty(wt.path);
45
+ if (dirty)
46
+ continue;
47
+ try {
48
+ await resetWorktree(wt.path, defaultBranch);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ return { entry: wt, isNew: false };
54
+ }
55
+ const maxTrees = this.config.maxTrees || 16;
56
+ if (state.worktrees.length >= maxTrees) {
57
+ throw new GroveExhaustedError(`Exhausted worktrees (max ${maxTrees})`);
58
+ }
59
+ const nextId = this.nextName(state);
60
+ const repoName = basename(this.config.repoRoot);
61
+ const wtPath = join(this.poolDir, nextId, repoName);
62
+ await mkdir(dirname(wtPath), { recursive: true });
63
+ await addWorktree(this.config.repoRoot, wtPath, defaultBranch);
64
+ const entry = {
65
+ name: nextId,
66
+ path: wtPath,
67
+ created_at: new Date().toISOString(),
68
+ state: "available",
69
+ };
70
+ state.worktrees.push(entry);
71
+ return { entry, isNew: true };
72
+ }
73
+ async acquire(options) {
17
74
  const branch = await getDefaultBranch(this.config.repoRoot);
18
- if (this.config.fetchOnAcquire !== false) {
75
+ const shouldFetch = options ? options.fetchOnAcquire !== false : this.config.fetchOnAcquire !== false;
76
+ if (shouldFetch) {
19
77
  await fetchOrigin(this.config.repoRoot);
20
78
  }
21
- let acquiredPath = "";
22
- let acquiredName = "";
23
- let runPostCreate = false;
79
+ if (!options) {
80
+ // Ephemeral acquire
81
+ let acquiredPath = "";
82
+ let acquiredName = "";
83
+ let runPostCreate = false;
84
+ await withStateLock(this.poolDir, async () => {
85
+ let state = await readState(this.poolDir);
86
+ state = await healState(state);
87
+ const { entry, isNew } = await this.findOrAllocateSlot(state, branch);
88
+ await reserveOwner(entry);
89
+ entry.state = "available";
90
+ await writeState(this.poolDir, state);
91
+ acquiredPath = entry.path;
92
+ acquiredName = entry.name;
93
+ runPostCreate = isNew;
94
+ });
95
+ if (runPostCreate) {
96
+ await this.runHook(this.config.hooks?.postCreate, acquiredPath);
97
+ }
98
+ return { path: acquiredPath, name: acquiredName };
99
+ }
100
+ // LEASE MODE
101
+ // LEASE MODE
102
+ let targetWtPath = "";
103
+ let isNewSlot = false;
104
+ let returningExisting = false;
24
105
  await withStateLock(this.poolDir, async () => {
25
106
  let state = await readState(this.poolDir);
26
107
  state = await healState(state);
27
- for (const wt of state.worktrees) {
28
- if (wt.destroying)
29
- continue;
30
- const inUse = (await ownerAlive(wt)) || (await isWorktreeInUse(wt.path));
31
- if (inUse)
32
- continue;
33
- const dirty = await isDirty(wt.path);
34
- if (dirty) {
35
- continue; // Do not destructively reset dirty worktrees
108
+ // Check existing lease
109
+ const existing = state.worktrees.find(w => w.leaseId === options.leaseId);
110
+ if (existing) {
111
+ if (options.ifLeased === "fail") {
112
+ throw new LeaseAlreadyExistsError(`Lease ${options.leaseId} already exists`);
36
113
  }
37
- try {
38
- await resetWorktree(wt.path, branch); // Always reset clean worktrees to default branch
114
+ if (existing.state === "quarantined") {
115
+ throw new LeaseQuarantinedError(`Lease ${options.leaseId} is quarantined`);
39
116
  }
40
- catch {
41
- continue;
117
+ if (!existsSync(existing.path)) {
118
+ existing.state = "quarantined";
119
+ await writeState(this.poolDir, state);
120
+ throw new LeaseQuarantinedError(`Lease ${options.leaseId} path missing`);
42
121
  }
43
- await reserveOwner(wt);
122
+ // Check compatibility
123
+ if (options.mode === "branch") {
124
+ if (existing.branch !== options.branch) {
125
+ throw new LeaseConflictError(`Lease conflict: requested branch ${options.branch}, existing has ${existing.branch}`);
126
+ }
127
+ }
128
+ else if (options.mode === "detached") {
129
+ if (existing.baseRef !== options.ref && existing.baseSha !== options.ref && existing.acquiredHeadSha !== options.ref) {
130
+ throw new LeaseConflictError(`Lease conflict: requested ref ${options.ref} does not match existing detached base or SHA`);
131
+ }
132
+ }
133
+ await reserveOwner(existing);
44
134
  await writeState(this.poolDir, state);
45
- acquiredPath = wt.path;
46
- acquiredName = wt.name;
47
- runPostCreate = true;
48
- return;
49
- }
50
- const maxTrees = this.config.maxTrees || 16;
51
- if (state.worktrees.length >= maxTrees) {
52
- throw new GroveExhaustedError(`Exhausted worktrees (max ${maxTrees})`);
135
+ targetWtPath = existing.path;
136
+ returningExisting = true;
137
+ return; // we can return existing
53
138
  }
54
- const nextId = this.nextName(state);
55
- const repoName = basename(this.config.repoRoot);
56
- const wtPath = join(this.poolDir, nextId, repoName);
57
- await mkdir(dirname(wtPath), { recursive: true });
58
- await addWorktree(this.config.repoRoot, wtPath, branch);
59
- const entry = {
60
- name: nextId,
61
- path: wtPath,
62
- created_at: new Date().toISOString(),
63
- };
139
+ // Need to acquire a new slot for the lease
140
+ const { entry, isNew } = await this.findOrAllocateSlot(state, branch);
141
+ entry.leaseId = options.leaseId;
142
+ entry.ownerId = options.ownerId;
143
+ entry.state = "leased";
144
+ entry.branch = options.mode === "branch" ? options.branch : undefined;
145
+ entry.updatedAt = new Date().toISOString();
64
146
  await reserveOwner(entry);
65
- state.worktrees.push(entry);
66
147
  await writeState(this.poolDir, state);
67
- acquiredPath = wtPath;
68
- acquiredName = nextId;
69
- runPostCreate = true;
148
+ targetWtPath = entry.path;
149
+ isNewSlot = isNew;
70
150
  });
71
- if (runPostCreate && this.config.hooks?.postCreate) {
72
- try {
73
- await runHooks(this.config.hooks.postCreate, acquiredPath, {
74
- stdout: process.stderr,
75
- stderr: process.stderr,
76
- });
151
+ if (isNewSlot) {
152
+ await this.runHook(this.config.hooks?.postCreate, targetWtPath);
153
+ }
154
+ if (returningExisting) {
155
+ return this.inspect(options.leaseId);
156
+ }
157
+ // Atomicity: checkout and set head
158
+ try {
159
+ let baseSha;
160
+ let baseRef;
161
+ if (options.mode === "branch") {
162
+ if (options.createBranch) {
163
+ baseRef = options.createBranch.from;
164
+ baseSha = await resolveRef(this.config.repoRoot, baseRef);
165
+ }
166
+ await checkoutBranch(targetWtPath, options.branch, options.createBranch);
77
167
  }
78
- catch {
79
- // hook failure does not fail acquire
168
+ else {
169
+ baseRef = options.ref;
170
+ baseSha = await resolveRef(this.config.repoRoot, baseRef);
171
+ await checkoutDetached(targetWtPath, options.ref);
80
172
  }
173
+ const headSha = await getHeadSha(targetWtPath);
174
+ await withStateLock(this.poolDir, async () => {
175
+ const state = await readState(this.poolDir);
176
+ const wt = state.worktrees.find(w => w.path === targetWtPath);
177
+ if (wt) {
178
+ wt.acquiredHeadSha = headSha;
179
+ wt.currentHeadSha = headSha;
180
+ wt.baseRef = baseRef;
181
+ wt.baseSha = baseSha;
182
+ await writeState(this.poolDir, state);
183
+ }
184
+ });
81
185
  }
82
- return { path: acquiredPath, name: acquiredName };
186
+ catch (err) {
187
+ // Quarantine on failure
188
+ await withStateLock(this.poolDir, async () => {
189
+ const state = await readState(this.poolDir);
190
+ const wt = state.worktrees.find(w => w.path === targetWtPath);
191
+ if (wt) {
192
+ wt.state = "quarantined";
193
+ await writeState(this.poolDir, state);
194
+ }
195
+ });
196
+ throw err;
197
+ }
198
+ const leaseData = await this.inspect(options.leaseId);
199
+ await this.runHook(this.config.hooks?.postAcquire, targetWtPath, this.leaseEnv(leaseData));
200
+ return leaseData;
83
201
  }
84
- async release(worktreePath) {
85
- const branch = await getDefaultBranch(this.config.repoRoot);
202
+ async release(leaseIdOrPath, options) {
203
+ if (!options) {
204
+ // Ephemeral release
205
+ const branch = await getDefaultBranch(this.config.repoRoot);
206
+ await withStateLock(this.poolDir, async () => {
207
+ const state = await readState(this.poolDir);
208
+ const wt = state.worktrees.find((w) => w.path === leaseIdOrPath);
209
+ if (!wt) {
210
+ throw new WorktreeNotManagedError(`worktree ${leaseIdOrPath} is not managed by grove`);
211
+ }
212
+ if (wt.destroying || wt.state === "destroying") {
213
+ throw new WorktreeDestroyingError(`worktree ${leaseIdOrPath} is being destroyed`);
214
+ }
215
+ });
216
+ await resetWorktree(leaseIdOrPath, branch);
217
+ await withStateLock(this.poolDir, async () => {
218
+ const state = await readState(this.poolDir);
219
+ const wt = state.worktrees.find((w) => w.path === leaseIdOrPath);
220
+ if (!wt)
221
+ return;
222
+ wt.owner_pid = undefined;
223
+ wt.owner_started_at = undefined;
224
+ wt.state = "available";
225
+ wt.leaseId = undefined;
226
+ await writeState(this.poolDir, state);
227
+ });
228
+ return;
229
+ }
230
+ // LEASE MODE RELEASE
231
+ let targetWtPath = "";
232
+ let leaseEnvVars = {};
86
233
  await withStateLock(this.poolDir, async () => {
87
234
  const state = await readState(this.poolDir);
88
- const wt = state.worktrees.find((w) => w.path === worktreePath);
89
- if (!wt) {
90
- throw new WorktreeNotManagedError(`worktree ${worktreePath} is not managed by grove`);
235
+ const wt = state.worktrees.find(w => w.leaseId === leaseIdOrPath || w.path === leaseIdOrPath);
236
+ if (!wt || !wt.leaseId) {
237
+ throw new LeaseNotFoundError(`Lease ${leaseIdOrPath} not found`);
91
238
  }
92
- if (wt.destroying) {
93
- throw new WorktreeDestroyingError(`worktree ${worktreePath} is being destroyed`);
239
+ const { inUse, unverified } = await isWorktreeInUse(wt.path);
240
+ const alive = await ownerAlive(wt);
241
+ if (options.cleanup === "reset" && !options.force) {
242
+ if (inUse || alive || unverified) {
243
+ throw new UnsafeCleanupError(`Unsafe cleanup: active processes or unverified safety. Use force: true.`);
244
+ }
94
245
  }
246
+ wt.state = "releasing";
247
+ wt.pendingCleanup = options;
248
+ await writeState(this.poolDir, state);
249
+ targetWtPath = wt.path;
250
+ leaseEnvVars = this.leaseEnv(entryToLease(wt, unverified ? "unverified" : "verified", this.config.repoRoot));
95
251
  });
96
- await resetWorktree(worktreePath, branch);
252
+ await this.runHook(this.config.hooks?.preRelease, targetWtPath, leaseEnvVars);
253
+ try {
254
+ if (options.cleanup === "reset") {
255
+ await resetWorktree(targetWtPath, options.resetTo || await getDefaultBranch(this.config.repoRoot));
256
+ }
257
+ // quarantine handles marking unusable, preserve does nothing to files
258
+ }
259
+ catch (err) {
260
+ await withStateLock(this.poolDir, async () => {
261
+ const state = await readState(this.poolDir);
262
+ const wt = state.worktrees.find(w => w.path === targetWtPath);
263
+ if (wt) {
264
+ wt.state = "quarantined";
265
+ await writeState(this.poolDir, state);
266
+ }
267
+ });
268
+ throw new UnsafeCleanupError(`Cleanup failed: ${err.message}`);
269
+ }
270
+ let finalLease = null;
97
271
  await withStateLock(this.poolDir, async () => {
98
272
  const state = await readState(this.poolDir);
99
- const wt = state.worktrees.find((w) => w.path === worktreePath);
100
- if (!wt) {
101
- throw new WorktreeNotManagedError(`worktree ${worktreePath} is not managed by grove`);
102
- }
103
- if (wt.destroying) {
104
- throw new WorktreeDestroyingError(`worktree ${worktreePath} is being destroyed`);
105
- }
273
+ const wt = state.worktrees.find(w => w.path === targetWtPath);
274
+ if (!wt)
275
+ return;
106
276
  wt.owner_pid = undefined;
107
277
  wt.owner_started_at = undefined;
108
- await writeState(this.poolDir, state);
109
- });
110
- }
111
- nextName(state) {
112
- let max = 0;
113
- for (const wt of state.worktrees) {
114
- const n = parseInt(wt.name, 10);
115
- if (!isNaN(n) && n > max) {
116
- max = n;
278
+ wt.pendingCleanup = undefined;
279
+ if (options.cleanup === "quarantine") {
280
+ wt.state = "quarantined";
117
281
  }
118
- }
119
- return (max + 1).toString();
120
- }
121
- async list() {
122
- const result = [];
123
- await withStateLock(this.poolDir, async () => {
124
- let state = await readState(this.poolDir);
125
- state = await healState(state);
126
- await writeState(this.poolDir, state);
127
- const cwd = process.cwd();
128
- for (const wt of state.worktrees) {
129
- if (wt.destroying)
130
- continue;
131
- let status = "available";
132
- const processes = await findInWorktree(wt.path);
133
- const alive = await ownerAlive(wt);
134
- if (alive) {
135
- status = "in-use";
136
- }
137
- else if (processes.length > 0) {
138
- status = "in-use";
139
- if (cwdInWorktree(cwd, wt.path)) {
140
- status = "you're here";
141
- }
142
- }
143
- else if (await isDirty(wt.path)) {
144
- status = "dirty";
145
- }
146
- result.push({
147
- name: wt.name,
148
- path: wt.path,
149
- status,
150
- processes,
151
- });
282
+ else if (options.cleanup === "reset") {
283
+ wt.state = "available";
284
+ const tempId = wt.leaseId; // capture to return lease metadata
285
+ wt.leaseId = undefined;
286
+ finalLease = entryToLease({ ...wt, leaseId: tempId }, "verified", this.config.repoRoot);
287
+ }
288
+ else if (options.cleanup === "preserve") {
289
+ wt.state = "leased";
152
290
  }
291
+ await writeState(this.poolDir, state);
153
292
  });
154
- return result;
293
+ await this.runHook(this.config.hooks?.postRelease, targetWtPath, leaseEnvVars);
294
+ return finalLease || this.inspect(leaseIdOrPath);
155
295
  }
156
- async destroy(worktreePath, options) {
157
- let reserved;
296
+ async destroy(leaseIdOrPath, options) {
297
+ let targetWtPath = "";
298
+ let leaseEnvVars = {};
299
+ let branchToDelete;
158
300
  await withStateLock(this.poolDir, async () => {
159
301
  const state = await readState(this.poolDir);
160
- const idx = state.worktrees.findIndex((wt) => wt.path === worktreePath);
302
+ const idx = state.worktrees.findIndex(w => w.leaseId === leaseIdOrPath || w.path === leaseIdOrPath);
161
303
  const targetWt = state.worktrees[idx];
162
304
  if (!targetWt) {
163
- throw new WorktreeNotManagedError(`worktree ${worktreePath} is not managed by grove`);
305
+ throw new WorktreeNotManagedError(`worktree ${leaseIdOrPath} not managed by grove`);
164
306
  }
307
+ const { inUse, unverified } = await isWorktreeInUse(targetWt.path);
308
+ const alive = await ownerAlive(targetWt);
165
309
  if (!options?.force) {
166
- const inUse = (await ownerAlive(targetWt)) || (await isWorktreeInUse(targetWt.path));
167
- if (inUse) {
168
- throw new WorktreeInUseError(`worktree ${worktreePath} is in use by an agent. Use --force to override`);
310
+ if (inUse || alive || unverified) {
311
+ throw new UnsafeCleanupError(`worktree ${targetWt.path} is in use or unverified. Use --force to override`);
312
+ }
313
+ }
314
+ if (options?.deleteBranch && targetWt.branch) {
315
+ const safePrefixes = this.config.safeDeleteBranchPrefixes || [];
316
+ if (!safePrefixes.some(p => targetWt.branch.startsWith(p))) {
317
+ throw new UnsafeCleanupError(`Branch ${targetWt.branch} does not match safe-delete prefixes`);
169
318
  }
319
+ branchToDelete = targetWt.branch;
170
320
  }
171
321
  targetWt.destroying = true;
322
+ targetWt.state = "destroying";
172
323
  await reserveOwner(targetWt);
173
- reserved = { ...targetWt };
324
+ targetWtPath = targetWt.path;
325
+ leaseEnvVars = targetWt.leaseId ? this.leaseEnv(entryToLease(targetWt, unverified ? "unverified" : "verified", this.config.repoRoot)) : {};
174
326
  await writeState(this.poolDir, state);
175
327
  });
176
- if (this.config.hooks?.preDestroy) {
177
- try {
178
- await runHooks(this.config.hooks.preDestroy, worktreePath, {
179
- stdout: process.stderr,
180
- stderr: process.stderr,
181
- });
182
- }
183
- catch { }
184
- }
328
+ await this.executeDestroy(targetWtPath, leaseEnvVars, branchToDelete, options);
329
+ }
330
+ async executeDestroy(targetWtPath, leaseEnvVars, branchToDelete, options) {
331
+ await this.runHook(this.config.hooks?.preDestroy, targetWtPath, leaseEnvVars);
185
332
  await withStateLock(this.poolDir, async () => {
186
333
  const state = await readState(this.poolDir);
187
- const idx = state.worktrees.findIndex((wt) => wt.path === worktreePath);
188
- if (idx === -1)
334
+ const idx = state.worktrees.findIndex((wt) => wt.path === targetWtPath);
335
+ const wt = state.worktrees[idx];
336
+ if (!wt)
189
337
  return;
190
- if (!sameDestroyReservation(state.worktrees[idx], reserved)) {
338
+ if (!wt.destroying && wt.state !== "destroying")
191
339
  return;
192
- }
193
340
  try {
194
- await removeWorktree(this.config.repoRoot, worktreePath);
341
+ await removeWorktree(this.config.repoRoot, targetWtPath);
195
342
  }
196
343
  catch { }
197
- assertPathWithinPool(this.poolDir, worktreePath);
344
+ this.assertPathWithinPool(this.poolDir, targetWtPath);
198
345
  try {
199
- await rm(dirname(worktreePath), { recursive: true, force: true });
346
+ await rm(dirname(targetWtPath), { recursive: true, force: true });
200
347
  }
201
348
  catch { }
349
+ let branchDeleteError;
350
+ if (branchToDelete) {
351
+ try {
352
+ await deleteBranch(this.config.repoRoot, branchToDelete, options?.force);
353
+ }
354
+ catch (err) {
355
+ branchDeleteError = err;
356
+ }
357
+ }
202
358
  state.worktrees.splice(idx, 1);
203
359
  await writeState(this.poolDir, state);
360
+ if (branchDeleteError) {
361
+ throw new BranchDeleteFailedError(`Branch deletion failed: ${branchDeleteError.message}`);
362
+ }
204
363
  });
205
364
  }
206
365
  async destroyAll(options) {
207
- let worktrees = [];
366
+ const targets = [];
208
367
  await withStateLock(this.poolDir, async () => {
209
368
  const state = await readState(this.poolDir);
210
- if (!options?.force) {
211
- for (const wt of state.worktrees) {
212
- const inUse = (await ownerAlive(wt)) || (await isWorktreeInUse(wt.path));
213
- if (inUse) {
214
- throw new WorktreeInUseError(`worktree ${wt.path} is in use by an agent. Use --force to override`);
369
+ // Phase 1: validate all
370
+ for (const wt of state.worktrees) {
371
+ const { inUse, unverified } = await isWorktreeInUse(wt.path);
372
+ const alive = await ownerAlive(wt);
373
+ if (!options?.force) {
374
+ if (inUse || alive || unverified) {
375
+ throw new UnsafeCleanupError(`worktree ${wt.path} is in use or unverified. Use --force to override`);
215
376
  }
216
377
  }
217
378
  }
379
+ // Phase 2: reserve all
218
380
  for (const wt of state.worktrees) {
219
381
  wt.destroying = true;
382
+ wt.state = "destroying";
220
383
  await reserveOwner(wt);
384
+ const { unverified } = await findInWorktree(wt.path);
385
+ const env = wt.leaseId ? this.leaseEnv(entryToLease(wt, unverified ? "unverified" : "verified", this.config.repoRoot)) : {};
386
+ targets.push({ path: wt.path, env });
221
387
  }
222
- worktrees = state.worktrees.map((wt) => ({ ...wt }));
223
388
  await writeState(this.poolDir, state);
224
389
  });
225
- for (const wt of worktrees) {
226
- if (this.config.hooks?.preDestroy) {
227
- try {
228
- await runHooks(this.config.hooks.preDestroy, wt.path, {
229
- stdout: process.stderr,
230
- stderr: process.stderr,
231
- });
232
- }
233
- catch { }
390
+ // Phase 3: execute destructs outside the shared lock
391
+ const errors = [];
392
+ await Promise.all(targets.map(async (target) => {
393
+ try {
394
+ await this.executeDestroy(target.path, target.env, undefined, options);
234
395
  }
396
+ catch (err) {
397
+ errors.push(err);
398
+ }
399
+ }));
400
+ if (errors.length > 0) {
401
+ throw errors[0]; // Propagate the first failure (e.g. BranchDeleteFailedError)
235
402
  }
403
+ }
404
+ async repair(options) {
236
405
  await withStateLock(this.poolDir, async () => {
237
406
  const state = await readState(this.poolDir);
238
- const remove = new Set();
239
- for (const wt of worktrees) {
240
- const idx = state.worktrees.findIndex((s) => s.path === wt.path);
241
- if (idx === -1 || !sameDestroyReservation(state.worktrees[idx], wt)) {
242
- continue;
407
+ const wt = state.worktrees.find(w => w.leaseId === options.leaseId);
408
+ if (!wt)
409
+ throw new LeaseNotFoundError(`Lease ${options.leaseId} not found`);
410
+ const { inUse, unverified } = await isWorktreeInUse(wt.path);
411
+ const alive = await ownerAlive(wt);
412
+ if (options.action === "force-destroy") {
413
+ if (!options.force && (inUse || alive || unverified)) {
414
+ throw new UnsafeCleanupError("Cannot force-destroy: processes running or unverified. Use force: true");
243
415
  }
244
- remove.add(wt.path);
245
- try {
246
- await removeWorktree(this.config.repoRoot, wt.path);
247
- }
248
- catch { }
249
- assertPathWithinPool(this.poolDir, wt.path);
250
- try {
251
- await rm(dirname(wt.path), { recursive: true, force: true });
416
+ }
417
+ if (options.action === "quarantine") {
418
+ wt.state = "quarantined";
419
+ wt.pendingCleanup = undefined;
420
+ await writeState(this.poolDir, state);
421
+ return;
422
+ }
423
+ if (options.action === "resume-cleanup") {
424
+ if (!wt.pendingCleanup) {
425
+ throw new UnsafeCleanupError("No pending cleanup to resume");
252
426
  }
253
- catch { }
427
+ // we will let the outside call handle it, just leave state as is
428
+ }
429
+ if (options.action === "force-destroy") {
430
+ // Handled by explicit call outside
254
431
  }
255
- state.worktrees = state.worktrees.filter((wt) => !remove.has(wt.path));
256
- await writeState(this.poolDir, state);
257
432
  });
433
+ if (options.action === "resume-cleanup") {
434
+ const wt = await this.inspect(options.leaseId);
435
+ if (wt?.pendingCleanup) {
436
+ return this.release(options.leaseId, wt.pendingCleanup);
437
+ }
438
+ }
439
+ if (options.action === "force-destroy") {
440
+ await this.destroy(options.leaseId, { force: true });
441
+ return;
442
+ }
443
+ const res = await this.inspect(options.leaseId);
444
+ if (!res)
445
+ throw new LeaseNotFoundError("Lease not found after repair");
446
+ return res;
447
+ }
448
+ async inspect(leaseIdOrPath) {
449
+ return inspectLease(leaseIdOrPath, this.poolDir, this.config);
450
+ }
451
+ async list(_options) {
452
+ return listWorktrees(this.poolDir);
453
+ }
454
+ async listLeases(_options) {
455
+ return listLeases(this.poolDir, this.config);
258
456
  }
259
457
  async findByPath(worktreePath) {
260
458
  const state = await readState(this.poolDir);
@@ -265,20 +463,35 @@ export class Grove {
265
463
  }
266
464
  return null;
267
465
  }
268
- }
269
- function sameDestroyReservation(current, reserved) {
270
- return (current.path === reserved.path &&
271
- current.destroying &&
272
- current.owner_pid === reserved.owner_pid &&
273
- current.owner_started_at === reserved.owner_started_at);
274
- }
275
- function cwdInWorktree(cwd, worktreePath) {
276
- const rel = relative(worktreePath, cwd);
277
- return !rel.startsWith("..") && !isAbsolute(rel);
278
- }
279
- function assertPathWithinPool(poolDir, targetPath) {
280
- const rel = relative(poolDir, targetPath);
281
- if (rel.startsWith("..") || isAbsolute(rel)) {
282
- throw new Error("Security violation: target path is outside the pool boundary");
466
+ nextName(state) {
467
+ let max = 0;
468
+ for (const wt of state.worktrees) {
469
+ const n = parseInt(wt.name, 10);
470
+ if (!isNaN(n) && n > max) {
471
+ max = n;
472
+ }
473
+ }
474
+ return (max + 1).toString();
475
+ }
476
+ cwdInWorktree(cwd, worktreePath) {
477
+ const rel = relative(worktreePath, cwd);
478
+ return !rel.startsWith("..") && !isAbsolute(rel);
479
+ }
480
+ assertPathWithinPool(poolDir, targetPath) {
481
+ const rel = relative(poolDir, targetPath);
482
+ if (rel.startsWith("..") || isAbsolute(rel)) {
483
+ throw new PathOutsidePoolError("Security violation: target path is outside the pool boundary");
484
+ }
485
+ }
486
+ leaseEnv(lease) {
487
+ const e = {
488
+ GROVE_LEASE_ID: lease.leaseId,
489
+ GROVE_SLOT_NAME: lease.slotName,
490
+ GROVE_REPO_ROOT: lease.repoRoot,
491
+ GROVE_WORKTREE_PATH: lease.path,
492
+ };
493
+ if (lease.branch)
494
+ e.GROVE_BRANCH = lease.branch;
495
+ return e;
283
496
  }
284
497
  }