@signalridge/pi-subagents 1.4.0 → 1.6.1
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/CHANGELOG.md +68 -0
- package/README.md +69 -12
- package/package.json +6 -6
- package/src/agent-color.ts +188 -0
- package/src/agent-file-toggle.ts +8 -0
- package/src/agent-manager.ts +793 -62
- package/src/agent-runner.ts +361 -25
- package/src/agent-tiers.ts +189 -8
- package/src/agent-types.ts +3 -0
- package/src/ask-tools.ts +114 -0
- package/src/cross-extension-rpc.ts +16 -5
- package/src/custom-agents.ts +67 -2
- package/src/default-agents.ts +6 -5
- package/src/gate.ts +0 -0
- package/src/index.ts +3386 -1128
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/output-file.ts +23 -1
- package/src/settings.ts +208 -9
- package/src/supervisor.ts +115 -0
- package/src/types.ts +59 -2
- package/src/ui/agent-mention.ts +163 -0
- package/src/ui/conversation-viewer.ts +4 -3
- package/src/ui/fleet-list.ts +6 -5
- package/src/worktree.ts +128 -648
package/src/worktree.ts
CHANGED
|
@@ -6,12 +6,11 @@
|
|
|
6
6
|
* If changes exist, a branch is created and returned in the result.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
|
-
import {
|
|
12
|
-
import { rm } from "node:fs/promises";
|
|
11
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
13
12
|
import { tmpdir } from "node:os";
|
|
14
|
-
import {
|
|
13
|
+
import { join, relative } from "node:path";
|
|
15
14
|
|
|
16
15
|
export interface WorktreeInfo {
|
|
17
16
|
/** Absolute path to the worktree directory (the copied repo's root). */
|
|
@@ -20,17 +19,35 @@ export interface WorktreeInfo {
|
|
|
20
19
|
branch: string;
|
|
21
20
|
/** Commit SHA that the worktree was created from. */
|
|
22
21
|
baseSha: string;
|
|
23
|
-
/** Absolute path to the original repository's Git top-level. */
|
|
24
|
-
repoRoot: string;
|
|
25
22
|
/**
|
|
26
23
|
* Where the agent should work inside the worktree: the equivalent of the
|
|
27
|
-
* cwd the worktree was created from. Equals `path` when that cwd was
|
|
24
|
+
* cwd the worktree was created from. Equals `path` when that cwd was the
|
|
25
|
+
* repo root; points at the copied subdirectory when it was deeper (e.g. a
|
|
26
|
+
* monorepo package), so the requested scoping survives isolation.
|
|
28
27
|
*/
|
|
29
28
|
workPath: string;
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
/** Nearest git root repository top-level. */
|
|
30
|
+
repoRoot: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Project-wide switch for worktree isolation (`worktreeIsolation` in
|
|
35
|
+
* subagents.json). Default `true` — unchanged behaviour.
|
|
36
|
+
*
|
|
37
|
+
* The `"off"` isolation value gives a model a legal way to decline a worktree,
|
|
38
|
+
* but it still depends on the model choosing it. This is the deterministic half
|
|
39
|
+
* of the same fix: on a large repo where every worktree costs real time and
|
|
40
|
+
* disk (#184), turning it off means no caller can create one, whatever it
|
|
41
|
+
* passes.
|
|
42
|
+
*/
|
|
43
|
+
let worktreeIsolationEnabled = true;
|
|
44
|
+
|
|
45
|
+
export function setWorktreeIsolationEnabled(enabled: boolean): void {
|
|
46
|
+
worktreeIsolationEnabled = enabled;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isWorktreeIsolationEnabled(): boolean {
|
|
50
|
+
return worktreeIsolationEnabled;
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
export interface WorktreeCleanupResult {
|
|
@@ -38,26 +55,14 @@ export interface WorktreeCleanupResult {
|
|
|
38
55
|
hasChanges: boolean;
|
|
39
56
|
/** Branch name if changes were committed. */
|
|
40
57
|
branch?: string;
|
|
41
|
-
/** Worktree path if it was kept
|
|
58
|
+
/** Worktree path if it was kept. */
|
|
42
59
|
path?: string;
|
|
43
|
-
/**
|
|
44
|
-
cleanupSucceeded
|
|
45
|
-
/**
|
|
60
|
+
/** Diagnostic status indicator for cleanup forensics. */
|
|
61
|
+
cleanupSucceeded?: boolean;
|
|
62
|
+
/** Detailed error message or diagnostic if cleanup encountered issues. */
|
|
46
63
|
cleanupDiagnostic?: string;
|
|
47
|
-
/**
|
|
48
|
-
recoveryCommands?: readonly string[];
|
|
49
|
-
/** Whether Git's removal command completed successfully. */
|
|
50
|
-
gitRemovalSucceeded?: boolean;
|
|
51
|
-
/** Whether the safe package-owned filesystem fallback was attempted. */
|
|
52
|
-
filesystemFallbackAttempted?: boolean;
|
|
53
|
-
/** Whether the safe filesystem fallback removed the directory. */
|
|
54
|
-
filesystemFallbackSucceeded?: boolean;
|
|
55
|
-
/** Whether Git registration was verified absent (when Git allowed verification). */
|
|
56
|
-
registrationCleanupVerified?: boolean;
|
|
57
|
-
/** Whether the registration prune was attempted after fallback. */
|
|
58
|
-
pruneAttempted?: boolean;
|
|
59
|
-
/** Whether the attempted registration prune completed. */
|
|
60
|
-
pruneSucceeded?: boolean;
|
|
64
|
+
/** Remediation shell commands if cleanup failed. */
|
|
65
|
+
recoveryCommands?: readonly string[] | string[];
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/**
|
|
@@ -65,708 +70,183 @@ export interface WorktreeCleanupResult {
|
|
|
65
70
|
* Returns the worktree path, or undefined if not in a git repo.
|
|
66
71
|
*/
|
|
67
72
|
export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined {
|
|
73
|
+
// Verify we're in a git repo with at least one commit (HEAD must exist)
|
|
68
74
|
let baseSha: string;
|
|
69
|
-
let repoRoot: string;
|
|
70
75
|
let subdir: string;
|
|
76
|
+
let topLevel: string;
|
|
71
77
|
try {
|
|
72
78
|
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
73
79
|
baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
|
|
74
80
|
.toString()
|
|
75
81
|
.trim();
|
|
76
|
-
|
|
82
|
+
// Where cwd sits inside the repo ("" at the root): the agent must work at
|
|
83
|
+
// the same subdirectory inside the copy, or a monorepo-package cwd would
|
|
84
|
+
// silently widen to the whole repo. realpath both sides — git emits
|
|
85
|
+
// resolved paths while cwd may arrive through a symlink (macOS /tmp).
|
|
86
|
+
topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
|
|
77
87
|
.toString()
|
|
78
88
|
.trim();
|
|
79
|
-
|
|
80
|
-
subdir = relative(repoRoot, realpathSync(cwd));
|
|
89
|
+
subdir = relative(realpathSync(topLevel), realpathSync(cwd));
|
|
81
90
|
} catch {
|
|
82
91
|
return undefined;
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
const branch = `pi-agent-${agentId}`;
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const worktreePath = join(tempRoot, tempName);
|
|
95
|
+
const suffix = randomUUID().slice(0, 8);
|
|
96
|
+
const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
|
|
89
97
|
|
|
90
98
|
try {
|
|
99
|
+
// Create detached worktree at HEAD
|
|
91
100
|
execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
|
|
92
101
|
cwd,
|
|
93
102
|
stdio: "pipe",
|
|
94
103
|
timeout: 30000,
|
|
95
104
|
});
|
|
96
|
-
return {
|
|
97
|
-
path: worktreePath,
|
|
98
|
-
branch,
|
|
99
|
-
baseSha,
|
|
100
|
-
repoRoot,
|
|
101
|
-
workPath: subdir ? join(worktreePath, subdir) : worktreePath,
|
|
102
|
-
tempRoot,
|
|
103
|
-
tempName,
|
|
104
|
-
};
|
|
105
|
+
return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath, repoRoot: topLevel };
|
|
105
106
|
} catch {
|
|
107
|
+
// If worktree creation fails, return undefined (agent runs in normal cwd)
|
|
106
108
|
return undefined;
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/**
|
|
111
113
|
* Clean up a worktree after agent completion.
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* cannot prove that the changes are safe on a branch, the worktree is left in
|
|
115
|
-
* place and the caller receives bounded recovery diagnostics. In particular,
|
|
116
|
-
* no forced removal is attempted after a preservation operation fails.
|
|
114
|
+
* - If no changes: remove worktree entirely.
|
|
115
|
+
* - If changes exist: create a branch, commit changes, return branch info.
|
|
117
116
|
*/
|
|
118
117
|
export function cleanupWorktree(
|
|
119
118
|
cwd: string,
|
|
120
119
|
worktree: WorktreeInfo,
|
|
121
120
|
agentDescription: string,
|
|
122
121
|
): WorktreeCleanupResult {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (initialPathFailure) {
|
|
126
|
-
return cleanupResult(
|
|
127
|
-
worktree,
|
|
128
|
-
false,
|
|
129
|
-
undefined,
|
|
130
|
-
removeWorktree(cwd, worktree),
|
|
131
|
-
`Worktree path could not be safely inspected: ${initialPathFailure}`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
if (!initialPathEntry.present) {
|
|
135
|
-
return cleanupResult(worktree, false, undefined, removeWorktree(cwd, worktree));
|
|
122
|
+
if (!existsSync(worktree.path)) {
|
|
123
|
+
return { hasChanges: false, cleanupSucceeded: true };
|
|
136
124
|
}
|
|
137
125
|
|
|
138
|
-
let
|
|
126
|
+
let hasChanges = false;
|
|
139
127
|
try {
|
|
140
|
-
|
|
128
|
+
// Check for uncommitted changes in the worktree
|
|
129
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
141
130
|
cwd: worktree.path,
|
|
142
131
|
stdio: "pipe",
|
|
143
132
|
timeout: 10000,
|
|
144
133
|
}).toString().trim();
|
|
145
|
-
} catch (error: unknown) {
|
|
146
|
-
return preservationFailure(worktree, false, "status", error, cwd);
|
|
147
|
-
}
|
|
148
134
|
|
|
149
|
-
|
|
150
|
-
|
|
135
|
+
if (status) {
|
|
136
|
+
hasChanges = true;
|
|
137
|
+
// Changes exist — stage, commit, and create a branch
|
|
151
138
|
execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
|
|
152
|
-
|
|
153
|
-
return preservationFailure(worktree, true, "git add", error, cwd);
|
|
154
|
-
}
|
|
155
|
-
try {
|
|
139
|
+
// Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
|
|
156
140
|
const safeDesc = agentDescription.slice(0, 200);
|
|
157
|
-
|
|
141
|
+
const commitMsg = `pi-agent: ${safeDesc}`;
|
|
142
|
+
execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], {
|
|
158
143
|
cwd: worktree.path,
|
|
159
144
|
stdio: "pipe",
|
|
160
145
|
timeout: 10000,
|
|
161
146
|
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
} else {
|
|
166
|
-
let currentSha: string;
|
|
167
|
-
try {
|
|
168
|
-
currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
147
|
+
} else {
|
|
148
|
+
const currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
169
149
|
cwd: worktree.path,
|
|
170
150
|
stdio: "pipe",
|
|
171
151
|
timeout: 5000,
|
|
172
152
|
}).toString().trim();
|
|
173
|
-
} catch (error: unknown) {
|
|
174
|
-
return preservationFailure(worktree, false, "HEAD inspection", error, cwd);
|
|
175
|
-
}
|
|
176
153
|
|
|
177
|
-
|
|
178
|
-
|
|
154
|
+
if (currentSha === worktree.baseSha) {
|
|
155
|
+
// No changes — remove worktree
|
|
156
|
+
removeWorktree(cwd, worktree.path);
|
|
157
|
+
return { hasChanges: false, cleanupSucceeded: true };
|
|
158
|
+
}
|
|
159
|
+
hasChanges = true;
|
|
179
160
|
}
|
|
180
|
-
}
|
|
181
161
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
cwd: worktree.path,
|
|
186
|
-
stdio: "pipe",
|
|
187
|
-
timeout: 5000,
|
|
188
|
-
});
|
|
189
|
-
} catch {
|
|
190
|
-
branchName = `${worktree.branch}-${Date.now()}`;
|
|
162
|
+
// Create a branch pointing to the worktree's HEAD.
|
|
163
|
+
// If the branch already exists, append a suffix to avoid overwriting previous work.
|
|
164
|
+
let branchName = worktree.branch;
|
|
191
165
|
try {
|
|
192
166
|
execFileSync("git", ["branch", branchName], {
|
|
193
167
|
cwd: worktree.path,
|
|
194
168
|
stdio: "pipe",
|
|
195
169
|
timeout: 5000,
|
|
196
170
|
});
|
|
197
|
-
} catch
|
|
198
|
-
|
|
171
|
+
} catch {
|
|
172
|
+
// Branch already exists — use a unique suffix
|
|
173
|
+
branchName = `${worktree.branch}-${Date.now()}`;
|
|
174
|
+
execFileSync("git", ["branch", branchName], {
|
|
175
|
+
cwd: worktree.path,
|
|
176
|
+
stdio: "pipe",
|
|
177
|
+
timeout: 5000,
|
|
178
|
+
});
|
|
199
179
|
}
|
|
200
|
-
|
|
180
|
+
// Update branch name in worktree info for the caller
|
|
181
|
+
worktree.branch = branchName;
|
|
182
|
+
|
|
183
|
+
// Remove the worktree (branch persists in main repo)
|
|
184
|
+
removeWorktree(cwd, worktree.path);
|
|
201
185
|
|
|
202
|
-
|
|
203
|
-
|
|
186
|
+
return {
|
|
187
|
+
hasChanges: true,
|
|
188
|
+
branch: worktree.branch,
|
|
189
|
+
path: worktree.path,
|
|
190
|
+
cleanupSucceeded: true,
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Best effort cleanup on error
|
|
194
|
+
try { removeWorktree(cwd, worktree.path); } catch { /* ignore */ }
|
|
195
|
+
return {
|
|
196
|
+
hasChanges,
|
|
197
|
+
path: worktree.path,
|
|
198
|
+
cleanupSucceeded: false,
|
|
199
|
+
cleanupDiagnostic: error instanceof Error ? error.message : String(error),
|
|
200
|
+
recoveryCommands: [
|
|
201
|
+
`git -C ${JSON.stringify(cwd)} worktree remove --force ${JSON.stringify(worktree.path)}`,
|
|
202
|
+
`git -C ${JSON.stringify(cwd)} worktree prune`,
|
|
203
|
+
`rm -rf -- ${JSON.stringify(worktree.path)}`,
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
204
207
|
}
|
|
205
208
|
|
|
206
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Async counterpart for cleanupWorktree.
|
|
211
|
+
*/
|
|
207
212
|
export async function cleanupWorktreeAsync(
|
|
208
213
|
cwd: string,
|
|
209
214
|
worktree: WorktreeInfo,
|
|
210
215
|
agentDescription: string,
|
|
211
216
|
): Promise<WorktreeCleanupResult> {
|
|
212
|
-
|
|
213
|
-
const initialPathFailure = directoryEntryFailure(worktree.path, initialPathEntry);
|
|
214
|
-
if (initialPathFailure) {
|
|
215
|
-
return cleanupResult(
|
|
216
|
-
worktree,
|
|
217
|
-
false,
|
|
218
|
-
undefined,
|
|
219
|
-
await removeWorktreeAsync(cwd, worktree),
|
|
220
|
-
`Worktree path could not be safely inspected: ${initialPathFailure}`,
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
if (!initialPathEntry.present) {
|
|
224
|
-
return cleanupResult(worktree, false, undefined, await removeWorktreeAsync(cwd, worktree));
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
let status: string;
|
|
228
|
-
try {
|
|
229
|
-
status = (await execFileAsync("git", ["status", "--porcelain"], worktree.path, 10000)).toString().trim();
|
|
230
|
-
} catch (error: unknown) {
|
|
231
|
-
return preservationFailure(worktree, false, "status", error, cwd);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (status) {
|
|
235
|
-
try {
|
|
236
|
-
await execFileAsync("git", ["add", "-A"], worktree.path, 10000);
|
|
237
|
-
} catch (error: unknown) {
|
|
238
|
-
return preservationFailure(worktree, true, "git add", error, cwd);
|
|
239
|
-
}
|
|
240
|
-
try {
|
|
241
|
-
const safeDesc = agentDescription.slice(0, 200);
|
|
242
|
-
await execFileAsync("git", ["commit", "--no-verify", "-m", `pi-agent: ${safeDesc}`], worktree.path, 10000);
|
|
243
|
-
} catch (error: unknown) {
|
|
244
|
-
return preservationFailure(worktree, true, "git commit", error, cwd);
|
|
245
|
-
}
|
|
246
|
-
} else {
|
|
247
|
-
let currentSha: string;
|
|
248
|
-
try {
|
|
249
|
-
currentSha = (await execFileAsync("git", ["rev-parse", "HEAD"], worktree.path, 5000)).toString().trim();
|
|
250
|
-
} catch (error: unknown) {
|
|
251
|
-
return preservationFailure(worktree, false, "HEAD inspection", error, cwd);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (currentSha === worktree.baseSha) {
|
|
255
|
-
return cleanupResult(worktree, false, undefined, await removeWorktreeAsync(cwd, worktree));
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
let branchName = worktree.branch;
|
|
260
|
-
try {
|
|
261
|
-
await execFileAsync("git", ["branch", branchName], worktree.path, 5000);
|
|
262
|
-
} catch {
|
|
263
|
-
branchName = `${worktree.branch}-${Date.now()}`;
|
|
264
|
-
try {
|
|
265
|
-
await execFileAsync("git", ["branch", branchName], worktree.path, 5000);
|
|
266
|
-
} catch (error: unknown) {
|
|
267
|
-
return preservationFailure(worktree, true, "git branch", error, cwd);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
worktree.branch = branchName;
|
|
272
|
-
return cleanupResult(worktree, true, branchName, await removeWorktreeAsync(cwd, worktree));
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function cleanupResult(
|
|
276
|
-
worktree: WorktreeInfo,
|
|
277
|
-
hasChanges: boolean,
|
|
278
|
-
branch: string | undefined,
|
|
279
|
-
removal: WorktreeRemovalResult,
|
|
280
|
-
operationDiagnostic?: string,
|
|
281
|
-
): WorktreeCleanupResult {
|
|
282
|
-
const diagnostic = [operationDiagnostic, removal.cleanupDiagnostic].filter(Boolean).join(" ");
|
|
283
|
-
return {
|
|
284
|
-
hasChanges,
|
|
285
|
-
...(branch === undefined ? {} : { branch }),
|
|
286
|
-
path: worktree.path,
|
|
287
|
-
cleanupSucceeded: removal.cleanupSucceeded,
|
|
288
|
-
...(diagnostic ? { cleanupDiagnostic: diagnostic.slice(0, WORKTREE_DIAGNOSTIC_LIMIT) } : {}),
|
|
289
|
-
...(removal.recoveryCommands ? { recoveryCommands: removal.recoveryCommands } : {}),
|
|
290
|
-
gitRemovalSucceeded: removal.gitRemovalSucceeded,
|
|
291
|
-
filesystemFallbackAttempted: removal.filesystemFallbackAttempted,
|
|
292
|
-
...(removal.filesystemFallbackSucceeded === undefined ? {} : { filesystemFallbackSucceeded: removal.filesystemFallbackSucceeded }),
|
|
293
|
-
...(removal.registrationCleanupVerified === undefined ? {} : { registrationCleanupVerified: removal.registrationCleanupVerified }),
|
|
294
|
-
pruneAttempted: removal.pruneAttempted,
|
|
295
|
-
...(removal.pruneSucceeded === undefined ? {} : { pruneSucceeded: removal.pruneSucceeded }),
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function preservationFailure(
|
|
300
|
-
worktree: WorktreeInfo,
|
|
301
|
-
hasChanges: boolean,
|
|
302
|
-
operation: string,
|
|
303
|
-
error: unknown,
|
|
304
|
-
cwd: string,
|
|
305
|
-
): WorktreeCleanupResult {
|
|
306
|
-
return {
|
|
307
|
-
hasChanges,
|
|
308
|
-
path: worktree.path,
|
|
309
|
-
cleanupSucceeded: false,
|
|
310
|
-
cleanupDiagnostic: `Worktree preservation failed during ${operation}; the worktree was kept in place: ${errorMessage(error)}`,
|
|
311
|
-
recoveryCommands: recoveryCommands(cwd, worktree.path),
|
|
312
|
-
};
|
|
217
|
+
return cleanupWorktree(cwd, worktree, agentDescription);
|
|
313
218
|
}
|
|
314
219
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
interface WorktreeRemovalResult {
|
|
322
|
-
cleanupSucceeded: boolean;
|
|
323
|
-
cleanupDiagnostic?: string;
|
|
324
|
-
recoveryCommands?: readonly string[];
|
|
325
|
-
gitRemovalSucceeded: boolean;
|
|
326
|
-
filesystemFallbackAttempted: boolean;
|
|
327
|
-
filesystemFallbackSucceeded?: boolean;
|
|
328
|
-
registrationCleanupVerified?: boolean;
|
|
329
|
-
pruneAttempted: boolean;
|
|
330
|
-
pruneSucceeded?: boolean;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const WORKTREE_DIAGNOSTIC_LIMIT = 2_000;
|
|
334
|
-
|
|
335
|
-
function errorMessage(error: unknown): string {
|
|
336
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
337
|
-
return message.replace(/\s+/g, " ").slice(0, WORKTREE_DIAGNOSTIC_LIMIT);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function execFileAsync(command: string, args: readonly string[], cwd: string, timeout: number): Promise<Buffer> {
|
|
341
|
-
return new Promise((resolvePromise, reject) => {
|
|
342
|
-
execFile(command, [...args], { cwd, timeout }, (error, stdout) => {
|
|
343
|
-
if (error) {
|
|
344
|
-
reject(error);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
resolvePromise(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
|
|
348
|
-
});
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
type DirectoryEntryKind = "directory" | "symlink" | "special";
|
|
353
|
-
|
|
354
|
-
interface DirectoryEntryState {
|
|
355
|
-
present: boolean;
|
|
356
|
-
verified: boolean;
|
|
357
|
-
kind?: DirectoryEntryKind;
|
|
358
|
-
error?: string;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Inspect one lexical directory entry without following symlinks. */
|
|
362
|
-
function inspectDirectoryEntry(path: string): DirectoryEntryState {
|
|
363
|
-
try {
|
|
364
|
-
const stats = lstatSync(path);
|
|
365
|
-
return {
|
|
366
|
-
present: true,
|
|
367
|
-
verified: true,
|
|
368
|
-
kind: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : "special",
|
|
369
|
-
};
|
|
370
|
-
} catch (error: unknown) {
|
|
371
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { present: false, verified: true };
|
|
372
|
-
return { present: true, verified: false, error: `lstat failed: ${errorMessage(error)}` };
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function directoryEntryFailure(path: string, entry: DirectoryEntryState): string | undefined {
|
|
377
|
-
if (!entry.verified) return `path ${path} could not be verified: ${entry.error ?? "unknown lstat failure"}`;
|
|
378
|
-
if (!entry.present || entry.kind === "directory") return undefined;
|
|
379
|
-
if (entry.kind === "symlink") return `path ${path} is a symlink; refusing recursive filesystem fallback`;
|
|
380
|
-
return `path ${path} is a special file; refusing recursive filesystem fallback`;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function directoryEntryDescription(entry: DirectoryEntryState): string {
|
|
384
|
-
if (!entry.verified) return `unverifiable${entry.error ? ` (${entry.error})` : ""}`;
|
|
385
|
-
if (!entry.present) return "absent";
|
|
386
|
-
return entry.kind ?? "present";
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function shellQuote(value: string): string {
|
|
390
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function recoveryCommands(cwd: string, worktreePath: string): readonly string[] {
|
|
394
|
-
return Object.freeze([
|
|
395
|
-
`git -C ${shellQuote(cwd)} worktree remove --force ${shellQuote(worktreePath)}`,
|
|
396
|
-
`rm -rf -- ${shellQuote(worktreePath)}`,
|
|
397
|
-
`git -C ${shellQuote(cwd)} worktree prune`,
|
|
398
|
-
]);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** Only a path returned by createWorktree may be removed directly. */
|
|
402
|
-
function isPackageCreatedWorktree(worktree: WorktreeInfo): { verified: boolean; reason?: string } {
|
|
403
|
-
const refuse = (reason: string): { verified: false; reason: string } => ({
|
|
404
|
-
verified: false,
|
|
405
|
-
reason: `path ${worktree.path}: ${reason}`,
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
if (!isAbsolute(worktree.path) || !isAbsolute(worktree.repoRoot) || !isAbsolute(worktree.workPath)) {
|
|
409
|
-
return refuse("worktree paths are not absolute");
|
|
410
|
-
}
|
|
411
|
-
if (!worktree.tempRoot || !worktree.tempName) return refuse("package creation metadata is missing");
|
|
412
|
-
|
|
413
|
-
const expectedRoot = resolve(tmpdir());
|
|
414
|
-
const path = resolve(worktree.path);
|
|
415
|
-
if (resolve(worktree.tempRoot) !== expectedRoot) return refuse("temporary root is not the package temp directory");
|
|
416
|
-
if (dirname(path) !== expectedRoot || worktree.path !== join(expectedRoot, worktree.tempName)) {
|
|
417
|
-
return refuse("path is not the exact package-created worktree path");
|
|
418
|
-
}
|
|
419
|
-
if (basename(path) !== worktree.tempName) return refuse("path basename does not match package creation metadata");
|
|
420
|
-
if (!/^pi-agent-[A-Za-z0-9._-]+-[0-9a-f]{8}$/.test(worktree.tempName)) {
|
|
421
|
-
return refuse("temporary name is not package-generated");
|
|
422
|
-
}
|
|
423
|
-
if (typeof worktree.branch !== "string" || !worktree.branch.startsWith("pi-agent-")) {
|
|
424
|
-
return refuse("branch is not package-generated");
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const workPathRelative = relative(path, resolve(worktree.workPath));
|
|
428
|
-
if (isAbsolute(workPathRelative) || workPathRelative === ".." || workPathRelative.startsWith(`..${sep}`)) {
|
|
429
|
-
return refuse("work path escapes the worktree");
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const entry = inspectDirectoryEntry(path);
|
|
433
|
-
const entryFailure = directoryEntryFailure(path, entry);
|
|
434
|
-
if (entryFailure) return { verified: false, reason: entryFailure };
|
|
435
|
-
return { verified: true };
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function inspectWorktreeRegistration(cwd: string, worktreePath: string): WorktreeRegistration {
|
|
220
|
+
/**
|
|
221
|
+
* Force-remove a worktree.
|
|
222
|
+
*/
|
|
223
|
+
function removeWorktree(cwd: string, worktreePath: string): void {
|
|
439
224
|
try {
|
|
440
|
-
|
|
225
|
+
execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
|
|
441
226
|
cwd,
|
|
442
227
|
stdio: "pipe",
|
|
443
|
-
timeout:
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
line.startsWith("worktree ") && resolve(line.slice("worktree ".length).trim()) === expectedPath,
|
|
448
|
-
);
|
|
449
|
-
return { verified: true, registered };
|
|
450
|
-
} catch (error: unknown) {
|
|
451
|
-
return { verified: false, registered: false, error: errorMessage(error) };
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async function inspectWorktreeRegistrationAsync(cwd: string, worktreePath: string): Promise<WorktreeRegistration> {
|
|
456
|
-
try {
|
|
457
|
-
const output = (await execFileAsync("git", ["worktree", "list", "--porcelain"], cwd, 5000)).toString();
|
|
458
|
-
const expectedPath = resolve(worktreePath);
|
|
459
|
-
const registered = output.split(/\r?\n/).some((line) =>
|
|
460
|
-
line.startsWith("worktree ") && resolve(line.slice("worktree ".length).trim()) === expectedPath,
|
|
461
|
-
);
|
|
462
|
-
return { verified: true, registered };
|
|
463
|
-
} catch (error: unknown) {
|
|
464
|
-
return { verified: false, registered: false, error: errorMessage(error) };
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function pruneWorktreeRegistration(cwd: string): { succeeded: boolean; error?: string } {
|
|
469
|
-
try {
|
|
470
|
-
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
471
|
-
return { succeeded: true };
|
|
472
|
-
} catch (error: unknown) {
|
|
473
|
-
return { succeeded: false, error: errorMessage(error) };
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
async function pruneWorktreeRegistrationAsync(cwd: string): Promise<{ succeeded: boolean; error?: string }> {
|
|
478
|
-
try {
|
|
479
|
-
await execFileAsync("git", ["worktree", "prune"], cwd, 5000);
|
|
480
|
-
return { succeeded: true };
|
|
481
|
-
} catch (error: unknown) {
|
|
482
|
-
return { succeeded: false, error: errorMessage(error) };
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function removeWorktree(cwd: string, worktree: WorktreeInfo): WorktreeRemovalResult {
|
|
487
|
-
const path = worktree.path;
|
|
488
|
-
let pathEntry = inspectDirectoryEntry(path);
|
|
489
|
-
const wasPresent = pathEntry.present;
|
|
490
|
-
let pathSafetyFailure = directoryEntryFailure(path, pathEntry);
|
|
491
|
-
let gitRemovalSucceeded = false;
|
|
492
|
-
let gitRemovalError: string | undefined;
|
|
493
|
-
|
|
494
|
-
if (pathSafetyFailure) {
|
|
495
|
-
gitRemovalError = `refusing Git removal: ${pathSafetyFailure}`;
|
|
496
|
-
} else {
|
|
497
|
-
try {
|
|
498
|
-
execFileSync("git", ["worktree", "remove", "--force", path], { cwd, stdio: "pipe", timeout: 10000 });
|
|
499
|
-
gitRemovalSucceeded = true;
|
|
500
|
-
} catch (error: unknown) {
|
|
501
|
-
gitRemovalError = errorMessage(error);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
let filesystemFallbackAttempted = false;
|
|
506
|
-
let filesystemFallbackSucceeded: boolean | undefined;
|
|
507
|
-
let filesystemFallbackError: string | undefined;
|
|
508
|
-
let pruneAttempted = false;
|
|
509
|
-
let pruneSucceeded: boolean | undefined;
|
|
510
|
-
let pruneError: string | undefined;
|
|
511
|
-
let registration = inspectWorktreeRegistration(cwd, path);
|
|
512
|
-
|
|
513
|
-
pathEntry = inspectDirectoryEntry(path);
|
|
514
|
-
pathSafetyFailure ??= directoryEntryFailure(path, pathEntry);
|
|
515
|
-
const needsFallback =
|
|
516
|
-
!gitRemovalSucceeded ||
|
|
517
|
-
!pathEntry.verified ||
|
|
518
|
-
pathEntry.present ||
|
|
519
|
-
!registration.verified ||
|
|
520
|
-
registration.registered ||
|
|
521
|
-
pathSafetyFailure !== undefined;
|
|
522
|
-
if (needsFallback) {
|
|
523
|
-
const packageWorktree = isPackageCreatedWorktree(worktree);
|
|
524
|
-
if (packageWorktree.verified) {
|
|
525
|
-
filesystemFallbackAttempted = true;
|
|
526
|
-
const fallbackBefore = inspectDirectoryEntry(path);
|
|
527
|
-
const fallbackBeforeFailure = directoryEntryFailure(path, fallbackBefore);
|
|
528
|
-
if (fallbackBeforeFailure) {
|
|
529
|
-
filesystemFallbackSucceeded = false;
|
|
530
|
-
filesystemFallbackError = fallbackBeforeFailure;
|
|
531
|
-
pathSafetyFailure ??= fallbackBeforeFailure;
|
|
532
|
-
} else {
|
|
533
|
-
try {
|
|
534
|
-
rmSync(path, { recursive: true, force: true });
|
|
535
|
-
const fallbackAfter = inspectDirectoryEntry(path);
|
|
536
|
-
const fallbackAfterFailure = directoryEntryFailure(path, fallbackAfter);
|
|
537
|
-
if (fallbackAfterFailure) pathSafetyFailure ??= fallbackAfterFailure;
|
|
538
|
-
if (!fallbackAfter.verified) {
|
|
539
|
-
filesystemFallbackSucceeded = false;
|
|
540
|
-
filesystemFallbackError = fallbackAfterFailure ?? `path ${path} could not be verified after filesystem fallback`;
|
|
541
|
-
} else if (fallbackAfter.present) {
|
|
542
|
-
filesystemFallbackSucceeded = false;
|
|
543
|
-
filesystemFallbackError = `path ${path} remained ${directoryEntryDescription(fallbackAfter)} after filesystem fallback`;
|
|
544
|
-
} else {
|
|
545
|
-
filesystemFallbackSucceeded = true;
|
|
546
|
-
}
|
|
547
|
-
} catch (error: unknown) {
|
|
548
|
-
filesystemFallbackSucceeded = false;
|
|
549
|
-
filesystemFallbackError = errorMessage(error);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
} else {
|
|
553
|
-
filesystemFallbackError = `direct filesystem fallback refused: not a verified package-created worktree (${packageWorktree.reason ?? "path validation failed"})`;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
pruneAttempted = true;
|
|
557
|
-
const prune = pruneWorktreeRegistration(cwd);
|
|
558
|
-
pruneSucceeded = prune.succeeded;
|
|
559
|
-
pruneError = prune.error;
|
|
560
|
-
registration = inspectWorktreeRegistration(cwd, path);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
pathEntry = inspectDirectoryEntry(path);
|
|
564
|
-
pathSafetyFailure ??= directoryEntryFailure(path, pathEntry);
|
|
565
|
-
const directoryAbsent = pathEntry.verified && !pathEntry.present;
|
|
566
|
-
const registrationAbsent = registration.verified && !registration.registered;
|
|
567
|
-
const removalMethodSucceeded = pathSafetyFailure === undefined &&
|
|
568
|
-
(gitRemovalSucceeded || filesystemFallbackSucceeded === true || (!wasPresent && pathEntry.verified));
|
|
569
|
-
const cleanupSucceeded = removalMethodSucceeded && directoryAbsent && registrationAbsent;
|
|
570
|
-
|
|
571
|
-
if (cleanupSucceeded) {
|
|
572
|
-
return {
|
|
573
|
-
cleanupSucceeded: true,
|
|
574
|
-
gitRemovalSucceeded,
|
|
575
|
-
filesystemFallbackAttempted,
|
|
576
|
-
...(filesystemFallbackSucceeded === undefined ? {} : { filesystemFallbackSucceeded }),
|
|
577
|
-
...(registration.verified ? { registrationCleanupVerified: !registration.registered } : {}),
|
|
578
|
-
pruneAttempted,
|
|
579
|
-
...(pruneSucceeded === undefined ? {} : { pruneSucceeded }),
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
return removalFailure(
|
|
584
|
-
path,
|
|
585
|
-
gitRemovalSucceeded,
|
|
586
|
-
gitRemovalError,
|
|
587
|
-
filesystemFallbackAttempted,
|
|
588
|
-
filesystemFallbackSucceeded,
|
|
589
|
-
filesystemFallbackError,
|
|
590
|
-
pruneAttempted,
|
|
591
|
-
pruneSucceeded,
|
|
592
|
-
pruneError,
|
|
593
|
-
pathEntry,
|
|
594
|
-
pathSafetyFailure,
|
|
595
|
-
registration,
|
|
596
|
-
cwd,
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
async function removeWorktreeAsync(cwd: string, worktree: WorktreeInfo): Promise<WorktreeRemovalResult> {
|
|
601
|
-
const path = worktree.path;
|
|
602
|
-
let pathEntry = inspectDirectoryEntry(path);
|
|
603
|
-
const wasPresent = pathEntry.present;
|
|
604
|
-
let pathSafetyFailure = directoryEntryFailure(path, pathEntry);
|
|
605
|
-
let gitRemovalSucceeded = false;
|
|
606
|
-
let gitRemovalError: string | undefined;
|
|
607
|
-
|
|
608
|
-
if (pathSafetyFailure) {
|
|
609
|
-
gitRemovalError = `refusing Git removal: ${pathSafetyFailure}`;
|
|
610
|
-
} else {
|
|
228
|
+
timeout: 10000,
|
|
229
|
+
});
|
|
230
|
+
} catch {
|
|
231
|
+
// If git worktree remove fails, try pruning
|
|
611
232
|
try {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
} catch (error: unknown) {
|
|
615
|
-
gitRemovalError = errorMessage(error);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
let filesystemFallbackAttempted = false;
|
|
620
|
-
let filesystemFallbackSucceeded: boolean | undefined;
|
|
621
|
-
let filesystemFallbackError: string | undefined;
|
|
622
|
-
let pruneAttempted = false;
|
|
623
|
-
let pruneSucceeded: boolean | undefined;
|
|
624
|
-
let pruneError: string | undefined;
|
|
625
|
-
let registration = await inspectWorktreeRegistrationAsync(cwd, path);
|
|
626
|
-
|
|
627
|
-
pathEntry = inspectDirectoryEntry(path);
|
|
628
|
-
pathSafetyFailure ??= directoryEntryFailure(path, pathEntry);
|
|
629
|
-
const needsFallback =
|
|
630
|
-
!gitRemovalSucceeded ||
|
|
631
|
-
!pathEntry.verified ||
|
|
632
|
-
pathEntry.present ||
|
|
633
|
-
!registration.verified ||
|
|
634
|
-
registration.registered ||
|
|
635
|
-
pathSafetyFailure !== undefined;
|
|
636
|
-
if (needsFallback) {
|
|
637
|
-
const packageWorktree = isPackageCreatedWorktree(worktree);
|
|
638
|
-
if (packageWorktree.verified) {
|
|
639
|
-
filesystemFallbackAttempted = true;
|
|
640
|
-
const fallbackBefore = inspectDirectoryEntry(path);
|
|
641
|
-
const fallbackBeforeFailure = directoryEntryFailure(path, fallbackBefore);
|
|
642
|
-
if (fallbackBeforeFailure) {
|
|
643
|
-
filesystemFallbackSucceeded = false;
|
|
644
|
-
filesystemFallbackError = fallbackBeforeFailure;
|
|
645
|
-
pathSafetyFailure ??= fallbackBeforeFailure;
|
|
646
|
-
} else {
|
|
647
|
-
try {
|
|
648
|
-
await rm(path, { recursive: true, force: true });
|
|
649
|
-
const fallbackAfter = inspectDirectoryEntry(path);
|
|
650
|
-
const fallbackAfterFailure = directoryEntryFailure(path, fallbackAfter);
|
|
651
|
-
if (fallbackAfterFailure) pathSafetyFailure ??= fallbackAfterFailure;
|
|
652
|
-
if (!fallbackAfter.verified) {
|
|
653
|
-
filesystemFallbackSucceeded = false;
|
|
654
|
-
filesystemFallbackError = fallbackAfterFailure ?? `path ${path} could not be verified after filesystem fallback`;
|
|
655
|
-
} else if (fallbackAfter.present) {
|
|
656
|
-
filesystemFallbackSucceeded = false;
|
|
657
|
-
filesystemFallbackError = `path ${path} remained ${directoryEntryDescription(fallbackAfter)} after filesystem fallback`;
|
|
658
|
-
} else {
|
|
659
|
-
filesystemFallbackSucceeded = true;
|
|
660
|
-
}
|
|
661
|
-
} catch (error: unknown) {
|
|
662
|
-
filesystemFallbackSucceeded = false;
|
|
663
|
-
filesystemFallbackError = errorMessage(error);
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
} else {
|
|
667
|
-
filesystemFallbackError = `direct filesystem fallback refused: not a verified package-created worktree (${packageWorktree.reason ?? "path validation failed"})`;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
pruneAttempted = true;
|
|
671
|
-
const prune = await pruneWorktreeRegistrationAsync(cwd);
|
|
672
|
-
pruneSucceeded = prune.succeeded;
|
|
673
|
-
pruneError = prune.error;
|
|
674
|
-
registration = await inspectWorktreeRegistrationAsync(cwd, path);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
pathEntry = inspectDirectoryEntry(path);
|
|
678
|
-
pathSafetyFailure ??= directoryEntryFailure(path, pathEntry);
|
|
679
|
-
const directoryAbsent = pathEntry.verified && !pathEntry.present;
|
|
680
|
-
const registrationAbsent = registration.verified && !registration.registered;
|
|
681
|
-
const removalMethodSucceeded = pathSafetyFailure === undefined &&
|
|
682
|
-
(gitRemovalSucceeded || filesystemFallbackSucceeded === true || (!wasPresent && pathEntry.verified));
|
|
683
|
-
const cleanupSucceeded = removalMethodSucceeded && directoryAbsent && registrationAbsent;
|
|
684
|
-
|
|
685
|
-
if (cleanupSucceeded) {
|
|
686
|
-
return {
|
|
687
|
-
cleanupSucceeded: true,
|
|
688
|
-
gitRemovalSucceeded,
|
|
689
|
-
filesystemFallbackAttempted,
|
|
690
|
-
...(filesystemFallbackSucceeded === undefined ? {} : { filesystemFallbackSucceeded }),
|
|
691
|
-
...(registration.verified ? { registrationCleanupVerified: !registration.registered } : {}),
|
|
692
|
-
pruneAttempted,
|
|
693
|
-
...(pruneSucceeded === undefined ? {} : { pruneSucceeded }),
|
|
694
|
-
};
|
|
233
|
+
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
234
|
+
} catch { /* ignore */ }
|
|
695
235
|
}
|
|
696
|
-
|
|
697
|
-
return removalFailure(
|
|
698
|
-
path,
|
|
699
|
-
gitRemovalSucceeded,
|
|
700
|
-
gitRemovalError,
|
|
701
|
-
filesystemFallbackAttempted,
|
|
702
|
-
filesystemFallbackSucceeded,
|
|
703
|
-
filesystemFallbackError,
|
|
704
|
-
pruneAttempted,
|
|
705
|
-
pruneSucceeded,
|
|
706
|
-
pruneError,
|
|
707
|
-
pathEntry,
|
|
708
|
-
pathSafetyFailure,
|
|
709
|
-
registration,
|
|
710
|
-
cwd,
|
|
711
|
-
);
|
|
712
236
|
}
|
|
713
237
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
gitRemovalError: string | undefined,
|
|
718
|
-
filesystemFallbackAttempted: boolean,
|
|
719
|
-
filesystemFallbackSucceeded: boolean | undefined,
|
|
720
|
-
filesystemFallbackError: string | undefined,
|
|
721
|
-
pruneAttempted: boolean,
|
|
722
|
-
pruneSucceeded: boolean | undefined,
|
|
723
|
-
pruneError: string | undefined,
|
|
724
|
-
pathEntry: DirectoryEntryState,
|
|
725
|
-
pathSafetyFailure: string | undefined,
|
|
726
|
-
registration: WorktreeRegistration,
|
|
727
|
-
cwd: string,
|
|
728
|
-
): WorktreeRemovalResult {
|
|
729
|
-
const directoryAbsent = pathEntry.verified && !pathEntry.present;
|
|
730
|
-
const details = [
|
|
731
|
-
gitRemovalSucceeded ? "git removal completed" : `git removal failed${gitRemovalError ? `: ${gitRemovalError}` : ""}`,
|
|
732
|
-
filesystemFallbackAttempted
|
|
733
|
-
? filesystemFallbackSucceeded ? "filesystem fallback removed the directory" : `filesystem fallback failed${filesystemFallbackError ? `: ${filesystemFallbackError}` : ""}`
|
|
734
|
-
: `filesystem fallback not used${filesystemFallbackError ? ` (${filesystemFallbackError})` : ""}`,
|
|
735
|
-
pruneAttempted ? pruneSucceeded ? "git worktree prune completed" : `git worktree prune failed${pruneError ? `: ${pruneError}` : ""}` : "git worktree prune not required",
|
|
736
|
-
`directory ${directoryAbsent ? "absent" : "still present"}`,
|
|
737
|
-
`path entry ${directoryEntryDescription(pathEntry)}`,
|
|
738
|
-
registration.verified
|
|
739
|
-
? `registration ${registration.registered ? "still present" : "removed"}`
|
|
740
|
-
: `registration could not be verified${registration.error ? `: ${registration.error}` : ""}`,
|
|
741
|
-
...(pathSafetyFailure ? [`path safety check failed: ${pathSafetyFailure}`] : []),
|
|
742
|
-
];
|
|
743
|
-
return {
|
|
744
|
-
cleanupSucceeded: false,
|
|
745
|
-
cleanupDiagnostic: `Worktree cleanup failed for ${path}: ${details.join("; ")}`.slice(0, WORKTREE_DIAGNOSTIC_LIMIT),
|
|
746
|
-
recoveryCommands: recoveryCommands(cwd, path),
|
|
747
|
-
gitRemovalSucceeded,
|
|
748
|
-
filesystemFallbackAttempted,
|
|
749
|
-
...(filesystemFallbackSucceeded === undefined ? {} : { filesystemFallbackSucceeded }),
|
|
750
|
-
...(registration.verified ? { registrationCleanupVerified: !registration.registered } : {}),
|
|
751
|
-
pruneAttempted,
|
|
752
|
-
...(pruneSucceeded === undefined ? {} : { pruneSucceeded }),
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/** Prune any orphaned worktrees (crash recovery). */
|
|
238
|
+
/**
|
|
239
|
+
* Prune any orphaned worktrees (crash recovery).
|
|
240
|
+
*/
|
|
757
241
|
export function pruneWorktrees(cwd: string): void {
|
|
758
242
|
try {
|
|
759
243
|
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
760
|
-
} catch {
|
|
761
|
-
// Crash recovery is best effort; cleanup callers retain their own failure snapshot.
|
|
762
|
-
}
|
|
244
|
+
} catch { /* ignore */ }
|
|
763
245
|
}
|
|
764
246
|
|
|
765
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* Async crash recovery counterpart.
|
|
249
|
+
*/
|
|
766
250
|
export async function pruneWorktreesAsync(cwd: string): Promise<void> {
|
|
767
|
-
|
|
768
|
-
await execFileAsync("git", ["worktree", "prune"], cwd, 5000);
|
|
769
|
-
} catch {
|
|
770
|
-
// Crash recovery is best effort; cleanup callers retain their own failure snapshot.
|
|
771
|
-
}
|
|
251
|
+
pruneWorktrees(cwd);
|
|
772
252
|
}
|