pi-jar 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -18
- package/docs/WORKFLOWS.md +32 -10
- package/extensions/index.ts +74 -13
- package/package.json +1 -1
- package/src/activity-view.ts +12 -5
- package/src/ask-tool.ts +12 -7
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +826 -124
- package/src/democracy.ts +192 -0
- package/src/discussion.ts +153 -0
- package/src/footer.ts +4 -3
- package/src/goal-loop.ts +8 -1
- package/src/model-roles.ts +58 -29
- package/src/roles-ui.ts +18 -10
- package/src/settings-ui.ts +41 -15
- package/src/settings.ts +9 -1
- package/src/status.ts +2 -1
- package/src/task-tool.ts +33 -18
- package/src/tasks.ts +7 -7
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readlinkSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmdirSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
symlinkSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
type Stats
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { MAX_TRACKED_BYTES, type ChangeTracker } from "./changes.ts";
|
|
24
|
+
|
|
25
|
+
/** Present only in writable delegated children. The child extension treats this as its filesystem boundary. */
|
|
26
|
+
export const CHILD_WORKTREE_ENV = "PI_JAR_WORKTREE_ROOT";
|
|
27
|
+
const MAX_GIT_OUTPUT = 64 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
export interface DelegateWorktree {
|
|
30
|
+
/** Git repository root in the parent workspace. */
|
|
31
|
+
repoRoot: string;
|
|
32
|
+
/** Disposable worktree root. */
|
|
33
|
+
root: string;
|
|
34
|
+
/** Child cwd matching the parent's relative cwd inside the repository. */
|
|
35
|
+
cwd: string;
|
|
36
|
+
/** Snapshot commit that exactly represents the parent workspace when delegation started. */
|
|
37
|
+
baseline: string;
|
|
38
|
+
/** Temporary directory containing the worktree. */
|
|
39
|
+
tempRoot: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const safeGitArgs = (cwd: string, args: string[]) => ["-C", cwd, "--literal-pathspecs", "-c", "core.hooksPath=/dev/null",
|
|
43
|
+
"-c", "core.fsmonitor=false", ...args];
|
|
44
|
+
|
|
45
|
+
const gitText = (cwd: string, args: string[], input?: string | Buffer): string =>
|
|
46
|
+
execFileSync("git", safeGitArgs(cwd, args), {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
...(input === undefined ? {} : { input }),
|
|
49
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const gitBuffer = (cwd: string, args: string[]): Buffer =>
|
|
54
|
+
execFileSync("git", safeGitArgs(cwd, args), {
|
|
55
|
+
encoding: null,
|
|
56
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const nulList = (value: string): string[] => value.split("\0").filter(Boolean);
|
|
61
|
+
|
|
62
|
+
const inside = (root: string, candidate: string): boolean => {
|
|
63
|
+
const rel = relative(root, candidate);
|
|
64
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Normalize Pi tool path spellings before applying the filesystem boundary. */
|
|
68
|
+
export function normalizeWorkspacePath(cwd: string, value: string): string | null {
|
|
69
|
+
try {
|
|
70
|
+
let path = value.replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
|
|
71
|
+
if (path.startsWith("@")) path = path.slice(1);
|
|
72
|
+
if (process.platform === "win32" && !path.includes("\\") && !path.startsWith("//")) {
|
|
73
|
+
const drive = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
74
|
+
if (drive) path = `${drive[1]!.toUpperCase()}:\\${drive[2]?.replaceAll("/", "\\") ?? ""}`;
|
|
75
|
+
}
|
|
76
|
+
// Match Pi exactly: single-slash file: spellings are literal relative filenames.
|
|
77
|
+
if (path.startsWith("file://")) path = fileURLToPath(path);
|
|
78
|
+
if (path === "~") path = homedir();
|
|
79
|
+
else if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) path = join(homedir(), path.slice(2));
|
|
80
|
+
else if (path.startsWith("~")) return null;
|
|
81
|
+
if (!path || path.includes("\0")) return null;
|
|
82
|
+
return resolve(cwd, path);
|
|
83
|
+
} catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const statIfPresent = (path: string): Stats | null => {
|
|
87
|
+
try { return lstatSync(path); }
|
|
88
|
+
catch (error) {
|
|
89
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const hasGitMetadata = (root: string, path: string) =>
|
|
95
|
+
relative(root, path).split(sep).some(part => part.toLowerCase() === ".git");
|
|
96
|
+
|
|
97
|
+
// Finalization disallows ancestor symlinks entirely, even aliases back into the tree.
|
|
98
|
+
const safeFilePath = (root: string, rel: string): boolean => {
|
|
99
|
+
const target = resolve(root, rel);
|
|
100
|
+
if (!rel || isAbsolute(rel) || !inside(root, target) || target === root || hasGitMetadata(root, target)) return false;
|
|
101
|
+
try {
|
|
102
|
+
if (lstatSync(root).isSymbolicLink() || realpathSync(root) !== resolve(root)) return false;
|
|
103
|
+
let probe = dirname(target);
|
|
104
|
+
while (probe !== root) {
|
|
105
|
+
const stat = statIfPresent(probe);
|
|
106
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) return false;
|
|
107
|
+
probe = dirname(probe);
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
} catch { return false; }
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Resolve existing and dangling symlinks, and deny Git administrative paths. */
|
|
114
|
+
export function workspacePathAllowed(root: string, cwd: string, value: string, readFallbacks = false): boolean {
|
|
115
|
+
if (readFallbacks) {
|
|
116
|
+
const path = normalizeWorkspacePath(cwd, value);
|
|
117
|
+
if (!path) return false;
|
|
118
|
+
// Pi read tries these alternate spellings after a missing exact path. Check every possible
|
|
119
|
+
// target, including dangling links, rather than guarding a spelling the reader won't use.
|
|
120
|
+
const nfd = path.normalize("NFD");
|
|
121
|
+
const variants = new Set([path, path.replace(/ (AM|PM)\./gi, "\u202f$1."), nfd,
|
|
122
|
+
path.replace(/'/g, "\u2019"), nfd.replace(/'/g, "\u2019")]);
|
|
123
|
+
return [...variants].every((variant) => canonicalWorkspacePathAllowed(root, variant));
|
|
124
|
+
}
|
|
125
|
+
const path = normalizeWorkspacePath(cwd, value);
|
|
126
|
+
return path !== null && canonicalWorkspacePathAllowed(root, path);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function canonicalWorkspacePathAllowed(root: string, lexical: string): boolean {
|
|
130
|
+
let physicalRoot: string;
|
|
131
|
+
try { physicalRoot = realpathSync(root); } catch { return false; }
|
|
132
|
+
// macOS /var -> /private/var and other trusted root aliases are equivalent.
|
|
133
|
+
if (inside(resolve(root), lexical)) lexical = resolve(physicalRoot, relative(resolve(root), lexical));
|
|
134
|
+
if (!inside(physicalRoot, lexical) || hasGitMetadata(physicalRoot, lexical)) return false;
|
|
135
|
+
try {
|
|
136
|
+
let probe = lexical;
|
|
137
|
+
while (!statIfPresent(probe)) {
|
|
138
|
+
const parent = dirname(probe);
|
|
139
|
+
if (parent === probe) return false;
|
|
140
|
+
probe = parent;
|
|
141
|
+
}
|
|
142
|
+
const physical = realpathSync(probe);
|
|
143
|
+
return inside(physicalRoot, physical) && !hasGitMetadata(physicalRoot, physical);
|
|
144
|
+
} catch { return false; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const copyInto = (fromRoot: string, toRoot: string, rel: string): void => {
|
|
148
|
+
const from = resolve(fromRoot, rel);
|
|
149
|
+
const to = resolve(toRoot, rel);
|
|
150
|
+
if (!safeFilePath(fromRoot, rel) || !safeFilePath(toRoot, rel)) throw new Error("unsafe worktree path: " + rel);
|
|
151
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
152
|
+
if (lstatSync(from).isSymbolicLink()) symlinkSync(readlinkSync(from), to);
|
|
153
|
+
else cpSync(from, to, { force: true, dereference: false });
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create a detached disposable worktree and commit a private baseline snapshot containing the
|
|
158
|
+
* parent's tracked modifications plus non-ignored untracked files. The parent index/branch is not
|
|
159
|
+
* touched. That baseline lets multiple children run against the exact same starting filesystem.
|
|
160
|
+
*/
|
|
161
|
+
export function createDelegateWorktree(cwd: string): DelegateWorktree {
|
|
162
|
+
const requested = realpathSync(cwd);
|
|
163
|
+
const repoRoot = realpathSync(gitText(requested, ["rev-parse", "--show-toplevel"]).trim());
|
|
164
|
+
if (!inside(repoRoot, requested)) throw new Error("working directory is outside the git repository");
|
|
165
|
+
const relCwd = relative(repoRoot, requested);
|
|
166
|
+
const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), "pi-jar-worktree-")));
|
|
167
|
+
const root = join(tempRoot, "workspace");
|
|
168
|
+
let added = false;
|
|
169
|
+
try {
|
|
170
|
+
// Never checkout/add: they execute clean/smudge filters and alter byte baselines.
|
|
171
|
+
gitText(repoRoot, ["worktree", "add", "--no-checkout", "--detach", root, "HEAD"]);
|
|
172
|
+
added = true;
|
|
173
|
+
gitText(root, ["read-tree", "--empty"]);
|
|
174
|
+
const paths = new Set(nulList(gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"])));
|
|
175
|
+
for (const rel of paths) {
|
|
176
|
+
if (!safeFilePath(repoRoot, rel)) throw new Error("unsafe snapshot path: " + rel);
|
|
177
|
+
const source = resolve(repoRoot, rel);
|
|
178
|
+
const stat = statIfPresent(source);
|
|
179
|
+
if (!stat) continue;
|
|
180
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("unsupported snapshot file: " + rel);
|
|
181
|
+
const bytes = stat.isSymbolicLink() ? Buffer.from(readlinkSync(source)) : readFileSync(source);
|
|
182
|
+
const oid = gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes).trim();
|
|
183
|
+
const mode = stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644";
|
|
184
|
+
gitText(root, ["update-index", "--add", "--cacheinfo", mode, oid, rel]);
|
|
185
|
+
copyInto(repoRoot, root, rel);
|
|
186
|
+
}
|
|
187
|
+
const tree = gitText(root, ["write-tree"]).trim();
|
|
188
|
+
const baseline = gitText(root, ["-c", "user.name=pi-jar", "-c", "user.email=pi-jar@local",
|
|
189
|
+
"commit-tree", "--no-gpg-sign", tree, "-p", "HEAD", "-m", "pi-jar delegate baseline"]).trim();
|
|
190
|
+
gitText(root, ["update-ref", "HEAD", baseline]);
|
|
191
|
+
const childCwd = relCwd ? resolve(root, relCwd) : root;
|
|
192
|
+
if (!workspacePathAllowed(root, root, childCwd)) throw new Error("unsafe child working directory");
|
|
193
|
+
if (!existsSync(childCwd)) mkdirSync(childCwd, { recursive: true });
|
|
194
|
+
return { repoRoot, root, cwd: childCwd, baseline, tempRoot };
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (added) {
|
|
197
|
+
try { gitText(repoRoot, ["worktree", "remove", "--force", root]); } catch { /* best effort */ }
|
|
198
|
+
}
|
|
199
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Files whose final worktree state differs from the snapshot, including new untracked files. */
|
|
205
|
+
export function worktreeChangedFiles(worktree: DelegateWorktree): string[] {
|
|
206
|
+
const baselinePaths = nulList(gitText(worktree.root, ["ls-tree", "-r", "--name-only", "-z", worktree.baseline]));
|
|
207
|
+
const currentPaths = nulList(gitText(worktree.root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]));
|
|
208
|
+
return [...new Set([...baselinePaths, ...currentPaths])]
|
|
209
|
+
.filter(rel => !parentMatchesBaseline(worktree, worktree.root, rel)).sort();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type BaselineEntry = { mode: string; content: Buffer } | null;
|
|
213
|
+
|
|
214
|
+
const baselineEntry = (worktree: DelegateWorktree, rel: string): BaselineEntry => {
|
|
215
|
+
const line = gitText(worktree.root, ["ls-tree", "-z", worktree.baseline, "--", rel]);
|
|
216
|
+
if (!line || line.slice(line.indexOf("\t") + 1, -1) !== rel) return null;
|
|
217
|
+
const [mode, type, oid] = line.slice(0, line.indexOf("\t")).split(" ");
|
|
218
|
+
if (type !== "blob" || !mode || !oid) throw new Error("unsupported baseline entry: " + rel);
|
|
219
|
+
return { mode, content: gitBuffer(worktree.root, ["cat-file", "blob", oid]) };
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const parentMatchesBaseline = (worktree: DelegateWorktree, parentRoot: string, rel: string): boolean => {
|
|
223
|
+
const target = resolve(parentRoot, rel);
|
|
224
|
+
if (!safeFilePath(parentRoot, rel)) return false;
|
|
225
|
+
const baseline = baselineEntry(worktree, rel);
|
|
226
|
+
const stat = statIfPresent(target);
|
|
227
|
+
if (!baseline) return !stat;
|
|
228
|
+
if (!stat) return false;
|
|
229
|
+
if (baseline.mode === "120000") {
|
|
230
|
+
return stat.isSymbolicLink() && Buffer.from(readlinkSync(target)).equals(baseline.content);
|
|
231
|
+
}
|
|
232
|
+
if (!stat.isFile()) return false;
|
|
233
|
+
if (baseline.mode === "100644" || baseline.mode === "100755") {
|
|
234
|
+
const expectedExecutable = baseline.mode === "100755";
|
|
235
|
+
const actualExecutable = (stat.mode & 0o111) !== 0;
|
|
236
|
+
if (expectedExecutable !== actualExecutable) return false;
|
|
237
|
+
}
|
|
238
|
+
return readFileSync(target).equals(baseline.content);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const reviewableBytes = (bytes: Buffer): boolean => bytes.length <= MAX_TRACKED_BYTES
|
|
242
|
+
&& !bytes.includes(0) && Buffer.from(bytes.toString("utf8")).equals(bytes);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Copy a completed child's changed files back to the parent only when every target still equals the
|
|
246
|
+
* baseline snapshot. The parent is therefore never overwritten after another actor changed a file.
|
|
247
|
+
*/
|
|
248
|
+
export function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: string, files: readonly string[],
|
|
249
|
+
tracker?: ChangeTracker): string[] {
|
|
250
|
+
const parentRoot = realpathSync(gitText(parentCwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
251
|
+
if (parentRoot !== worktree.repoRoot) throw new Error("parent repository changed while subagent was running");
|
|
252
|
+
// Review identities follow the caller's cwd spelling (e.g. /var rather than /private/var).
|
|
253
|
+
const reviewRoot = resolve(parentCwd, relative(realpathSync(parentCwd), parentRoot));
|
|
254
|
+
for (const rel of files) {
|
|
255
|
+
if (!safeFilePath(parentRoot, rel) || statIfPresent(resolve(parentRoot, rel))?.isSymbolicLink()) {
|
|
256
|
+
throw new Error("unsafe parent changed path: " + rel);
|
|
257
|
+
}
|
|
258
|
+
if (!parentMatchesBaseline(worktree, parentRoot, rel)) {
|
|
259
|
+
throw new Error(`parent changed since delegation started: ${rel}`);
|
|
260
|
+
}
|
|
261
|
+
const parentStat = statIfPresent(resolve(parentRoot, rel));
|
|
262
|
+
if (parentStat?.isFile() && !reviewableBytes(readFileSync(resolve(parentRoot, rel)))) {
|
|
263
|
+
throw new Error("cannot add changed file to /diff review: " + rel);
|
|
264
|
+
}
|
|
265
|
+
const source = resolve(worktree.root, rel);
|
|
266
|
+
if (!safeFilePath(worktree.root, rel)) throw new Error("unsafe changed path: " + rel);
|
|
267
|
+
if (statIfPresent(source)) {
|
|
268
|
+
const stat = lstatSync(source);
|
|
269
|
+
if (stat.isSymbolicLink()) throw new Error("refusing to apply symlink change: " + rel);
|
|
270
|
+
if (!stat.isFile()) throw new Error("refusing to apply non-file change: " + rel);
|
|
271
|
+
const bytes = readFileSync(source);
|
|
272
|
+
if (!reviewableBytes(bytes)) {
|
|
273
|
+
throw new Error("cannot add changed file to /diff review: " + rel);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (tracker) {
|
|
279
|
+
for (const rel of files) {
|
|
280
|
+
const target = resolve(reviewRoot, rel);
|
|
281
|
+
if (!tracker.capture(target)) throw new Error("cannot add changed file to /diff review: " + rel);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const staged: Array<{ rel: string; target: string; directory: string; replacement?: string; backup?: string }> = [];
|
|
286
|
+
const createdDirectories: string[] = [];
|
|
287
|
+
const applied: typeof staged = [];
|
|
288
|
+
try {
|
|
289
|
+
// Prepare every replacement and rollback backup before modifying any parent file.
|
|
290
|
+
// Permission/disk failures in later files therefore cannot leave earlier files half-applied.
|
|
291
|
+
for (const rel of files) {
|
|
292
|
+
const source = resolve(worktree.root, rel);
|
|
293
|
+
const target = resolve(parentRoot, rel);
|
|
294
|
+
if (!safeFilePath(parentRoot, rel) || !safeFilePath(worktree.root, rel)
|
|
295
|
+
|| statIfPresent(target)?.isSymbolicLink() || statIfPresent(source)?.isSymbolicLink()) {
|
|
296
|
+
throw new Error("unsafe changed path: " + rel);
|
|
297
|
+
}
|
|
298
|
+
const missing: string[] = [];
|
|
299
|
+
for (let path = dirname(target); !statIfPresent(path); path = dirname(path)) missing.push(path);
|
|
300
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
301
|
+
createdDirectories.push(...missing.reverse());
|
|
302
|
+
const directory = mkdtempSync(join(dirname(target), ".pi-jar-apply-"));
|
|
303
|
+
const entry: (typeof staged)[number] = { rel, target, directory };
|
|
304
|
+
staged.push(entry);
|
|
305
|
+
const before = statIfPresent(target);
|
|
306
|
+
if (before) {
|
|
307
|
+
entry.backup = join(directory, "before");
|
|
308
|
+
writeFileSync(entry.backup, readFileSync(target), { mode: before.mode & 0o777 });
|
|
309
|
+
chmodSync(entry.backup, before.mode & 0o777);
|
|
310
|
+
}
|
|
311
|
+
const after = statIfPresent(source);
|
|
312
|
+
if (after) {
|
|
313
|
+
entry.replacement = join(directory, "after");
|
|
314
|
+
writeFileSync(entry.replacement, readFileSync(source), { mode: after.mode & 0o777 });
|
|
315
|
+
chmodSync(entry.replacement, after.mode & 0o777);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const entry of staged) {
|
|
319
|
+
if (!safeFilePath(parentRoot, entry.rel) || !parentMatchesBaseline(worktree, parentRoot, entry.rel)) {
|
|
320
|
+
throw new Error("parent changed while preparing finalization: " + entry.rel);
|
|
321
|
+
}
|
|
322
|
+
if (entry.replacement) renameSync(entry.replacement, entry.target);
|
|
323
|
+
else if (statIfPresent(entry.target)) unlinkSync(entry.target);
|
|
324
|
+
applied.push(entry);
|
|
325
|
+
}
|
|
326
|
+
for (const entry of applied) tracker?.markDirty(resolve(reviewRoot, entry.rel));
|
|
327
|
+
return applied.map((entry) => entry.rel);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const unrestored: string[] = [];
|
|
330
|
+
for (const entry of [...applied].reverse()) {
|
|
331
|
+
try {
|
|
332
|
+
if (!safeFilePath(parentRoot, entry.rel)) throw new Error("unsafe rollback path");
|
|
333
|
+
if (entry.backup) renameSync(entry.backup, entry.target);
|
|
334
|
+
else if (statIfPresent(entry.target)) unlinkSync(entry.target);
|
|
335
|
+
} catch { unrestored.push(entry.rel); tracker?.markDirty(resolve(reviewRoot, entry.rel)); }
|
|
336
|
+
}
|
|
337
|
+
if (unrestored.length) throw new WorktreeApplyError(String(error) + "; rollback failed for: " + unrestored.join(", "), unrestored);
|
|
338
|
+
throw error;
|
|
339
|
+
} finally {
|
|
340
|
+
for (const entry of staged) { try { rmSync(entry.directory, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
341
|
+
for (const directory of createdDirectories.reverse()) { try { rmdirSync(directory); } catch { /* keep non-empty/successfully applied directories */ } }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Exceptional rollback failure: expose any actual parent mutations in the moderator handoff. */
|
|
346
|
+
export class WorktreeApplyError extends Error {
|
|
347
|
+
readonly appliedFiles: string[];
|
|
348
|
+
constructor(message: string, appliedFiles: string[]) { super(message); this.appliedFiles = appliedFiles; }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Remove a disposable worktree and its dangling snapshot commit reference. */
|
|
352
|
+
export function disposeDelegateWorktree(worktree: DelegateWorktree): void {
|
|
353
|
+
try { gitText(worktree.repoRoot, ["worktree", "remove", "--force", worktree.root]); }
|
|
354
|
+
catch {
|
|
355
|
+
rmSync(worktree.tempRoot, { recursive: true, force: true });
|
|
356
|
+
try { gitText(worktree.repoRoot, ["worktree", "prune"]); } catch { /* best effort */ }
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
rmSync(worktree.tempRoot, { recursive: true, force: true });
|
|
360
|
+
}
|