@ferueda/grove 0.2.0 → 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/errors.d.ts +34 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +55 -0
- package/dist/git/branch.d.ts +8 -0
- package/dist/git/branch.d.ts.map +1 -1
- package/dist/git/branch.js +57 -0
- package/dist/hooks.d.ts +3 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +11 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/pool.d.ts +20 -20
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +395 -182
- package/dist/process/detect.d.ts +9 -2
- package/dist/process/detect.d.ts.map +1 -1
- package/dist/process/detect.js +7 -5
- package/dist/process/terminate.js +2 -2
- package/dist/queries.d.ts +8 -0
- package/dist/queries.d.ts.map +1 -0
- package/dist/queries.js +97 -0
- package/dist/schemas.d.ts +79 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +29 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +5 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
75
|
+
const shouldFetch = options ? options.fetchOnAcquire !== false : this.config.fetchOnAcquire !== false;
|
|
76
|
+
if (shouldFetch) {
|
|
19
77
|
await fetchOrigin(this.config.repoRoot);
|
|
20
78
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
-
|
|
114
|
+
if (existing.state === "quarantined") {
|
|
115
|
+
throw new LeaseQuarantinedError(`Lease ${options.leaseId} is quarantined`);
|
|
39
116
|
}
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
runPostCreate = true;
|
|
148
|
+
targetWtPath = entry.path;
|
|
149
|
+
isNewSlot = isNew;
|
|
70
150
|
});
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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(
|
|
85
|
-
|
|
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(
|
|
89
|
-
if (!wt) {
|
|
90
|
-
throw new
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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(
|
|
100
|
-
if (!wt)
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
293
|
+
await this.runHook(this.config.hooks?.postRelease, targetWtPath, leaseEnvVars);
|
|
294
|
+
return finalLease || this.inspect(leaseIdOrPath);
|
|
155
295
|
}
|
|
156
|
-
async destroy(
|
|
157
|
-
let
|
|
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(
|
|
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 ${
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 ===
|
|
188
|
-
|
|
334
|
+
const idx = state.worktrees.findIndex((wt) => wt.path === targetWtPath);
|
|
335
|
+
const wt = state.worktrees[idx];
|
|
336
|
+
if (!wt)
|
|
189
337
|
return;
|
|
190
|
-
if (!
|
|
338
|
+
if (!wt.destroying && wt.state !== "destroying")
|
|
191
339
|
return;
|
|
192
|
-
}
|
|
193
340
|
try {
|
|
194
|
-
await removeWorktree(this.config.repoRoot,
|
|
341
|
+
await removeWorktree(this.config.repoRoot, targetWtPath);
|
|
195
342
|
}
|
|
196
343
|
catch { }
|
|
197
|
-
assertPathWithinPool(this.poolDir,
|
|
344
|
+
this.assertPathWithinPool(this.poolDir, targetWtPath);
|
|
198
345
|
try {
|
|
199
|
-
await rm(dirname(
|
|
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
|
-
|
|
366
|
+
const targets = [];
|
|
208
367
|
await withStateLock(this.poolDir, async () => {
|
|
209
368
|
const state = await readState(this.poolDir);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
}
|