@cr1ms0n/pi-subagent 0.8.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 +352 -0
- package/LICENSE +21 -0
- package/README.md +543 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/docs/COST-ACCOUNTING.md +66 -0
- package/docs/PLAN.md +325 -0
- package/docs/RELEASING.md +32 -0
- package/docs/ROADMAP.md +252 -0
- package/docs/SECURITY.md +85 -0
- package/docs/UI-OVERHAUL.md +186 -0
- package/docs/UX.md +141 -0
- package/extensions/subagent.ts +1 -0
- package/package.json +58 -0
- package/skills/subagent/SKILL.md +103 -0
- package/src/agents.ts +285 -0
- package/src/backend.ts +146 -0
- package/src/backends/claude.ts +384 -0
- package/src/backends/codex.ts +330 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/pi.ts +94 -0
- package/src/btw.ts +34 -0
- package/src/config.ts +254 -0
- package/src/distill.ts +222 -0
- package/src/extension.ts +1527 -0
- package/src/format.ts +365 -0
- package/src/index.ts +60 -0
- package/src/launch.ts +120 -0
- package/src/maintenance.ts +6 -0
- package/src/model-policy.ts +157 -0
- package/src/notifications.ts +106 -0
- package/src/orchestrator.ts +247 -0
- package/src/output.ts +124 -0
- package/src/persistence.ts +334 -0
- package/src/policy.ts +500 -0
- package/src/process-lock.ts +687 -0
- package/src/protocol.ts +290 -0
- package/src/registry.ts +632 -0
- package/src/runner.ts +850 -0
- package/src/schema.ts +166 -0
- package/src/semaphore.ts +123 -0
- package/src/structured.ts +169 -0
- package/src/transcript.ts +360 -0
- package/src/types.ts +197 -0
- package/src/ui.ts +545 -0
- package/src/usage.ts +274 -0
- package/src/worktree.ts +753 -0
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { defaultConfig } from "./config.js";
|
|
6
|
+
|
|
7
|
+
export interface ExecResult { code: number; stdout: string; stderr: string }
|
|
8
|
+
export type ExecFn = (command: string, args: string[], cwd?: string, signal?: AbortSignal) => Promise<ExecResult>;
|
|
9
|
+
|
|
10
|
+
export interface WorktreeHandle {
|
|
11
|
+
cwd: string;
|
|
12
|
+
branch: string;
|
|
13
|
+
baseCwd: string;
|
|
14
|
+
baseCommit: string;
|
|
15
|
+
changed: boolean;
|
|
16
|
+
diffSummary?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Baseline patch (vs baseCommit) seeded from the parent checkout's WIP when
|
|
19
|
+
* `includeWip` was requested. Includes untracked files as intent-to-add diffs
|
|
20
|
+
* after seeding. Used to subtract parent WIP from agent-only reports.
|
|
21
|
+
*/
|
|
22
|
+
wipPatch?: string;
|
|
23
|
+
/** Relative paths of parent untracked files copied into the worktree. */
|
|
24
|
+
wipUntracked?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SweepReport {
|
|
28
|
+
pruned: boolean;
|
|
29
|
+
removed: string[];
|
|
30
|
+
kept: string[];
|
|
31
|
+
/** Patch files written for reclaimed worktrees that held unique work. */
|
|
32
|
+
archived: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GlobalSweepReport extends SweepReport {
|
|
36
|
+
/** Base repos that were swept (resolved from containers + the current checkout). */
|
|
37
|
+
swept: string[];
|
|
38
|
+
/** Containers whose base repo is gone; kept untouched — see sweepAll policy. */
|
|
39
|
+
orphanedContainers: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateWorktreeOptions {
|
|
43
|
+
/** Seed the worktree with the parent checkout's uncommitted WIP (default false). */
|
|
44
|
+
includeWip?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WorktreeDiffResult {
|
|
48
|
+
stat: string;
|
|
49
|
+
patch: string;
|
|
50
|
+
truncated: boolean;
|
|
51
|
+
/** Set when the report may still contain parent WIP because subtraction failed. */
|
|
52
|
+
warning?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface WorktreeApplyResult {
|
|
56
|
+
applied: boolean;
|
|
57
|
+
stat: string;
|
|
58
|
+
/** Set when the applied patch may still contain parent WIP because subtraction failed. */
|
|
59
|
+
warning?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const WIP_PATCH_FILE = "wip.patch";
|
|
63
|
+
const BASE_REPO_FILE = "base-repo";
|
|
64
|
+
const WIP_UNTRACKED_FILE = "wip-untracked.txt";
|
|
65
|
+
const BASE_COMMIT_FILE = "base-commit";
|
|
66
|
+
/** Archived unique work of reclaimed worktrees, per repo container. */
|
|
67
|
+
const PATCHES_DIR = "_patches";
|
|
68
|
+
export const INCLUDES_PARENT_WIP = "[includes parent WIP]";
|
|
69
|
+
|
|
70
|
+
async function defaultExec(command: string, args: string[], cwd?: string, signal?: AbortSignal): Promise<ExecResult> {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
if (signal?.aborted) return reject(new Error("Worktree command aborted"));
|
|
73
|
+
const child = spawn(command, args, { cwd, signal, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
74
|
+
let stdout = "";
|
|
75
|
+
let stderr = "";
|
|
76
|
+
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
77
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
78
|
+
child.once("error", reject);
|
|
79
|
+
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizePatch(patch: string): string {
|
|
84
|
+
return patch.replace(/\r\n/g, "\n").replace(/\n+$/g, "\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Safe git-worktree isolation. Changed means uncommitted changes OR commits beyond base.
|
|
89
|
+
*
|
|
90
|
+
* Worktrees live under a durable root (default ~/.pi/subagent-worktrees/<repo-hash>/),
|
|
91
|
+
* never a purgeable OS tmpdir, so preserved work survives reboots. `sweep()` garbage
|
|
92
|
+
* collects unchanged or expired leftovers from crashes and failed finalizations.
|
|
93
|
+
*/
|
|
94
|
+
export class WorktreeManager {
|
|
95
|
+
constructor(
|
|
96
|
+
private readonly execFn: ExecFn = defaultExec,
|
|
97
|
+
private readonly rootDir: string = defaultConfig.worktreeDir,
|
|
98
|
+
) {}
|
|
99
|
+
|
|
100
|
+
async isGitRepo(cwd: string, signal?: AbortSignal): Promise<boolean> {
|
|
101
|
+
// Spawn rejects (ENOENT) when cwd itself no longer exists — not a repo.
|
|
102
|
+
const result = await this.execFn("git", ["rev-parse", "--is-inside-work-tree"], cwd, signal)
|
|
103
|
+
.catch(() => ({ code: 1, stdout: "", stderr: "" }));
|
|
104
|
+
return result.code === 0 && result.stdout.trim() === "true";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Stable per-repo container so sweep can enumerate all worktrees for one repo. */
|
|
108
|
+
repoRoot(baseCwd: string): string {
|
|
109
|
+
const hash = createHash("sha256").update(path.resolve(baseCwd)).digest("hex").slice(0, 12);
|
|
110
|
+
const name = path.basename(path.resolve(baseCwd)).replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 32) || "repo";
|
|
111
|
+
return path.join(this.rootDir, `${name}-${hash}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private containerOf(cwd: string): string {
|
|
115
|
+
return path.dirname(cwd);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async writeWipArtifacts(cwd: string, wipPatch: string, wipUntracked: string[]): Promise<void> {
|
|
119
|
+
const root = this.containerOf(cwd);
|
|
120
|
+
await fs.writeFile(path.join(root, WIP_PATCH_FILE), wipPatch, "utf8").catch(() => {});
|
|
121
|
+
await fs.writeFile(path.join(root, WIP_UNTRACKED_FILE), `${wipUntracked.join("\n")}${wipUntracked.length ? "\n" : ""}`, "utf8").catch(() => {});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async loadWipArtifacts(cwd: string): Promise<{ wipPatch?: string; wipUntracked?: string[] }> {
|
|
125
|
+
const root = this.containerOf(cwd);
|
|
126
|
+
const patch = await fs.readFile(path.join(root, WIP_PATCH_FILE), "utf8").catch(() => undefined);
|
|
127
|
+
const listRaw = await fs.readFile(path.join(root, WIP_UNTRACKED_FILE), "utf8").catch(() => undefined);
|
|
128
|
+
const wipUntracked = listRaw === undefined
|
|
129
|
+
? undefined
|
|
130
|
+
: listRaw.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
131
|
+
return {
|
|
132
|
+
wipPatch: patch === undefined ? undefined : patch,
|
|
133
|
+
wipUntracked,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async resolveWip(
|
|
138
|
+
worktree: { cwd: string; wipPatch?: string; wipUntracked?: string[] },
|
|
139
|
+
): Promise<{ wipPatch?: string; wipUntracked?: string[] }> {
|
|
140
|
+
if (worktree.wipPatch !== undefined || worktree.wipUntracked !== undefined) {
|
|
141
|
+
return { wipPatch: worktree.wipPatch, wipUntracked: worktree.wipUntracked };
|
|
142
|
+
}
|
|
143
|
+
return this.loadWipArtifacts(worktree.cwd);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async captureParentWip(
|
|
147
|
+
baseCwd: string,
|
|
148
|
+
signal?: AbortSignal,
|
|
149
|
+
): Promise<{ patch: string; untracked: string[] }> {
|
|
150
|
+
const diff = await this.execFn("git", ["diff", "--binary", "HEAD"], baseCwd, signal);
|
|
151
|
+
if (diff.code !== 0) throw new Error(`Unable to capture parent WIP: ${diff.stderr.trim()}`);
|
|
152
|
+
const ls = await this.execFn("git", ["ls-files", "-o", "--exclude-standard"], baseCwd, signal);
|
|
153
|
+
if (ls.code !== 0) throw new Error(`Unable to list untracked files: ${ls.stderr.trim()}`);
|
|
154
|
+
const untracked = ls.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
155
|
+
return { patch: diff.stdout, untracked };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Stream a patch into `git apply` (optionally reverse). */
|
|
159
|
+
private async applyPatchStream(
|
|
160
|
+
cwd: string,
|
|
161
|
+
patch: string,
|
|
162
|
+
options: { reverse?: boolean; check?: boolean; signal?: AbortSignal } = {},
|
|
163
|
+
): Promise<ExecResult> {
|
|
164
|
+
if (!patch.trim()) return { code: 0, stdout: "", stderr: "" };
|
|
165
|
+
const args = ["apply", "--whitespace=nowarn"];
|
|
166
|
+
if (options.reverse) args.push("--reverse");
|
|
167
|
+
if (options.check) args.push("--check");
|
|
168
|
+
return new Promise<ExecResult>((resolve, reject) => {
|
|
169
|
+
const child = spawn("git", args, {
|
|
170
|
+
cwd,
|
|
171
|
+
shell: false,
|
|
172
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
173
|
+
signal: options.signal,
|
|
174
|
+
});
|
|
175
|
+
let stdout = "";
|
|
176
|
+
let stderr = "";
|
|
177
|
+
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
178
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
179
|
+
child.once("error", reject);
|
|
180
|
+
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
181
|
+
child.stdin?.on("error", () => { /* EPIPE when git exits early */ });
|
|
182
|
+
child.stdin?.end(patch);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async seedWipIntoWorktree(
|
|
187
|
+
cwd: string,
|
|
188
|
+
baseCwd: string,
|
|
189
|
+
baseCommit: string,
|
|
190
|
+
parentWip: { patch: string; untracked: string[] },
|
|
191
|
+
signal?: AbortSignal,
|
|
192
|
+
): Promise<{ wipPatch: string; wipUntracked: string[] }> {
|
|
193
|
+
if (parentWip.patch.trim()) {
|
|
194
|
+
const applied = await this.applyPatchStream(cwd, parentWip.patch, { signal });
|
|
195
|
+
if (applied.code !== 0) {
|
|
196
|
+
throw new Error(`Unable to seed parent WIP into worktree: ${applied.stderr.trim() || applied.stdout.trim() || "git apply failed"}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const rel of parentWip.untracked) {
|
|
200
|
+
// Guard against absolute / traversal paths from a hostile listing.
|
|
201
|
+
if (!rel || path.isAbsolute(rel) || rel.split(/[\\/]/).includes("..")) continue;
|
|
202
|
+
const src = path.join(baseCwd, rel);
|
|
203
|
+
const dest = path.join(cwd, rel);
|
|
204
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
205
|
+
await fs.copyFile(src, dest);
|
|
206
|
+
}
|
|
207
|
+
// Snapshot the full baseline (tracked WIP + untracked as intent-to-add) so
|
|
208
|
+
// later subtraction is a single reverse-apply against one stored patch.
|
|
209
|
+
await this.stageUntracked(cwd, signal);
|
|
210
|
+
const snap = await this.execFn("git", ["diff", "--binary", baseCommit], cwd, signal);
|
|
211
|
+
if (snap.code !== 0) throw new Error(`Unable to snapshot seeded WIP: ${snap.stderr.trim()}`);
|
|
212
|
+
return { wipPatch: snap.stdout, wipUntracked: parentWip.untracked };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async create(
|
|
216
|
+
baseCwd: string,
|
|
217
|
+
label = "subagent",
|
|
218
|
+
signal?: AbortSignal,
|
|
219
|
+
options: CreateWorktreeOptions = {},
|
|
220
|
+
): Promise<WorktreeHandle> {
|
|
221
|
+
if (!(await this.isGitRepo(baseCwd, signal))) throw new Error(`${baseCwd} is not a git repository`);
|
|
222
|
+
const head = await this.execFn("git", ["rev-parse", "HEAD"], baseCwd, signal);
|
|
223
|
+
if (head.code !== 0) throw new Error(`Unable to resolve HEAD: ${head.stderr.trim()}`);
|
|
224
|
+
const baseCommit = head.stdout.trim();
|
|
225
|
+
const id = randomUUID().slice(0, 8);
|
|
226
|
+
const safe = label.replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 24) || "task";
|
|
227
|
+
const branch = `pi-subagent/${safe}-${id}`;
|
|
228
|
+
const root = path.join(this.repoRoot(baseCwd), `${safe}-${id}`);
|
|
229
|
+
await fs.mkdir(root, { recursive: true });
|
|
230
|
+
const cwd = path.join(root, "work");
|
|
231
|
+
|
|
232
|
+
// Capture parent WIP before worktree add so concurrent parent edits mid-create
|
|
233
|
+
// cannot partially seed the child.
|
|
234
|
+
let parentWip: { patch: string; untracked: string[] } | undefined;
|
|
235
|
+
if (options.includeWip) {
|
|
236
|
+
parentWip = await this.captureParentWip(baseCwd, signal);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const result = await this.execFn("git", ["worktree", "add", "-b", branch, cwd, baseCommit], baseCwd, signal);
|
|
241
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "git worktree add failed");
|
|
242
|
+
// Markers let sweep() find the owning repo and diff base for orphaned directories.
|
|
243
|
+
await fs.writeFile(path.join(root, BASE_REPO_FILE), `${path.resolve(baseCwd)}\n`, "utf8").catch(() => {});
|
|
244
|
+
await fs.writeFile(path.join(root, BASE_COMMIT_FILE), `${baseCommit}\n`, "utf8").catch(() => {});
|
|
245
|
+
|
|
246
|
+
let wipPatch: string | undefined;
|
|
247
|
+
let wipUntracked: string[] | undefined;
|
|
248
|
+
if (parentWip && (parentWip.patch.trim() || parentWip.untracked.length)) {
|
|
249
|
+
const seeded = await this.seedWipIntoWorktree(cwd, baseCwd, baseCommit, parentWip, signal);
|
|
250
|
+
wipPatch = seeded.wipPatch;
|
|
251
|
+
wipUntracked = seeded.wipUntracked;
|
|
252
|
+
await this.writeWipArtifacts(cwd, wipPatch, wipUntracked);
|
|
253
|
+
}
|
|
254
|
+
return { cwd, branch, baseCwd, baseCommit, changed: false, wipPatch, wipUntracked };
|
|
255
|
+
} catch (error) {
|
|
256
|
+
await this.execFn("git", ["worktree", "remove", "--force", cwd], baseCwd).catch(() => {});
|
|
257
|
+
await this.execFn("git", ["branch", "-D", branch], baseCwd).catch(() => {});
|
|
258
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* True when the worktree has no commits beyond base and its working tree is
|
|
265
|
+
* either empty or bit-for-bit the seeded parent WIP baseline.
|
|
266
|
+
*/
|
|
267
|
+
private async isOnlyWipSeed(handle: WorktreeHandle, signal?: AbortSignal): Promise<boolean> {
|
|
268
|
+
const { wipPatch } = await this.resolveWip(handle);
|
|
269
|
+
if (wipPatch === undefined) return false;
|
|
270
|
+
await this.stageUntracked(handle.cwd, signal);
|
|
271
|
+
const current = await this.execFn("git", ["diff", "--binary", handle.baseCommit], handle.cwd, signal);
|
|
272
|
+
if (current.code !== 0) return false;
|
|
273
|
+
return normalizePatch(current.stdout) === normalizePatch(wipPatch);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async refreshStatus(handle: WorktreeHandle, signal?: AbortSignal): Promise<WorktreeHandle> {
|
|
277
|
+
const status = await this.execFn("git", ["status", "--porcelain"], handle.cwd, signal);
|
|
278
|
+
if (status.code !== 0) throw new Error(`Unable to inspect worktree: ${status.stderr.trim()}`);
|
|
279
|
+
const head = await this.execFn("git", ["rev-parse", "HEAD"], handle.cwd, signal);
|
|
280
|
+
if (head.code !== 0) throw new Error(`Unable to inspect worktree HEAD: ${head.stderr.trim()}`);
|
|
281
|
+
const hasCommits = head.stdout.trim() !== handle.baseCommit;
|
|
282
|
+
const hasWorkingChanges = status.stdout.trim().length > 0;
|
|
283
|
+
let changed = hasCommits || hasWorkingChanges;
|
|
284
|
+
// Seeded-but-untouched WIP is not agent work → treat as unchanged so finalize cleans up.
|
|
285
|
+
if (changed && !hasCommits && (handle.wipPatch !== undefined || (await this.loadWipArtifacts(handle.cwd)).wipPatch !== undefined)) {
|
|
286
|
+
if (await this.isOnlyWipSeed(handle, signal)) changed = false;
|
|
287
|
+
}
|
|
288
|
+
let diffSummary: string | undefined;
|
|
289
|
+
if (changed) {
|
|
290
|
+
const diff = await this.execFn("git", ["diff", "--stat", `${handle.baseCommit}..HEAD`], handle.cwd, signal);
|
|
291
|
+
const working = await this.execFn("git", ["diff", "--stat"], handle.cwd, signal);
|
|
292
|
+
diffSummary = [diff.stdout.trim(), working.stdout.trim(), status.stdout.trim()].filter(Boolean).join("\n");
|
|
293
|
+
}
|
|
294
|
+
return { ...handle, changed, diffSummary };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Preserve any branch with commits or uncommitted work; delete only truly unchanged worktrees. */
|
|
298
|
+
async finalize(handle: WorktreeHandle, signal?: AbortSignal): Promise<WorktreeHandle> {
|
|
299
|
+
const latest = await this.refreshStatus(handle, signal);
|
|
300
|
+
if (latest.changed) return latest;
|
|
301
|
+
const removed = await this.execFn("git", ["worktree", "remove", "--force", latest.cwd], latest.baseCwd);
|
|
302
|
+
if (removed.code !== 0) throw new Error(`Unable to remove unchanged worktree: ${removed.stderr.trim()}`);
|
|
303
|
+
await this.execFn("git", ["branch", "-D", latest.branch], latest.baseCwd).catch(() => {});
|
|
304
|
+
await fs.rm(path.dirname(latest.cwd), { recursive: true, force: true });
|
|
305
|
+
return latest;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Make untracked files visible to `git diff` (intent-to-add). Safe on finished worktrees. */
|
|
309
|
+
private async stageUntracked(cwd: string, signal?: AbortSignal): Promise<void> {
|
|
310
|
+
await this.execFn("git", ["add", "-A", "--intent-to-add"], cwd, signal).catch(() => {});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async currentFullDiff(
|
|
314
|
+
worktree: { cwd: string; baseCommit: string },
|
|
315
|
+
signal?: AbortSignal,
|
|
316
|
+
): Promise<{ stat: string; patch: string }> {
|
|
317
|
+
await this.stageUntracked(worktree.cwd, signal);
|
|
318
|
+
const stat = await this.execFn("git", ["diff", "--stat", worktree.baseCommit], worktree.cwd, signal);
|
|
319
|
+
if (stat.code !== 0) throw new Error(`Unable to diff worktree: ${stat.stderr.trim()}`);
|
|
320
|
+
const patch = await this.execFn("git", ["diff", "--binary", worktree.baseCommit], worktree.cwd, signal);
|
|
321
|
+
if (patch.code !== 0) throw new Error(`Unable to diff worktree: ${patch.stderr.trim()}`);
|
|
322
|
+
return { stat: stat.stdout.trim(), patch: patch.stdout };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Subtract stored parent WIP from the combined worktree delta when reverse
|
|
327
|
+
* application is clean. Returns combined delta + warning otherwise.
|
|
328
|
+
*/
|
|
329
|
+
private async subtractWip(
|
|
330
|
+
worktree: { cwd: string; baseCommit: string; baseCwd?: string },
|
|
331
|
+
combined: { stat: string; patch: string },
|
|
332
|
+
wipPatch: string | undefined,
|
|
333
|
+
signal?: AbortSignal,
|
|
334
|
+
): Promise<{ stat: string; patch: string; clean: boolean }> {
|
|
335
|
+
if (wipPatch === undefined) return { ...combined, clean: true };
|
|
336
|
+
if (!wipPatch.trim()) return { ...combined, clean: true };
|
|
337
|
+
if (!combined.patch.trim()) return { stat: "", patch: "", clean: true };
|
|
338
|
+
if (normalizePatch(combined.patch) === normalizePatch(wipPatch)) {
|
|
339
|
+
return { stat: "", patch: "", clean: true };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Reverse-apply WIP on a throwaway worktree that first receives the combined
|
|
343
|
+
// delta. Success → remaining diff is agent-only. Failure → never invent a
|
|
344
|
+
// partial result; callers report the combined delta with a warning.
|
|
345
|
+
const preferBase = worktree.baseCwd;
|
|
346
|
+
const tmpRoot = await fs.mkdtemp(path.join(this.containerOf(worktree.cwd), "wip-sub-"));
|
|
347
|
+
const tmp = path.join(tmpRoot, "work");
|
|
348
|
+
try {
|
|
349
|
+
// Attach against the live base checkout when known; otherwise the seeded
|
|
350
|
+
// worktree itself (same object store) so extension actions without baseCwd work.
|
|
351
|
+
let addBase = worktree.cwd;
|
|
352
|
+
if (preferBase && (await this.isGitRepo(preferBase, signal))) addBase = preferBase;
|
|
353
|
+
const added = await this.execFn(
|
|
354
|
+
"git",
|
|
355
|
+
["worktree", "add", "--detach", tmp, worktree.baseCommit],
|
|
356
|
+
addBase,
|
|
357
|
+
signal,
|
|
358
|
+
);
|
|
359
|
+
if (added.code !== 0) return { ...combined, clean: false };
|
|
360
|
+
|
|
361
|
+
const forward = await this.applyPatchStream(tmp, combined.patch, { signal });
|
|
362
|
+
if (forward.code !== 0) return { ...combined, clean: false };
|
|
363
|
+
const reverse = await this.applyPatchStream(tmp, wipPatch, { reverse: true, signal });
|
|
364
|
+
if (reverse.code !== 0) return { ...combined, clean: false };
|
|
365
|
+
|
|
366
|
+
await this.stageUntracked(tmp, signal);
|
|
367
|
+
const agentStat = await this.execFn("git", ["diff", "--stat", worktree.baseCommit], tmp, signal);
|
|
368
|
+
const agentPatch = await this.execFn("git", ["diff", "--binary", worktree.baseCommit], tmp, signal);
|
|
369
|
+
if (agentStat.code !== 0 || agentPatch.code !== 0) return { ...combined, clean: false };
|
|
370
|
+
return { stat: agentStat.stdout.trim(), patch: agentPatch.stdout, clean: true };
|
|
371
|
+
} catch {
|
|
372
|
+
return { ...combined, clean: false };
|
|
373
|
+
} finally {
|
|
374
|
+
if (preferBase) {
|
|
375
|
+
await this.execFn("git", ["worktree", "remove", "--force", tmp], preferBase).catch(() => {});
|
|
376
|
+
}
|
|
377
|
+
await this.execFn("git", ["worktree", "remove", "--force", tmp], worktree.cwd).catch(() => {});
|
|
378
|
+
await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Full patch (committed beyond base + uncommitted + untracked) of a worktree, capped. */
|
|
383
|
+
async diff(
|
|
384
|
+
worktree: { cwd: string; baseCommit: string; wipPatch?: string; wipUntracked?: string[]; baseCwd?: string },
|
|
385
|
+
maxBytes = 256 * 1024,
|
|
386
|
+
signal?: AbortSignal,
|
|
387
|
+
): Promise<WorktreeDiffResult> {
|
|
388
|
+
const combined = await this.currentFullDiff(worktree, signal);
|
|
389
|
+
const { wipPatch } = await this.resolveWip(worktree);
|
|
390
|
+
const subtracted = await this.subtractWip(worktree, combined, wipPatch, signal);
|
|
391
|
+
const full = subtracted.patch;
|
|
392
|
+
const truncated = Buffer.byteLength(full, "utf8") > maxBytes;
|
|
393
|
+
const warning = subtracted.clean ? undefined : INCLUDES_PARENT_WIP;
|
|
394
|
+
const patch = truncated ? full.slice(0, maxBytes) : full;
|
|
395
|
+
// When subtraction emptied the patch, recompute a neutral stat string.
|
|
396
|
+
let stat = subtracted.stat;
|
|
397
|
+
if (subtracted.clean && !full.trim()) stat = "";
|
|
398
|
+
return {
|
|
399
|
+
stat,
|
|
400
|
+
patch,
|
|
401
|
+
truncated,
|
|
402
|
+
warning,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Apply a worktree's changes (committed + uncommitted vs base) onto the base
|
|
408
|
+
* checkout as working-tree changes via `git apply --3way`. Never commits and
|
|
409
|
+
* never deletes the worktree — review/discard stays a separate explicit step.
|
|
410
|
+
*
|
|
411
|
+
* With a stored WIP baseline, only agent-only changes are applied when
|
|
412
|
+
* subtraction is clean; otherwise the combined delta is applied and a warning
|
|
413
|
+
* is returned.
|
|
414
|
+
*/
|
|
415
|
+
async apply(
|
|
416
|
+
worktree: { cwd: string; baseCommit: string; branch?: string; wipPatch?: string; wipUntracked?: string[]; baseCwd?: string },
|
|
417
|
+
baseCwd: string,
|
|
418
|
+
signal?: AbortSignal,
|
|
419
|
+
): Promise<WorktreeApplyResult> {
|
|
420
|
+
if (!(await this.isGitRepo(baseCwd, signal))) throw new Error(`${baseCwd} is not a git repository`);
|
|
421
|
+
const status = await this.execFn("git", ["status", "--porcelain"], worktree.cwd, signal);
|
|
422
|
+
if (status.code !== 0) throw new Error(`Unable to inspect worktree: ${status.stderr.trim()}`);
|
|
423
|
+
const combined = await this.currentFullDiff(worktree, signal);
|
|
424
|
+
const { wipPatch } = await this.resolveWip(worktree);
|
|
425
|
+
const subtracted = await this.subtractWip({ ...worktree, baseCwd }, combined, wipPatch, signal);
|
|
426
|
+
if (!subtracted.patch.trim()) return { applied: false, stat: "(no changes to apply)" };
|
|
427
|
+
|
|
428
|
+
// --3way merges via blob identity (same object store) and surfaces conflicts
|
|
429
|
+
// as markers instead of failing outright on drifted context.
|
|
430
|
+
const result = await new Promise<ExecResult>((resolve, reject) => {
|
|
431
|
+
const child = spawn("git", ["apply", "--3way", "--whitespace=nowarn"], {
|
|
432
|
+
cwd: baseCwd,
|
|
433
|
+
shell: false,
|
|
434
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
435
|
+
signal,
|
|
436
|
+
});
|
|
437
|
+
let stdout = "";
|
|
438
|
+
let stderr = "";
|
|
439
|
+
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
440
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
441
|
+
child.once("error", reject);
|
|
442
|
+
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
443
|
+
child.stdin?.on("error", () => { /* EPIPE when git exits early */ });
|
|
444
|
+
child.stdin?.end(subtracted.patch);
|
|
445
|
+
});
|
|
446
|
+
if (result.code !== 0) {
|
|
447
|
+
throw new Error(`git apply failed: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`);
|
|
448
|
+
}
|
|
449
|
+
const stat = await this.execFn("git", ["diff", "--stat"], baseCwd, signal);
|
|
450
|
+
return {
|
|
451
|
+
applied: true,
|
|
452
|
+
stat: stat.stdout.trim() || "(applied)",
|
|
453
|
+
warning: subtracted.clean ? undefined : INCLUDES_PARENT_WIP,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async forceRemove(handle: WorktreeHandle): Promise<void> {
|
|
458
|
+
await this.execFn("git", ["worktree", "remove", "--force", handle.cwd], handle.baseCwd).catch(() => {});
|
|
459
|
+
await this.execFn("git", ["branch", "-D", handle.branch], handle.baseCwd).catch(() => {});
|
|
460
|
+
await fs.rm(path.dirname(handle.cwd), { recursive: true, force: true });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Minimum age before sweep may touch a worktree; shields concurrent runtimes not in keepPaths. */
|
|
464
|
+
static readonly SWEEP_MIN_AGE_MS = 60 * 60_000;
|
|
465
|
+
|
|
466
|
+
/** Deterministic archived-patch location for a (possibly deleted) worktree cwd. */
|
|
467
|
+
archivedPatchPathFor(cwd: string): string {
|
|
468
|
+
const entry = path.basename(path.dirname(cwd));
|
|
469
|
+
return path.join(path.dirname(path.dirname(cwd)), PATCHES_DIR, `${entry}.patch`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Diff base for archival: create-time marker, else merge-base with the base repo HEAD. */
|
|
473
|
+
private async resolveArchiveBase(cwd: string, baseCwd: string): Promise<string | undefined> {
|
|
474
|
+
const marker = await fs
|
|
475
|
+
.readFile(path.join(path.dirname(cwd), BASE_COMMIT_FILE), "utf8")
|
|
476
|
+
.then((raw) => raw.trim())
|
|
477
|
+
.catch(() => "");
|
|
478
|
+
if (marker) return marker;
|
|
479
|
+
const baseHead = await this.execFn("git", ["rev-parse", "HEAD"], baseCwd);
|
|
480
|
+
if (baseHead.code !== 0) return undefined;
|
|
481
|
+
const mergeBase = await this.execFn("git", ["merge-base", "HEAD", baseHead.stdout.trim()], cwd);
|
|
482
|
+
if (mergeBase.code !== 0) return undefined;
|
|
483
|
+
return mergeBase.stdout.trim() || undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Preserve a worktree's unique work (committed beyond base + uncommitted +
|
|
488
|
+
* untracked) as one applyable patch file before its directory is reclaimed.
|
|
489
|
+
* Returns the patch path, or undefined when nothing could be captured.
|
|
490
|
+
*/
|
|
491
|
+
private async archiveWorktreePatch(
|
|
492
|
+
cwd: string,
|
|
493
|
+
base: string,
|
|
494
|
+
meta: { branch: string; baseCwd: string },
|
|
495
|
+
): Promise<string | undefined> {
|
|
496
|
+
await this.stageUntracked(cwd);
|
|
497
|
+
const patch = await this.execFn("git", ["diff", "--binary", base], cwd);
|
|
498
|
+
if (patch.code !== 0) return undefined;
|
|
499
|
+
const body = patch.stdout;
|
|
500
|
+
if (!body.trim()) return undefined;
|
|
501
|
+
const file = this.archivedPatchPathFor(cwd);
|
|
502
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
503
|
+
const header = [
|
|
504
|
+
`Archived pi-subagent worktree patch`,
|
|
505
|
+
`branch: ${meta.branch}`,
|
|
506
|
+
`base-commit: ${base}`,
|
|
507
|
+
`base-repo: ${path.resolve(meta.baseCwd)}`,
|
|
508
|
+
`archived-at: ${new Date().toISOString()}`,
|
|
509
|
+
`apply with: git apply --3way ${path.basename(file)}`,
|
|
510
|
+
``,
|
|
511
|
+
].join("\n");
|
|
512
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
513
|
+
await fs.writeFile(tmp, header + body, "utf8");
|
|
514
|
+
await fs.rename(tmp, file);
|
|
515
|
+
return file;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Apply an archived worktree patch onto a checkout as working-tree changes. */
|
|
519
|
+
async applyArchivedPatch(patchFile: string, baseCwd: string, signal?: AbortSignal): Promise<WorktreeApplyResult> {
|
|
520
|
+
if (!(await this.isGitRepo(baseCwd, signal))) throw new Error(`${baseCwd} is not a git repository`);
|
|
521
|
+
const patch = await fs.readFile(patchFile, "utf8");
|
|
522
|
+
const result = await new Promise<ExecResult>((resolve, reject) => {
|
|
523
|
+
const child = spawn("git", ["apply", "--3way", "--whitespace=nowarn"], {
|
|
524
|
+
cwd: baseCwd,
|
|
525
|
+
shell: false,
|
|
526
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
527
|
+
signal,
|
|
528
|
+
});
|
|
529
|
+
let stdout = "";
|
|
530
|
+
let stderr = "";
|
|
531
|
+
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
532
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
533
|
+
child.once("error", reject);
|
|
534
|
+
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
535
|
+
child.stdin?.on("error", () => { /* EPIPE when git exits early */ });
|
|
536
|
+
child.stdin?.end(patch);
|
|
537
|
+
});
|
|
538
|
+
if (result.code !== 0) {
|
|
539
|
+
throw new Error(`git apply failed: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`);
|
|
540
|
+
}
|
|
541
|
+
const stat = await this.execFn("git", ["diff", "--stat"], baseCwd, signal);
|
|
542
|
+
return { applied: true, stat: stat.stdout.trim() || "(applied)" };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** True when every commit on `sha` is reachable from some ref other than `branch` itself. */
|
|
546
|
+
private async isReachableElsewhere(baseCwd: string, sha: string, branch: string): Promise<boolean> {
|
|
547
|
+
if (!sha) return false;
|
|
548
|
+
const refs = await this.execFn(
|
|
549
|
+
"git",
|
|
550
|
+
["for-each-ref", "--format=%(refname:short)", "--contains", sha, "refs/heads", "refs/remotes", "refs/tags"],
|
|
551
|
+
baseCwd,
|
|
552
|
+
);
|
|
553
|
+
if (refs.code !== 0) return false;
|
|
554
|
+
return refs.stdout.split("\n").map((line) => line.trim()).filter(Boolean).some((ref) => ref !== branch);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Lifecycle-driven reclamation for one repo. A worktree is "over" when it is
|
|
559
|
+
* not referenced by any live run (`keepPaths`) and is past the concurrency
|
|
560
|
+
* safety window. Over-worktrees are always reclaimed immediately — there is
|
|
561
|
+
* no wall-clock retention. Their meaningful impact is preserved first:
|
|
562
|
+
* - Unique work (dirty tree, or commits on no other ref) is archived as one
|
|
563
|
+
* applyable patch under `<repo-container>/_patches/`.
|
|
564
|
+
* - A branch whose commits exist nowhere else is NEVER deleted, so committed
|
|
565
|
+
* work stays recoverable from git itself.
|
|
566
|
+
* - The multi-GB directory (checkout + node_modules) is then removed.
|
|
567
|
+
* `git worktree prune` clears stale registrations (deleted directories).
|
|
568
|
+
* Never touches worktrees referenced by `keepPaths` (live runs).
|
|
569
|
+
*/
|
|
570
|
+
async sweep(
|
|
571
|
+
baseCwd: string,
|
|
572
|
+
_retentionDays = defaultConfig.worktreeRetentionDays,
|
|
573
|
+
keepPaths: ReadonlySet<string> = new Set(),
|
|
574
|
+
now = Date.now(),
|
|
575
|
+
): Promise<SweepReport> {
|
|
576
|
+
return this.sweepContainer(baseCwd, this.repoRoot(baseCwd), keepPaths, now);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Sweep one container against its base repo. Split from `sweep()` because a
|
|
581
|
+
* container's on-disk path may differ from `repoRoot(baseCwd)` when the base
|
|
582
|
+
* path was recovered through a symlink-resolving gitdir pointer.
|
|
583
|
+
*/
|
|
584
|
+
private async sweepContainer(
|
|
585
|
+
baseCwd: string,
|
|
586
|
+
container: string,
|
|
587
|
+
keepPaths: ReadonlySet<string>,
|
|
588
|
+
now: number,
|
|
589
|
+
): Promise<SweepReport> {
|
|
590
|
+
const report: SweepReport = { pruned: false, removed: [], kept: [], archived: [] };
|
|
591
|
+
if (!(await this.isGitRepo(baseCwd))) return report;
|
|
592
|
+
const pruned = await this.execFn("git", ["worktree", "prune"], baseCwd).catch(() => ({ code: 1 } as ExecResult));
|
|
593
|
+
report.pruned = pruned.code === 0;
|
|
594
|
+
|
|
595
|
+
const entries = await fs.readdir(container, { withFileTypes: true }).catch(() => []);
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
if (!entry.isDirectory()) continue;
|
|
598
|
+
// The archive directory is not a worktree container; never treat it as one.
|
|
599
|
+
if (entry.name === PATCHES_DIR) continue;
|
|
600
|
+
const root = path.join(container, entry.name);
|
|
601
|
+
const cwd = path.join(root, "work");
|
|
602
|
+
if (keepPaths.has(cwd)) {
|
|
603
|
+
report.kept.push(cwd);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const stat = await fs.stat(cwd).catch(() => undefined);
|
|
607
|
+
if (!stat) {
|
|
608
|
+
// Orphaned container (work dir already gone).
|
|
609
|
+
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
|
610
|
+
report.removed.push(cwd);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const age = now - stat.mtimeMs;
|
|
614
|
+
if (age < WorktreeManager.SWEEP_MIN_AGE_MS) {
|
|
615
|
+
// Too young: may belong to a concurrent runtime whose keepPaths we cannot see.
|
|
616
|
+
report.kept.push(cwd);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const branchResult = await this.execFn("git", ["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => undefined);
|
|
621
|
+
const shaResult = await this.execFn("git", ["rev-parse", "HEAD"], cwd).catch(() => undefined);
|
|
622
|
+
const statusResult = await this.execFn("git", ["status", "--porcelain"], cwd).catch(() => undefined);
|
|
623
|
+
if (branchResult?.code !== 0 || shaResult?.code !== 0 || statusResult?.code !== 0) {
|
|
624
|
+
// Unable to prove safety: keep.
|
|
625
|
+
report.kept.push(cwd);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
const branch = branchResult.stdout.trim();
|
|
629
|
+
const sha = shaResult.stdout.trim();
|
|
630
|
+
let dirty = statusResult.stdout.trim().length > 0;
|
|
631
|
+
// WIP-seeded leftovers with no agent edits are treated as clean for sweep.
|
|
632
|
+
if (dirty) {
|
|
633
|
+
const artifacts = await this.loadWipArtifacts(cwd);
|
|
634
|
+
if (artifacts.wipPatch !== undefined) {
|
|
635
|
+
const onlyWip = await this.isOnlyWipSeed(
|
|
636
|
+
{ cwd, branch, baseCwd, baseCommit: sha, changed: dirty, wipPatch: artifacts.wipPatch, wipUntracked: artifacts.wipUntracked },
|
|
637
|
+
).catch(() => false);
|
|
638
|
+
if (onlyWip) dirty = false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const handle: WorktreeHandle = { cwd, branch, baseCwd, baseCommit: sha, changed: dirty };
|
|
642
|
+
const uniqueCommits = !(await this.isReachableElsewhere(baseCwd, sha, branch));
|
|
643
|
+
|
|
644
|
+
if (!dirty && !uniqueCommits) {
|
|
645
|
+
// Fully redundant: clean tree, commits preserved on other refs.
|
|
646
|
+
await this.forceRemove(handle).catch(() => {});
|
|
647
|
+
report.removed.push(cwd);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// The run is over and the worktree holds unique work: distill it. Archive
|
|
652
|
+
// the full delta as one patch, then reclaim the directory. Failure to
|
|
653
|
+
// archive keeps the worktree (never destroy work we could not preserve).
|
|
654
|
+
const base = await this.resolveArchiveBase(cwd, baseCwd);
|
|
655
|
+
let archived: string | undefined;
|
|
656
|
+
if (base) {
|
|
657
|
+
archived = await this.archiveWorktreePatch(cwd, base, { branch, baseCwd }).catch(() => undefined);
|
|
658
|
+
}
|
|
659
|
+
if (!archived) {
|
|
660
|
+
report.kept.push(cwd);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
report.archived.push(archived);
|
|
664
|
+
if (uniqueCommits) {
|
|
665
|
+
// Keep the branch (commits exist nowhere else); drop only the directory.
|
|
666
|
+
await this.execFn("git", ["worktree", "remove", "--force", cwd], baseCwd).catch(() => {});
|
|
667
|
+
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
|
668
|
+
} else {
|
|
669
|
+
await this.forceRemove(handle).catch(() => {});
|
|
670
|
+
}
|
|
671
|
+
report.removed.push(cwd);
|
|
672
|
+
}
|
|
673
|
+
return report;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Base repo of a container, from any worktree's create-time marker or its
|
|
678
|
+
* linked `.git` gitdir pointer. A marker is trusted only when the base's
|
|
679
|
+
* container hash round-trips (rejects moved repos / foreign directories);
|
|
680
|
+
* a gitdir pointer only when its admin dir under the base still exists
|
|
681
|
+
* (hashes cannot round-trip — git stores realpaths, containers may not).
|
|
682
|
+
*/
|
|
683
|
+
private async resolveContainerBase(container: string): Promise<string | undefined> {
|
|
684
|
+
const entries = await fs.readdir(container, { withFileTypes: true }).catch(() => []);
|
|
685
|
+
for (const entry of entries) {
|
|
686
|
+
if (!entry.isDirectory() || entry.name === PATCHES_DIR) continue;
|
|
687
|
+
const root = path.join(container, entry.name);
|
|
688
|
+
const marker = await fs.readFile(path.join(root, BASE_REPO_FILE), "utf8").then((raw) => raw.trim()).catch(() => "");
|
|
689
|
+
if (marker && this.repoRoot(marker) === container && (await this.isGitRepo(marker))) return marker;
|
|
690
|
+
const gitFile = await fs.readFile(path.join(root, "work", ".git"), "utf8").catch(() => "");
|
|
691
|
+
const gitdir = /^gitdir:\s*(.+?)\s*$/m.exec(gitFile)?.[1];
|
|
692
|
+
const linked = gitdir?.split(`${path.sep}.git${path.sep}worktrees${path.sep}`)[0];
|
|
693
|
+
if (!linked || linked === gitdir) continue;
|
|
694
|
+
const adminDir = await fs.stat(gitdir!).catch(() => undefined);
|
|
695
|
+
if (adminDir?.isDirectory() && (await this.isGitRepo(linked))) return linked;
|
|
696
|
+
}
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Machine-wide lifecycle sweep: every repo container under the worktree root,
|
|
702
|
+
* not just the current checkout's. Each container's base repo is resolved via
|
|
703
|
+
* `resolveContainerBase` and reclaimed with the exact `sweep()` safety model
|
|
704
|
+
* (archive-then-remove, unreachable branches kept, keepPaths + min-age shields).
|
|
705
|
+
*
|
|
706
|
+
* Policy for containers whose base repo no longer exists: **keep, never
|
|
707
|
+
* delete**. The worktree's object store lives inside the deleted base repo,
|
|
708
|
+
* so we cannot diff, archive, or even distinguish unique work from a pristine
|
|
709
|
+
* checkout — removal could destroy the only remaining copy. They are reported
|
|
710
|
+
* in `orphanedContainers` so the owner can delete them deliberately. Only
|
|
711
|
+
* containers holding neither worktrees nor archived patches are removed.
|
|
712
|
+
*/
|
|
713
|
+
async sweepAll(
|
|
714
|
+
currentCwd?: string,
|
|
715
|
+
keepPaths: ReadonlySet<string> = new Set(),
|
|
716
|
+
now = Date.now(),
|
|
717
|
+
): Promise<GlobalSweepReport> {
|
|
718
|
+
const report: GlobalSweepReport = { pruned: false, removed: [], kept: [], archived: [], swept: [], orphanedContainers: [] };
|
|
719
|
+
const sweptContainers = new Set<string>();
|
|
720
|
+
const sweepBase = async (baseCwd: string, container: string) => {
|
|
721
|
+
if (sweptContainers.has(container)) return;
|
|
722
|
+
sweptContainers.add(container);
|
|
723
|
+
report.swept.push(path.resolve(baseCwd));
|
|
724
|
+
const sub = await this.sweepContainer(path.resolve(baseCwd), container, keepPaths, now);
|
|
725
|
+
report.pruned = report.pruned || sub.pruned;
|
|
726
|
+
report.removed.push(...sub.removed);
|
|
727
|
+
report.kept.push(...sub.kept);
|
|
728
|
+
report.archived.push(...sub.archived);
|
|
729
|
+
};
|
|
730
|
+
if (currentCwd) await sweepBase(currentCwd, this.repoRoot(currentCwd)).catch(() => {});
|
|
731
|
+
|
|
732
|
+
const containers = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
|
|
733
|
+
for (const entry of containers) {
|
|
734
|
+
if (!entry.isDirectory()) continue;
|
|
735
|
+
const container = path.join(this.rootDir, entry.name);
|
|
736
|
+
if (sweptContainers.has(container)) continue;
|
|
737
|
+
const baseCwd = await this.resolveContainerBase(container);
|
|
738
|
+
if (baseCwd) {
|
|
739
|
+
await sweepBase(baseCwd, container).catch(() => {});
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const contents = await fs.readdir(container).catch(() => []);
|
|
743
|
+
const worktreeEntries = contents.filter((name) => name !== PATCHES_DIR);
|
|
744
|
+
const patches = await fs.readdir(path.join(container, PATCHES_DIR)).catch(() => []);
|
|
745
|
+
if (!worktreeEntries.length && !patches.length) {
|
|
746
|
+
await fs.rm(container, { recursive: true, force: true }).catch(() => {});
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (worktreeEntries.length) report.orphanedContainers.push(container);
|
|
750
|
+
}
|
|
751
|
+
return report;
|
|
752
|
+
}
|
|
753
|
+
}
|