pi-ultracode 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,68 @@
1
1
  /**
2
- * Git worktree isolation for workflow subagents.
2
+ * Git worktree isolation and non-destructive patch integration.
3
3
  *
4
- * When an agent() call requests `isolation: 'worktree'`, the subagent runs in a
5
- * throwaway git worktree on a detached branch so parallel file-mutating agents
6
- * don't clobber each other. After the agent finishes we capture a diff; a
7
- * worktree with no changes is removed immediately ("auto-removed if unchanged").
4
+ * Every isolated agent owns a random detached worktree. Captured patches stay
5
+ * byte-exact, are applied from the canonical repository root, and never modify
6
+ * the user's index.
8
7
  */
9
8
 
10
9
  import { execFileSync } from "node:child_process";
11
- import { randomBytes } from "node:crypto";
10
+ import { createHash, randomBytes } from "node:crypto";
12
11
  import * as fs from "node:fs";
13
12
  import * as os from "node:os";
14
13
  import * as path from "node:path";
15
- import { ensurePrivateArtifactDirectory, writeArtifactFile } from "./run-artifacts.ts";
14
+ import {
15
+ ensurePrivateArtifactDirectory,
16
+ readContainedArtifactBytes,
17
+ writeArtifactFile,
18
+ } from "./run-artifacts.ts";
19
+ import { hermeticGitEnvironment, stripOneLineEnding } from "./hermetic-git.ts";
20
+ import { resolveRepositoryContext } from "./repository-context.ts";
21
+
22
+ const MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
23
+ const DEFAULT_GIT_TIMEOUT_MS = 25_000;
24
+ const MAX_ROLLBACK_SNAPSHOT_BYTES = 256 * 1024 * 1024;
25
+ /** Ownership cookie inside the private object store; survives path reuse, not directory replacement. */
26
+ const RAW_OBJECT_OWNER_MARKER = ".ultracode-owned";
27
+
28
+ type PatchInput = string | Uint8Array;
29
+
30
+ type PathSnapshot =
31
+ | { path: Buffer; relative: Buffer; kind: "missing" }
32
+ | { path: Buffer; relative: Buffer; kind: "directory"; mode: number }
33
+ | { path: Buffer; relative: Buffer; kind: "file"; data: Buffer; mode: number }
34
+ | { path: Buffer; relative: Buffer; kind: "symlink"; target: Buffer };
35
+
36
+ interface ApplySnapshot {
37
+ roots: Array<{ path: Buffer; relative: Buffer }>;
38
+ paths: PathSnapshot[];
39
+ directories: Array<{ path: Buffer; existed: boolean; mode?: number }>;
40
+ }
16
41
 
17
42
  export interface Worktree {
43
+ /** Canonical root of the repository that owns this worktree. */
44
+ repoRoot: string;
18
45
  path: string;
19
- /** cwd the subagent should use (worktree root joined with the original relative cwd). */
46
+ /** cwd the subagent should use inside the worktree. */
20
47
  agentCwd: string;
21
- branch: string;
22
48
  baseCommit: string;
49
+ /** Immutable ownership evidence for the linked worktree. */
50
+ gitDir: string;
51
+ gitDirDevice: number;
52
+ gitDirInode: number;
53
+ gitCommonDir: string;
54
+ device: number;
55
+ inode: number;
56
+ /** Private raw-object store used to bypass filters and the real index. */
57
+ rawObjectDirectory: string;
58
+ rawObjectDevice: number;
59
+ rawObjectInode: number;
60
+ /** Secret written into the raw-object store; required before deleting that path. */
61
+ rawObjectToken: string;
62
+ repositoryObjectDirectory: string;
63
+ rawBaseTree: string;
64
+ /** Synthetic checkout files absent from baseCommit, used to distinguish hook setup from agent changes. */
65
+ rawBaselineExtras: RawBaselineExtra[];
23
66
  }
24
67
 
25
68
  export interface WorktreeDiff {
@@ -27,26 +70,38 @@ export interface WorktreeDiff {
27
70
  insertions: number;
28
71
  deletions: number;
29
72
  diffStat: string;
30
- patch: string;
73
+ patch: Buffer;
74
+ /** Raw repository-relative path bytes, safe for non-UTF8 filenames. */
75
+ paths: Buffer[];
31
76
  }
32
77
 
33
- function git(cwd: string, args: string[]): string {
34
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
78
+ interface GitRunOptions {
79
+ timeoutMs?: number;
80
+ env?: NodeJS.ProcessEnv;
81
+ input?: Uint8Array;
82
+ maxBuffer?: number;
35
83
  }
36
84
 
37
- function tryGit(cwd: string, args: string[]): string | undefined {
38
- try {
39
- return git(cwd, args);
40
- } catch {
41
- return undefined;
42
- }
85
+ function gitBuffer(cwd: string, args: string[], options: GitRunOptions = {}): Buffer {
86
+ return execFileSync("git", args, {
87
+ cwd,
88
+ encoding: "buffer",
89
+ stdio: [options.input ? "pipe" : "ignore", "pipe", "pipe"],
90
+ input: options.input,
91
+ env: hermeticGitEnvironment(options.env),
92
+ maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT_BYTES,
93
+ timeout: options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
94
+ killSignal: "SIGKILL",
95
+ }) as Buffer;
96
+ }
97
+
98
+ function gitLine(cwd: string, args: string[], options: GitRunOptions = {}): string {
99
+ return stripOneLineEnding(gitBuffer(cwd, args, options).toString("utf8"));
43
100
  }
44
101
 
45
- /** Like tryGit but does NOT `.trim()` — required for `git diff --binary` output,
46
- * whose trailing blank lines are part of the patch format and must not be stripped. */
47
- function tryGitRaw(cwd: string, args: string[]): string | undefined {
102
+ function tryGit(cwd: string, args: string[], options: GitRunOptions = {}): string | undefined {
48
103
  try {
49
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
104
+ return gitLine(cwd, args, options);
50
105
  } catch {
51
106
  return undefined;
52
107
  }
@@ -56,116 +111,929 @@ export function isGitRepo(cwd: string): boolean {
56
111
  return tryGit(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
57
112
  }
58
113
 
59
- /**
60
- * Create an isolated worktree for one subagent. Throws if `cwd` is not inside a
61
- * git repository (the caller falls back to a shared cwd in that case).
62
- */
114
+ /** Create a uniquely-owned detached worktree. Never removes an existing path. */
63
115
  export function createWorktree(cwd: string, runId: string, index: number): Worktree {
64
- const toplevel = tryGit(cwd, ["rev-parse", "--show-toplevel"]);
65
- if (!toplevel) throw new Error("isolation: 'worktree' requires the working directory to be inside a git repository");
66
-
67
- const baseCommit = tryGit(cwd, ["rev-parse", "HEAD"]) ?? "";
116
+ const context = resolveRepositoryContext(cwd);
117
+ if (!context.repoRoot || context.relativeCwd === undefined || !context.gitCommonDir) {
118
+ throw new Error("isolation: 'worktree' requires a Git working tree");
119
+ }
120
+ const repoRoot = context.repoRoot;
121
+ const baseCommit = gitLine(repoRoot, ["rev-parse", "HEAD"]);
68
122
  if (!baseCommit) {
69
123
  throw new Error("isolation: 'worktree' requires at least one commit in the repository");
70
124
  }
71
125
 
72
- const safeRun = runId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 24) || "run";
73
- const branch = `ultracode/${safeRun}-${index}`;
74
- const worktreePath = path.join(os.tmpdir(), `ultracode-wt-${safeRun}-${index}`);
126
+ const diagnostic = runId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 12) || "run";
127
+ const nonce = randomBytes(12).toString("hex");
128
+ const worktreePath = path.join(os.tmpdir(), `ultracode-wt-${diagnostic}-${index}-${nonce}`);
129
+ const rawObjectDirectory = `${worktreePath}.ultracode-objects`;
130
+ let rootIdentity: DirectoryIdentity | undefined;
131
+ let registrationIdentity: RegistrationIdentity | undefined;
132
+ let rawIdentity: DirectoryIdentity | undefined;
133
+ let rawCreated = false;
134
+
135
+ try {
136
+ gitBuffer(repoRoot, ["worktree", "add", "--detach", worktreePath, baseCommit]);
137
+ const rootStat = fs.lstatSync(worktreePath);
138
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
139
+ throw new Error("created worktree root is not an owned directory");
140
+ }
141
+ rootIdentity = { device: rootStat.dev, inode: rootStat.ino };
142
+ registrationIdentity = discoverRegistrationAtPath(worktreePath, context.gitCommonDir);
143
+ if (!registrationIdentity) {
144
+ throw new Error("created worktree Git metadata is not a verified linked-worktree registration");
145
+ }
146
+ const gitDir = registrationIdentity.gitDir;
147
+ const gitCommonDir = context.gitCommonDir;
148
+ const repositoryObjectDirectory = fs.realpathSync(
149
+ gitLine(repoRoot, ["rev-parse", "--path-format=absolute", "--git-path", "objects"]),
150
+ );
151
+ fs.mkdirSync(rawObjectDirectory, { mode: 0o700 });
152
+ rawCreated = true;
153
+ const rawObjectToken = randomBytes(16).toString("hex");
154
+ writeRawObjectOwnerMarker(rawObjectDirectory, rawObjectToken);
155
+ const rawObjectStat = fs.lstatSync(rawObjectDirectory);
156
+ if (!rawObjectStat.isDirectory() || rawObjectStat.isSymbolicLink()) {
157
+ throw new Error("raw object store is not an owned directory");
158
+ }
159
+ rawIdentity = { device: rawObjectStat.dev, inode: rawObjectStat.ino };
160
+ const rawBase = buildRawTree({
161
+ cwd: worktreePath,
162
+ baseCommit,
163
+ objectDirectory: rawObjectDirectory,
164
+ repositoryObjectDirectory,
165
+ includeUntracked: true,
166
+ allowTrackedDeletion: false,
167
+ timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
168
+ });
169
+ const rawBaselineExtras = [...rawBase.entries]
170
+ .filter(([key, entry]) => !rawBase.basePathKeys.has(key) && entry.type === "blob")
171
+ .map(([key, entry]) => ({
172
+ key,
173
+ mode: entry.mode,
174
+ type: "blob" as const,
175
+ oid: entry.oid,
176
+ tracked: rawBase.trackedPathKeys.has(key),
177
+ }));
178
+ const agentCwd = context.relativeCwd
179
+ ? path.join(worktreePath, context.relativeCwd)
180
+ : worktreePath;
181
+ const realAgentCwd = fs.realpathSync(agentCwd);
182
+ const relativeAgentCwd = path.relative(fs.realpathSync(worktreePath), realAgentCwd);
183
+ if (relativeAgentCwd.startsWith("..") || path.isAbsolute(relativeAgentCwd)) {
184
+ throw new Error("resolved agent cwd escaped the isolated worktree");
185
+ }
186
+ return {
187
+ repoRoot,
188
+ path: worktreePath,
189
+ agentCwd: realAgentCwd,
190
+ baseCommit,
191
+ gitDir,
192
+ gitDirDevice: registrationIdentity.device,
193
+ gitDirInode: registrationIdentity.inode,
194
+ gitCommonDir,
195
+ device: rootStat.dev,
196
+ inode: rootStat.ino,
197
+ rawObjectDirectory,
198
+ rawObjectDevice: rawObjectStat.dev,
199
+ rawObjectInode: rawObjectStat.ino,
200
+ rawObjectToken,
201
+ repositoryObjectDirectory,
202
+ rawBaseTree: rawBase.tree,
203
+ rawBaselineExtras,
204
+ };
205
+ } catch (error) {
206
+ registrationIdentity ??= discoverRegistrationAtPath(
207
+ worktreePath,
208
+ context.gitCommonDir,
209
+ );
210
+ if (!rootIdentity && registrationIdentity) {
211
+ rootIdentity = directoryIdentity(worktreePath);
212
+ }
213
+ if (registrationIdentity && unregisterRegistration(repoRoot, registrationIdentity, worktreePath)) {
214
+ if (rootIdentity) tryRemoveOwnedDirectory(worktreePath, rootIdentity);
215
+ if (rawCreated && rawIdentity) tryRemoveOwnedDirectory(rawObjectDirectory, rawIdentity);
216
+ }
217
+ throw error;
218
+ }
219
+ }
75
220
 
76
- // Best-effort GC of orphaned + stale ultracode worktrees from earlier runs.
77
- // Rate-limited so a fleet of worktree agents doesn't hammer tmpdir on every create.
78
- const _now = Date.now();
79
- if (_now - lastReapAt > REAP_INTERVAL_MS) {
80
- lastReapAt = _now;
81
- reapStaleWorktrees(toplevel);
221
+ /** Capture a byte-exact raw filesystem diff without filters or the real index. */
222
+ export function captureWorktreeDiff(
223
+ worktree: Worktree,
224
+ timeoutMs = DEFAULT_GIT_TIMEOUT_MS,
225
+ ): WorktreeDiff {
226
+ const deadline = Date.now() + normalizeTimeout(timeoutMs);
227
+ assertOwnedWorktree(worktree, deadline);
228
+ const verifiedGitMetadata = assertUnchangedGitlinks(worktree, deadline);
229
+ assertCleanSubmodules(worktree, deadline);
230
+ assertNoUnsupportedFilesystemNodes(worktree, deadline, verifiedGitMetadata);
231
+ const current = buildRawTree({
232
+ cwd: worktree.path,
233
+ baseCommit: worktree.baseCommit,
234
+ objectDirectory: worktree.rawObjectDirectory,
235
+ repositoryObjectDirectory: worktree.repositoryObjectDirectory,
236
+ includeUntracked: true,
237
+ allowTrackedDeletion: true,
238
+ timeoutMs: remainingTimeout(deadline),
239
+ });
240
+ const objectEnv = rawObjectEnvironment(worktree.rawObjectDirectory, worktree.repositoryObjectDirectory);
241
+ const syntheticBasePathsToRemove = new Set(worktree.rawBaselineExtras
242
+ .filter((baseline) => {
243
+ const entry = current.entries.get(baseline.key);
244
+ if (!entry) return true;
245
+ return entry.mode !== baseline.mode
246
+ || entry.type !== baseline.type
247
+ || entry.oid !== baseline.oid
248
+ || (!baseline.tracked && current.trackedPathKeys.has(baseline.key));
249
+ })
250
+ .map((entry) => entry.key));
251
+ const comparisonBaseTree = syntheticBasePathsToRemove.size > 0
252
+ ? filterRawTree(
253
+ worktree.path,
254
+ worktree.rawBaseTree,
255
+ syntheticBasePathsToRemove,
256
+ objectEnv,
257
+ deadline,
258
+ )
259
+ : worktree.rawBaseTree;
260
+ const run = (args: string[]): Buffer => gitBuffer(worktree.path, args, {
261
+ timeoutMs: remainingTimeout(deadline),
262
+ env: objectEnv,
263
+ });
264
+ const sharedDiffArgs = [
265
+ "--no-renames",
266
+ "--no-ext-diff",
267
+ "--no-textconv",
268
+ "--src-prefix=a/",
269
+ "--dst-prefix=b/",
270
+ comparisonBaseTree,
271
+ current.tree,
272
+ ];
273
+ const numstat = run([
274
+ "-c", "core.quotepath=false", "diff", "--numstat", "-z", ...sharedDiffArgs,
275
+ ]);
276
+ const diffStat = run([
277
+ "-c", "core.quotepath=false", "diff", "--stat", ...sharedDiffArgs,
278
+ ]).toString("utf8");
279
+ const patch = run([
280
+ "-c", "core.quotepath=false", "diff", "--binary", "--full-index", ...sharedDiffArgs,
281
+ ]);
282
+
283
+ const parsed = parseNumstat(numstat);
284
+ if (parsed.filesChanged > 0 && patch.length === 0) {
285
+ throw new Error("git diff reported changed files but produced an empty patch");
82
286
  }
287
+ return { ...parsed, diffStat, patch };
288
+ }
83
289
 
84
- // Clean up any stale worktree from a crashed prior run.
85
- removeWorktreeQuiet(toplevel, worktreePath, branch);
290
+ export function hasChanges(diff: WorktreeDiff): boolean {
291
+ return diff.filesChanged > 0;
292
+ }
86
293
 
87
- git(toplevel, ["worktree", "add", "--detach", worktreePath, baseCommit]);
88
- // Move onto a named branch so the diff has a stable ref and cleanup is unambiguous.
89
- tryGit(worktreePath, ["checkout", "-B", branch]);
294
+ interface RawTreeInput {
295
+ cwd: string;
296
+ baseCommit: string;
297
+ objectDirectory: string;
298
+ repositoryObjectDirectory: string;
299
+ includeUntracked: boolean;
300
+ allowTrackedDeletion: boolean;
301
+ timeoutMs: number;
302
+ }
90
303
 
91
- linkNodeModules(toplevel, worktreePath);
304
+ interface RawBaselineExtra {
305
+ key: string;
306
+ mode: string;
307
+ type: "blob";
308
+ oid: string;
309
+ tracked: boolean;
310
+ }
92
311
 
93
- const relativeCwd = path.relative(toplevel, path.resolve(cwd));
94
- const agentCwd = relativeCwd && !relativeCwd.startsWith("..") ? path.join(worktreePath, relativeCwd) : worktreePath;
312
+ interface RawTreeResult {
313
+ tree: string;
314
+ entries: Map<string, RawTreeEntry>;
315
+ basePathKeys: Set<string>;
316
+ trackedPathKeys: Set<string>;
317
+ }
95
318
 
96
- return { path: worktreePath, agentCwd, branch, baseCommit };
319
+ interface RawTreeEntry {
320
+ mode: string;
321
+ type: "blob" | "commit";
322
+ oid: string;
323
+ path: Buffer;
97
324
  }
98
325
 
99
- /** Stage everything and capture the diff vs the base commit. */
100
- export function captureWorktreeDiff(worktree: Worktree): WorktreeDiff {
101
- tryGit(worktree.path, ["add", "-A"]);
102
- // core.quotepath=false keeps non-ASCII paths unquoted in the patch text (so
103
- // applyPatch's path parser targets the real file); --binary carries literal
104
- // binary patch data so binary changes are applicable + recoverable.
105
- const numstat = tryGit(worktree.path, ["-c", "core.quotepath=false", "diff", "--cached", "--numstat", worktree.baseCommit]) ?? "";
106
- const diffStat = tryGit(worktree.path, ["-c", "core.quotepath=false", "diff", "--cached", "--stat", worktree.baseCommit]) ?? "";
107
- const patch = tryGitRaw(worktree.path, ["-c", "core.quotepath=false", "diff", "--cached", "--binary", worktree.baseCommit]) ?? "";
326
+ function buildRawTree(input: RawTreeInput): RawTreeResult {
327
+ const deadline = Date.now() + normalizeTimeout(input.timeoutMs);
328
+ const objectEnv = rawObjectEnvironment(input.objectDirectory, input.repositoryObjectDirectory);
329
+ const baseEntries = parseLsTree(gitBuffer(
330
+ input.cwd,
331
+ ["ls-tree", "-r", "-z", "--full-tree", input.baseCommit],
332
+ { timeoutMs: remainingTimeout(deadline), env: objectEnv },
333
+ ));
334
+ const basePathKeys = new Set(baseEntries.map((entry) => entry.path.toString("base64")));
335
+ const trackedPaths = listCurrentTrackedPaths(input.cwd, objectEnv, deadline);
336
+ const trackedPathKeys = new Set(trackedPaths.keys());
337
+ const entries = new Map<string, RawTreeEntry>();
338
+ for (const base of baseEntries) {
339
+ const key = base.path.toString("base64");
340
+ if (base.mode === "160000") {
341
+ setRawTreeEntry(entries, base);
342
+ continue;
343
+ }
344
+ const current = rawEntryFromFilesystem(input.cwd, base.path, objectEnv, deadline);
345
+ if (current) setRawTreeEntry(entries, current);
346
+ else if (!input.allowTrackedDeletion) {
347
+ throw new Error(`fresh worktree is missing a tracked path: ${base.path.toString("utf8")}`);
348
+ }
349
+ }
350
+ if (input.includeUntracked) {
351
+ const untracked = listUntrackedFromBase(input, objectEnv, deadline);
352
+ for (const relative of untracked) {
353
+ const key = relative.toString("base64");
354
+ if (entries.has(key)) continue;
355
+ const entry = rawEntryFromFilesystem(input.cwd, relative, objectEnv, deadline);
356
+ if (entry) setRawTreeEntry(entries, entry);
357
+ }
358
+ }
359
+ for (const [key, relative] of trackedPaths) {
360
+ if (entries.has(key)) continue;
361
+ const entry = rawEntryFromFilesystem(input.cwd, relative, objectEnv, deadline);
362
+ if (entry) setRawTreeEntry(entries, entry);
363
+ else if (!input.allowTrackedDeletion) {
364
+ throw new Error(`fresh worktree is missing a tracked path: ${relative.toString("utf8")}`);
365
+ }
366
+ }
108
367
 
109
- let filesChanged = 0;
110
- let insertions = 0;
111
- let deletions = 0;
112
- for (const line of numstat.split("\n")) {
113
- if (!line.trim()) continue;
114
- filesChanged++;
115
- const [add, del] = line.split("\t");
116
- if (add && add !== "-") insertions += Number(add) || 0;
117
- if (del && del !== "-") deletions += Number(del) || 0;
368
+ return {
369
+ tree: writeRawTree(input.cwd, entries.values(), objectEnv, deadline),
370
+ entries,
371
+ basePathKeys,
372
+ trackedPathKeys,
373
+ };
374
+ }
375
+
376
+ function listCurrentTrackedPaths(
377
+ cwd: string,
378
+ objectEnv: NodeJS.ProcessEnv,
379
+ deadline: number,
380
+ ): Map<string, Buffer> {
381
+ const paths = new Map<string, Buffer>();
382
+ for (const entry of parseLsTree(gitBuffer(
383
+ cwd,
384
+ ["ls-tree", "-r", "-z", "--full-tree", "HEAD"],
385
+ { timeoutMs: remainingTimeout(deadline), env: objectEnv },
386
+ ))) {
387
+ if (entry.mode !== "160000") paths.set(entry.path.toString("base64"), entry.path);
118
388
  }
119
- return { filesChanged, insertions, deletions, diffStat, patch };
389
+ for (const entry of parseIndexEntries(gitBuffer(
390
+ cwd,
391
+ ["ls-files", "--stage", "-z"],
392
+ { timeoutMs: remainingTimeout(deadline) },
393
+ ))) {
394
+ if (entry.stage !== "0") {
395
+ throw new Error("worktree index contains unmerged entries; worktree must be preserved");
396
+ }
397
+ if (entry.mode !== "160000") paths.set(entry.path.toString("base64"), entry.path);
398
+ }
399
+ return paths;
120
400
  }
121
401
 
122
- export function hasChanges(diff: WorktreeDiff): boolean {
123
- return diff.filesChanged > 0;
402
+ function writeRawTree(
403
+ cwd: string,
404
+ values: Iterable<RawTreeEntry>,
405
+ objectEnv: NodeJS.ProcessEnv,
406
+ deadline: number,
407
+ ): string {
408
+ const indexPath = path.join(os.tmpdir(), `ultracode-index-${process.pid}-${randomBytes(8).toString("hex")}`);
409
+ const indexEnv = { ...objectEnv, GIT_INDEX_FILE: indexPath };
410
+ try {
411
+ gitBuffer(cwd, ["read-tree", "--empty"], {
412
+ timeoutMs: remainingTimeout(deadline),
413
+ env: indexEnv,
414
+ });
415
+ const indexInfo = [...values]
416
+ .sort((left, right) => Buffer.compare(left.path, right.path))
417
+ .map((entry) => Buffer.concat([
418
+ Buffer.from(`${entry.mode} ${entry.oid}\t`, "ascii"),
419
+ entry.path,
420
+ Buffer.from([0]),
421
+ ]));
422
+ if (indexInfo.length > 0) {
423
+ gitBuffer(cwd, ["update-index", "-z", "--index-info"], {
424
+ timeoutMs: remainingTimeout(deadline),
425
+ env: indexEnv,
426
+ input: Buffer.concat(indexInfo),
427
+ });
428
+ }
429
+ return gitLine(cwd, ["write-tree"], {
430
+ timeoutMs: remainingTimeout(deadline),
431
+ env: indexEnv,
432
+ });
433
+ } finally {
434
+ try {
435
+ fs.rmSync(indexPath, { force: true });
436
+ } catch {
437
+ // ignore
438
+ }
439
+ }
440
+ }
441
+
442
+ function filterRawTree(
443
+ cwd: string,
444
+ tree: string,
445
+ excludedPathKeys: ReadonlySet<string>,
446
+ objectEnv: NodeJS.ProcessEnv,
447
+ deadline: number,
448
+ ): string {
449
+ const entries = parseLsTree(gitBuffer(
450
+ cwd,
451
+ ["ls-tree", "-r", "-z", "--full-tree", tree],
452
+ { timeoutMs: remainingTimeout(deadline), env: objectEnv },
453
+ )).filter((entry) => !excludedPathKeys.has(entry.path.toString("base64")));
454
+ return writeRawTree(cwd, entries, objectEnv, deadline);
455
+ }
456
+
457
+ function listUntrackedFromBase(
458
+ input: RawTreeInput,
459
+ objectEnv: NodeJS.ProcessEnv,
460
+ deadline: number,
461
+ ): Buffer[] {
462
+ const indexPath = path.join(os.tmpdir(), `ultracode-base-index-${process.pid}-${randomBytes(8).toString("hex")}`);
463
+ const env = { ...objectEnv, GIT_INDEX_FILE: indexPath };
464
+ try {
465
+ gitBuffer(input.cwd, ["read-tree", input.baseCommit], {
466
+ timeoutMs: remainingTimeout(deadline),
467
+ env,
468
+ });
469
+ return splitNul(gitBuffer(
470
+ input.cwd,
471
+ ["ls-files", "--others", "--exclude-standard", "-z"],
472
+ { timeoutMs: remainingTimeout(deadline), env },
473
+ ));
474
+ } finally {
475
+ try {
476
+ fs.rmSync(indexPath, { force: true });
477
+ } catch {
478
+ // ignore
479
+ }
480
+ }
481
+ }
482
+
483
+ interface FilesystemEntry {
484
+ mode: string;
485
+ path: Buffer;
486
+ content: Buffer;
487
+ }
488
+
489
+ function rawEntryFromFilesystem(
490
+ cwd: string,
491
+ relative: Buffer,
492
+ objectEnv: NodeJS.ProcessEnv,
493
+ deadline: number,
494
+ ): RawTreeEntry | undefined {
495
+ const entry = readFilesystemEntryNoFollow(cwd, relative);
496
+ if (!entry) return undefined;
497
+ const oid = gitLine(cwd, ["hash-object", "-w", "--no-filters", "--stdin"], {
498
+ timeoutMs: remainingTimeout(deadline),
499
+ env: objectEnv,
500
+ input: entry.content,
501
+ });
502
+ return { mode: entry.mode, type: "blob", oid, path: entry.path };
503
+ }
504
+
505
+ function readFilesystemEntryNoFollow(cwd: string, relative: Buffer): FilesystemEntry | undefined {
506
+ const components = splitPath(relative);
507
+ const root = Buffer.from(cwd);
508
+ const separator = Buffer.from(path.sep);
509
+ let target = root;
510
+ const traversed: Buffer[] = [];
511
+ for (let index = 0; index < components.length; index++) {
512
+ const component = components[index];
513
+ traversed.push(component);
514
+ target = Buffer.concat([target, separator, component]);
515
+ let stat: fs.Stats;
516
+ try {
517
+ stat = fs.lstatSync(target);
518
+ } catch (error: any) {
519
+ if (error?.code === "ENOENT") return undefined;
520
+ throw error;
521
+ }
522
+ const entryPath = Buffer.concat(interleave(traversed, Buffer.from("/")));
523
+ if (stat.isSymbolicLink()) {
524
+ return {
525
+ mode: "120000",
526
+ path: entryPath,
527
+ content: Buffer.from(fs.readlinkSync(target, { encoding: "buffer" }) as Buffer),
528
+ };
529
+ }
530
+ if (stat.isFile()) {
531
+ if (stat.size > MAX_ROLLBACK_SNAPSHOT_BYTES) {
532
+ throw new Error(`raw worktree file exceeds ${MAX_ROLLBACK_SNAPSHOT_BYTES} bytes`);
533
+ }
534
+ return {
535
+ mode: (stat.mode & 0o111) !== 0 ? "100755" : "100644",
536
+ path: entryPath,
537
+ content: fs.readFileSync(target),
538
+ };
539
+ }
540
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
541
+ if (index < components.length - 1) continue;
542
+ return undefined;
543
+ }
544
+ throw new Error(`unsupported filesystem node in worktree: ${entryPath.toString("utf8")}`);
545
+ }
546
+ return undefined;
547
+ }
548
+
549
+ function setRawTreeEntry(entries: Map<string, RawTreeEntry>, entry: RawTreeEntry): void {
550
+ for (const [key, existing] of entries) {
551
+ if (isDescendantPath(entry.path, existing.path)) return;
552
+ if (isDescendantPath(existing.path, entry.path)) entries.delete(key);
553
+ }
554
+ entries.set(entry.path.toString("base64"), entry);
555
+ }
556
+
557
+ function isDescendantPath(candidate: Buffer, parent: Buffer): boolean {
558
+ return candidate.length > parent.length
559
+ && candidate.subarray(0, parent.length).equals(parent)
560
+ && candidate[parent.length] === 0x2f;
561
+ }
562
+
563
+ function parseLsTree(value: Buffer): RawTreeEntry[] {
564
+ return splitNul(value).map((record) => {
565
+ const firstSpace = record.indexOf(0x20);
566
+ const secondSpace = firstSpace < 0 ? -1 : record.indexOf(0x20, firstSpace + 1);
567
+ const tab = secondSpace < 0 ? -1 : record.indexOf(0x09, secondSpace + 1);
568
+ if (firstSpace < 0 || secondSpace < 0 || tab < 0) {
569
+ throw new Error("git ls-tree returned malformed output");
570
+ }
571
+ const mode = record.subarray(0, firstSpace).toString("ascii");
572
+ const type = record.subarray(firstSpace + 1, secondSpace).toString("ascii");
573
+ const oid = record.subarray(secondSpace + 1, tab).toString("ascii");
574
+ if ((type !== "blob" && type !== "commit") || !/^[0-9a-f]+$/.test(oid)) {
575
+ throw new Error("git ls-tree returned an unsupported entry");
576
+ }
577
+ return { mode, type, oid, path: Buffer.from(record.subarray(tab + 1)) } as RawTreeEntry;
578
+ });
579
+ }
580
+
581
+ interface IndexEntry {
582
+ mode: string;
583
+ oid: string;
584
+ stage: string;
585
+ path: Buffer;
586
+ }
587
+
588
+ function parseIndexEntries(value: Buffer): IndexEntry[] {
589
+ return splitNul(value).map((record) => {
590
+ const tab = record.indexOf(0x09);
591
+ if (tab < 0) throw new Error("git ls-files returned malformed index output");
592
+ const fields = record.subarray(0, tab).toString("ascii").split(" ");
593
+ if (
594
+ fields.length !== 3
595
+ || !/^[0-7]{6}$/.test(fields[0] ?? "")
596
+ || !/^[0-9a-f]+$/.test(fields[1] ?? "")
597
+ || !/^[0-3]$/.test(fields[2] ?? "")
598
+ ) {
599
+ throw new Error("git ls-files returned malformed index entry");
600
+ }
601
+ return {
602
+ mode: fields[0]!,
603
+ oid: fields[1]!,
604
+ stage: fields[2]!,
605
+ path: Buffer.from(record.subarray(tab + 1)),
606
+ };
607
+ });
608
+ }
609
+
610
+ function splitNul(value: Buffer): Buffer[] {
611
+ const records: Buffer[] = [];
612
+ let start = 0;
613
+ for (let index = 0; index <= value.length; index++) {
614
+ if (index < value.length && value[index] !== 0) continue;
615
+ if (index > start) records.push(Buffer.from(value.subarray(start, index)));
616
+ start = index + 1;
617
+ }
618
+ return records;
619
+ }
620
+
621
+ function rawObjectEnvironment(objectDirectory: string, repositoryObjectDirectory: string): NodeJS.ProcessEnv {
622
+ return {
623
+ GIT_OBJECT_DIRECTORY: objectDirectory,
624
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjectDirectory,
625
+ };
626
+ }
627
+
628
+ function assertUnchangedGitlinks(worktree: Worktree, deadline: number): Set<string> {
629
+ const readTreeGitlinks = (treeish: string): Map<string, { oid: string; path: Buffer }> => {
630
+ const entries = parseLsTree(gitBuffer(
631
+ worktree.path,
632
+ ["ls-tree", "-r", "-z", "--full-tree", treeish],
633
+ { timeoutMs: remainingTimeout(deadline) },
634
+ ));
635
+ return new Map(entries
636
+ .filter((entry) => entry.mode === "160000")
637
+ .map((entry) => [entry.path.toString("base64"), { oid: entry.oid, path: entry.path }]));
638
+ };
639
+ const base = readTreeGitlinks(worktree.baseCommit);
640
+ const head = readTreeGitlinks("HEAD");
641
+ assertSameGitlinks(base, head, "committed");
642
+
643
+ const index = new Map<string, { oid: string; path: Buffer }>();
644
+ for (const entry of parseIndexEntries(gitBuffer(
645
+ worktree.path,
646
+ ["ls-files", "--stage", "-z"],
647
+ { timeoutMs: remainingTimeout(deadline) },
648
+ ))) {
649
+ const key = entry.path.toString("base64");
650
+ if (entry.stage !== "0") {
651
+ if (entry.mode === "160000" || base.has(key)) {
652
+ throw new Error("changed gitlink cannot be delivered; worktree must be preserved");
653
+ }
654
+ continue;
655
+ }
656
+ if (entry.mode === "160000") index.set(key, { oid: entry.oid, path: entry.path });
657
+ }
658
+ assertSameGitlinks(base, index, "staged");
659
+
660
+ const indexTags = new Map<string, string>();
661
+ for (const record of splitNul(gitBuffer(
662
+ worktree.path,
663
+ ["ls-files", "-v", "-z"],
664
+ { timeoutMs: remainingTimeout(deadline) },
665
+ ))) {
666
+ if (record.length < 3 || record[1] !== 0x20) {
667
+ throw new Error("git ls-files returned malformed flag output");
668
+ }
669
+ indexTags.set(record.subarray(2).toString("base64"), String.fromCharCode(record[0]!));
670
+ }
671
+ for (const [key] of base) {
672
+ if (indexTags.get(key) !== "H") {
673
+ throw new Error("gitlink index flags cannot hide submodule changes; worktree must be preserved");
674
+ }
675
+ }
676
+
677
+ const verifiedGitMetadata = new Set<string>();
678
+ for (const entry of base.values()) {
679
+ const submoduleRoot = resolveDirectoryNoFollow(worktree.path, entry.path);
680
+ if (!submoduleRoot) continue;
681
+ const submodulePath = submoduleRoot.toString();
682
+ if (!Buffer.from(submodulePath).equals(submoduleRoot)) {
683
+ throw new Error("submodule path encoding cannot be verified; worktree must be preserved");
684
+ }
685
+ try {
686
+ gitLine(submodulePath, ["rev-parse", "HEAD"], {
687
+ timeoutMs: remainingTimeout(deadline),
688
+ });
689
+ } catch {
690
+ if (fs.readdirSync(submoduleRoot).length === 0) continue;
691
+ throw new Error("uninitialized submodule contains files; worktree must be preserved");
692
+ }
693
+ assertInitializedRepositoryClean(
694
+ submodulePath,
695
+ entry.oid,
696
+ deadline,
697
+ 0,
698
+ verifiedGitMetadata,
699
+ entry.path,
700
+ );
701
+ }
702
+ return verifiedGitMetadata;
703
+ }
704
+
705
+ function assertInitializedRepositoryClean(
706
+ repoRoot: string,
707
+ expectedHead: string,
708
+ deadline: number,
709
+ depth: number,
710
+ verifiedGitMetadata: Set<string>,
711
+ relativeRepoRoot: Buffer,
712
+ ): void {
713
+ if (depth > 64) throw new Error("nested submodule depth cannot be verified; worktree must be preserved");
714
+ const discoveryEnv = {
715
+ GIT_WORK_TREE: repoRoot,
716
+ GIT_OPTIONAL_LOCKS: "0",
717
+ GIT_NO_REPLACE_OBJECTS: "1",
718
+ };
719
+ const gitDir = fs.realpathSync(gitLine(repoRoot, ["rev-parse", "--path-format=absolute", "--git-dir"], {
720
+ timeoutMs: remainingTimeout(deadline),
721
+ env: discoveryEnv,
722
+ }));
723
+ recordVerifiedGitMetadata(repoRoot, gitDir, relativeRepoRoot, verifiedGitMetadata);
724
+ const repositoryEnv = {
725
+ GIT_DIR: gitDir,
726
+ GIT_WORK_TREE: repoRoot,
727
+ GIT_OPTIONAL_LOCKS: "0",
728
+ GIT_NO_REPLACE_OBJECTS: "1",
729
+ };
730
+ const fixedConfig = [
731
+ "-c", "core.filemode=true",
732
+ "-c", "core.fsmonitor=false",
733
+ "-c", "core.ignoreStat=false",
734
+ ];
735
+ const run = (args: string[], input?: Buffer): Buffer => gitBuffer(
736
+ repoRoot,
737
+ [...fixedConfig, ...args],
738
+ { timeoutMs: remainingTimeout(deadline), env: repositoryEnv, input },
739
+ );
740
+ const line = (args: string[], input?: Buffer): string => stripOneLineEnding(run(args, input).toString("utf8"));
741
+
742
+ if (line(["rev-parse", "HEAD"]) !== expectedHead) {
743
+ throw new Error("changed submodule HEAD cannot be delivered; worktree must be preserved");
744
+ }
745
+ const expectedEntries = parseLsTree(run(["ls-tree", "-r", "-z", "--full-tree", expectedHead]));
746
+ const expectedByPath = new Map(expectedEntries.map((entry) => [entry.path.toString("base64"), entry]));
747
+ const indexEntries = parseIndexEntries(run(["ls-files", "--stage", "-z"]));
748
+ if (indexEntries.some((entry) => entry.stage !== "0") || indexEntries.length !== expectedEntries.length) {
749
+ throw new Error("changed submodule index cannot be delivered; worktree must be preserved");
750
+ }
751
+ for (const entry of indexEntries) {
752
+ const expected = expectedByPath.get(entry.path.toString("base64"));
753
+ if (!expected || expected.mode !== entry.mode || expected.oid !== entry.oid) {
754
+ throw new Error("changed submodule index cannot be delivered; worktree must be preserved");
755
+ }
756
+ }
757
+ for (const flag of ["-v", "-f"]) {
758
+ for (const record of splitNul(run(["ls-files", flag, "-z"]))) {
759
+ if (record.length < 3 || record[1] !== 0x20 || record[0] !== 0x48) {
760
+ throw new Error("submodule index flags cannot hide changes; worktree must be preserved");
761
+ }
762
+ }
763
+ }
764
+
765
+ for (const expected of expectedEntries) {
766
+ if (expected.mode === "160000") continue;
767
+ const actual = readFilesystemEntryNoFollow(repoRoot, expected.path);
768
+ if (!actual || !actual.path.equals(expected.path) || actual.mode !== expected.mode) {
769
+ throw new Error("dirty submodule cannot be delivered; worktree must be preserved");
770
+ }
771
+ const oid = line(["hash-object", "--no-filters", "--stdin"], actual.content);
772
+ if (oid !== expected.oid) {
773
+ throw new Error("dirty submodule cannot be delivered; worktree must be preserved");
774
+ }
775
+ }
776
+ if (run(["ls-files", "--others", "--exclude-standard", "-z"]).length > 0) {
777
+ throw new Error("dirty submodule cannot be delivered; worktree must be preserved");
778
+ }
779
+
780
+ for (const entry of expectedEntries.filter((candidate) => candidate.mode === "160000")) {
781
+ const nestedRoot = resolveDirectoryNoFollow(repoRoot, entry.path);
782
+ if (!nestedRoot || fs.readdirSync(nestedRoot).length === 0) continue;
783
+ const nestedPath = nestedRoot.toString();
784
+ if (!Buffer.from(nestedPath).equals(nestedRoot)) {
785
+ throw new Error("nested submodule path encoding cannot be verified; worktree must be preserved");
786
+ }
787
+ const nestedRelative = Buffer.concat([relativeRepoRoot, Buffer.from("/"), entry.path]);
788
+ assertInitializedRepositoryClean(
789
+ nestedPath,
790
+ entry.oid,
791
+ deadline,
792
+ depth + 1,
793
+ verifiedGitMetadata,
794
+ nestedRelative,
795
+ );
796
+ }
124
797
  }
125
798
 
126
- // GC cadence + staleness threshold for ultracode worktrees + patch files in tmpdir.
127
- const REAP_INTERVAL_MS = 60_000; // reap at most once per minute per process
128
- const STALE_WORKTREE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // entries older than this are reaped
129
- let lastReapAt = 0;
799
+ function recordVerifiedGitMetadata(
800
+ repoRoot: string,
801
+ gitDir: string,
802
+ relativeRepoRoot: Buffer,
803
+ verifiedGitMetadata: Set<string>,
804
+ ): void {
805
+ const metadataPath = path.join(repoRoot, ".git");
806
+ const stat = fs.lstatSync(metadataPath);
807
+ let resolved: string;
808
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
809
+ resolved = fs.realpathSync(metadataPath);
810
+ } else if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 4096) {
811
+ const match = fs.readFileSync(metadataPath, "utf8").trim().match(/^gitdir:\s+(.+)$/);
812
+ if (!match) throw new Error("submodule Git metadata cannot be verified; worktree must be preserved");
813
+ resolved = fs.realpathSync(path.resolve(repoRoot, match[1]));
814
+ } else {
815
+ throw new Error("submodule Git metadata cannot be verified; worktree must be preserved");
816
+ }
817
+ if (resolved !== gitDir) {
818
+ throw new Error("submodule Git metadata identity changed; worktree must be preserved");
819
+ }
820
+ const relative = Buffer.concat([relativeRepoRoot, Buffer.from("/.git")]);
821
+ verifiedGitMetadata.add(relative.toString("base64"));
822
+ }
130
823
 
131
- /** Generate a unique tmp path for a patch file. Uses crypto-strong randomness
132
- * (not a per-realm counter) so the name is collision-proof across calls,
133
- * milliseconds, processes, AND worker threads (which share a pid but have
134
- * separate module realms — a per-realm counter would collide on call 0). */
824
+ function resolveDirectoryNoFollow(cwd: string, relative: Buffer): Buffer | undefined {
825
+ const components = splitPath(relative);
826
+ const separator = Buffer.from(path.sep);
827
+ let target = Buffer.from(cwd);
828
+ for (const component of components) {
829
+ target = Buffer.concat([target, separator, component]);
830
+ let stat: fs.Stats;
831
+ try {
832
+ stat = fs.lstatSync(target);
833
+ } catch (error: any) {
834
+ if (error?.code === "ENOENT") return undefined;
835
+ throw error;
836
+ }
837
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
838
+ throw new Error("submodule path contains a non-directory ancestor; worktree must be preserved");
839
+ }
840
+ }
841
+ return target;
842
+ }
843
+
844
+ function assertSameGitlinks(
845
+ base: Map<string, { oid: string; path: Buffer }>,
846
+ candidate: Map<string, { oid: string; path: Buffer }>,
847
+ state: string,
848
+ ): void {
849
+ if (base.size === candidate.size && [...base].every(([key, entry]) => candidate.get(key)?.oid === entry.oid)) {
850
+ return;
851
+ }
852
+ throw new Error(`${state} gitlink changes cannot be delivered; worktree must be preserved`);
853
+ }
854
+
855
+ function assertNoUnsupportedFilesystemNodes(
856
+ worktree: Worktree,
857
+ deadline: number,
858
+ verifiedGitMetadata: ReadonlySet<string>,
859
+ ): void {
860
+ const cwd = worktree.path;
861
+ const root = Buffer.from(cwd);
862
+ const stack: Array<{ absolute: Buffer; relative: Buffer }> = [{ absolute: root, relative: Buffer.alloc(0) }];
863
+ const special: Buffer[] = [];
864
+ let visited = 0;
865
+ while (stack.length > 0) {
866
+ if (Date.now() >= deadline) throw new Error("worktree filesystem scan exceeded its deadline");
867
+ const directory = stack.pop()!;
868
+ const children = fs.readdirSync(directory.absolute, {
869
+ encoding: "buffer",
870
+ withFileTypes: true,
871
+ }) as unknown as Array<fs.Dirent<Buffer>>;
872
+ for (const child of children) {
873
+ visited++;
874
+ if (visited > 200_000) {
875
+ throw new Error("worktree filesystem scan exceeds 200000 nodes; worktree must be preserved");
876
+ }
877
+ const name = Buffer.from(child.name);
878
+ if (directory.relative.length === 0 && name.equals(Buffer.from(".git"))) continue;
879
+ const separator = Buffer.from(path.sep);
880
+ const absolute = Buffer.concat([directory.absolute, separator, name]);
881
+ const relative = directory.relative.length === 0
882
+ ? name
883
+ : Buffer.concat([directory.relative, Buffer.from("/"), name]);
884
+ const stat = fs.lstatSync(absolute);
885
+ if (name.equals(Buffer.from(".git"))) {
886
+ if (verifiedGitMetadata.has(relative.toString("base64"))) continue;
887
+ special.push(relative);
888
+ } else if (stat.isDirectory() && !stat.isSymbolicLink()) {
889
+ stack.push({ absolute, relative });
890
+ } else if (!stat.isFile() && !stat.isSymbolicLink()) {
891
+ special.push(relative);
892
+ }
893
+ }
894
+ }
895
+ if (special.length === 0) return;
896
+
897
+ const input = Buffer.concat(special.flatMap((relative) => [relative, Buffer.from([0])]));
898
+ let ignoredOutput: Buffer;
899
+ try {
900
+ ignoredOutput = gitBuffer(cwd, ["check-ignore", "--no-index", "-z", "--stdin"], {
901
+ timeoutMs: remainingTimeout(deadline),
902
+ input,
903
+ });
904
+ } catch (error: any) {
905
+ if (error?.status !== 1) throw error;
906
+ ignoredOutput = Buffer.isBuffer(error?.stdout) ? error.stdout : Buffer.alloc(0);
907
+ }
908
+ const ignored = new Set(splitNul(ignoredOutput).map((relative) => relative.toString("base64")));
909
+ const unsupported = special.find((relative) => !ignored.has(relative.toString("base64")));
910
+ if (unsupported) {
911
+ throw new Error(`unsupported filesystem node in worktree: ${unsupported.toString("utf8")}`);
912
+ }
913
+ }
914
+
915
+ function assertCleanSubmodules(worktree: Worktree, deadline: number): void {
916
+ const status = gitBuffer(worktree.path, [
917
+ "status",
918
+ "--porcelain=v2",
919
+ "-z",
920
+ "--untracked-files=all",
921
+ "--ignore-submodules=none",
922
+ ], { timeoutMs: remainingTimeout(deadline) });
923
+ for (const record of splitNul(status)) {
924
+ if (record[0] !== 0x31 && record[0] !== 0x32) continue;
925
+ const fields = record.toString("latin1").split(" ");
926
+ const xy = fields[1];
927
+ const submodule = fields[2];
928
+ const touchesGitlink = fields.slice(3, 6).includes("160000");
929
+ if (
930
+ (submodule?.startsWith("S") && submodule !== "S...")
931
+ || (touchesGitlink && xy !== "..")
932
+ ) {
933
+ throw new Error("dirty or changed submodule cannot be delivered; worktree must be preserved");
934
+ }
935
+ }
936
+ }
937
+
938
+ /** Generate a collision-resistant private patch path. */
135
939
  export function patchTmpPath(): string {
136
940
  return path.join(os.tmpdir(), `ultracode-patch-${process.pid}-${randomBytes(8).toString("hex")}.patch`);
137
941
  }
138
942
 
139
- /** Apply a captured patch back onto the original working tree. */
140
- export function applyPatch(cwd: string, patch: string): boolean {
141
- if (!patch.trim()) return false;
142
- const paths = patchedFiles(patch);
143
- // Snapshot the pre-apply working-tree content of every path the patch touches,
144
- // so a failed apply (which leaves conflict markers + an unmerged index) can be
145
- // reverted to exactly this state, preserving any pre-existing uncommitted edits.
146
- const before = new Map<string, Buffer | null>();
147
- for (const p of paths) {
943
+ /**
944
+ * Apply a patch without touching the target repository's index.
945
+ *
946
+ * We deliberately avoid `--3way`: it mutates the real index on conflicts and
947
+ * makes exact rollback of staged state, modes, and symlinks impossible.
948
+ */
949
+ export function applyPatch(
950
+ repoRoot: string,
951
+ patchInput: PatchInput,
952
+ timeoutMs = DEFAULT_GIT_TIMEOUT_MS,
953
+ ): boolean {
954
+ const patch = withTrailingNewline(patchInput);
955
+ if (patch.length === 0 || containsGitlink(patch)) return false;
956
+ const tmp = patchTmpPath();
957
+ const deadline = Date.now() + normalizeTimeout(timeoutMs);
958
+ const targetRoot = fs.realpathSync(repoRoot);
959
+ let repository: PatchRepository | undefined;
960
+ let attempted = false;
961
+ let snapshot: ApplySnapshot | undefined;
962
+ try {
963
+ fs.writeFileSync(tmp, patch, { flag: "wx", mode: 0o600 });
964
+ repository = createPatchRepository(targetRoot, deadline);
965
+ const reportedPaths = readPatchPaths(repository, tmp);
966
+ const postModes = parsePatchPostModes(patch, reportedPaths);
967
+ const touchedPaths = deduplicatePaths(reportedPaths);
968
+ fs.writeFileSync(tmp, reorderPatchSections(patch, reportedPaths));
969
+ populatePatchIndex(repository, targetRoot, touchedPaths);
970
+ runPatchGit(repository, patchApplyArgs(["--check", tmp]));
971
+ snapshot = snapshotApplyPaths(targetRoot, touchedPaths);
972
+ runPatchGit(repository, patchApplyArgs([tmp]));
973
+ attempted = true;
974
+ materializePatchIndex(repository, targetRoot, touchedPaths);
975
+ preserveUnrepresentedFileModes(targetRoot, snapshot);
976
+ assertPatchPostModes(targetRoot, postModes);
977
+ assertPatchPostContents(repository, targetRoot, patch, reportedPaths);
978
+ fsyncAppliedPaths(targetRoot, touchedPaths);
979
+ return true;
980
+ } catch (error) {
981
+ if (attempted && snapshot) {
982
+ try {
983
+ restoreApplySnapshot(repoRoot, snapshot);
984
+ } catch (rollbackError) {
985
+ throw new Error(
986
+ `patch application reached an uncertain state and rollback failed: ${errorMessage(rollbackError)}`,
987
+ { cause: error },
988
+ );
989
+ }
990
+ }
991
+ return false;
992
+ } finally {
993
+ removePatchRepository(repository);
148
994
  try {
149
- before.set(p, fs.readFileSync(path.join(cwd, p)));
995
+ fs.unlinkSync(tmp);
150
996
  } catch {
151
- before.set(p, null); // path does not exist yet (the patch adds it)
997
+ // ignore
152
998
  }
153
999
  }
154
- // Unique per call (see patchTmpPath): previously the name only depended on pid
155
- // + cwd.length, so concurrent applies (worker threads sharing a pid, or a
156
- // future async apply) with equal-length cwds collided on the same tmp file.
1000
+ }
1001
+
1002
+ /** Verify that retained durable delivery material is still present in the target tree. */
1003
+ export function verifyAppliedPatch(
1004
+ repoRoot: string,
1005
+ patchPath: string,
1006
+ expectedHash: string,
1007
+ trustedPatchRoot: string,
1008
+ timeoutMs = DEFAULT_GIT_TIMEOUT_MS,
1009
+ ): boolean {
1010
+ const patch = readContainedArtifactBytes(
1011
+ trustedPatchRoot,
1012
+ patchPath,
1013
+ "workflow delivery patch",
1014
+ MAX_GIT_OUTPUT_BYTES,
1015
+ );
1016
+ const actualHash = createHash("sha256").update(patch).digest("hex");
1017
+ if (actualHash !== expectedHash || containsGitlink(patch)) {
1018
+ throw new Error("workflow delivery patch is missing, changed, or unsupported");
1019
+ }
157
1020
  const tmp = patchTmpPath();
1021
+ const deadline = Date.now() + normalizeTimeout(timeoutMs);
1022
+ const targetRoot = fs.realpathSync(repoRoot);
1023
+ let repository: PatchRepository | undefined;
158
1024
  try {
159
- fs.writeFileSync(tmp, patch.endsWith("\n") ? patch : `${patch}\n`);
160
- git(cwd, ["apply", "--3way", tmp]);
1025
+ fs.writeFileSync(tmp, patch, { flag: "wx", mode: 0o600 });
1026
+ repository = createPatchRepository(targetRoot, deadline);
1027
+ const reportedPaths = readPatchPaths(repository, tmp);
1028
+ const postModes = parsePatchPostModes(patch, reportedPaths);
1029
+ assertPatchPostModes(targetRoot, postModes);
1030
+ assertPatchPostContents(repository, targetRoot, patch, reportedPaths);
161
1031
  return true;
162
- } catch {
163
- // `git apply --3way` on a real conflict writes conflict markers into the file
164
- // and leaves an unmerged (UU) index entry; `git checkout --` then refuses with
165
- // "path is unmerged". Reset the index entry and restore the pre-apply content.
166
- revertPatchedPaths(cwd, paths, before);
1032
+ } catch (error: any) {
1033
+ if (error?.code === "ENOENT" || /delivery patch/.test(errorMessage(error))) throw error;
167
1034
  return false;
168
1035
  } finally {
1036
+ removePatchRepository(repository);
169
1037
  try {
170
1038
  fs.unlinkSync(tmp);
171
1039
  } catch {
@@ -174,210 +1042,1063 @@ export function applyPatch(cwd: string, patch: string): boolean {
174
1042
  }
175
1043
  }
176
1044
 
177
- /** Every path a patch touches (adds, modifies, deletes, renames). Parses the
178
- * `+++ b/<path>`, `--- a/<path>`, and `diff --git a/<x> b/<y>` headers, skipping
179
- * `/dev/null`. Deletions (`+++ /dev/null`) are captured via their `--- a/` side
180
- * so a failed apply can restore the deleted file too. */
181
- function patchedFiles(patch: string): string[] {
182
- const out: string[] = [];
183
- const seen = new Set<string>();
184
- const push = (p: string) => {
185
- if (p && p !== "/dev/null" && !seen.has(p)) {
186
- seen.add(p);
187
- out.push(p);
1045
+ interface PatchPostMode {
1046
+ path: Buffer;
1047
+ mode: "100644" | "100755" | "120000" | null;
1048
+ }
1049
+
1050
+ function parsePatchPostModes(patch: Buffer, paths: Buffer[]): PatchPostMode[] {
1051
+ const sections: Array<PatchPostMode["mode"] | undefined> = [];
1052
+ let section = -1;
1053
+ let inHeader = false;
1054
+ for (const line of patch.toString("latin1").split("\n")) {
1055
+ if (line.startsWith("diff --git ")) {
1056
+ sections.push(undefined);
1057
+ section++;
1058
+ inHeader = true;
1059
+ continue;
188
1060
  }
189
- };
190
- for (const line of patch.split("\n")) {
191
- let m = line.match(/^\+\+\+ b\/(.+)$/);
192
- if (m) {
193
- push(m[1]);
1061
+ if (!inHeader || section < 0) continue;
1062
+ let mode: string | null | undefined;
1063
+ if (line.startsWith("new file mode ")) mode = line.slice("new file mode ".length);
1064
+ else if (line.startsWith("new mode ")) mode = line.slice("new mode ".length);
1065
+ else if (line.startsWith("deleted file mode ")) mode = null;
1066
+ else if (line.startsWith("--- ") || line === "GIT binary patch" || line.startsWith("@@ ")) {
1067
+ inHeader = false;
194
1068
  continue;
195
1069
  }
196
- m = line.match(/^--- a\/(.+)$/);
197
- if (m) {
198
- push(m[1]);
1070
+ if (mode !== undefined) {
1071
+ if (mode !== null && !["100644", "100755", "120000"].includes(mode)) {
1072
+ throw new Error(`patch contains unsupported post-apply mode: ${mode}`);
1073
+ }
1074
+ sections[section] = mode as PatchPostMode["mode"];
1075
+ }
1076
+ }
1077
+ if (sections.length !== paths.length) {
1078
+ throw new Error("patch sections do not match NUL-delimited changed paths");
1079
+ }
1080
+ const byPath = new Map<string, PatchPostMode>();
1081
+ for (let index = 0; index < sections.length; index++) {
1082
+ const mode = sections[index];
1083
+ if (mode === undefined) continue;
1084
+ const entryPath = Buffer.from(paths[index]!);
1085
+ byPath.set(entryPath.toString("base64"), { path: entryPath, mode });
1086
+ }
1087
+ return [...byPath.values()];
1088
+ }
1089
+
1090
+ function reorderPatchSections(patch: Buffer, paths: Buffer[]): Buffer {
1091
+ const { prefix, sections: values } = splitPatchSections(patch, paths.length);
1092
+ const sections = values.map((value, index) => {
1093
+ const headerEndCandidates = ["\n--- ", "\nGIT binary patch", "\n@@ "]
1094
+ .map((needle) => value.indexOf(Buffer.from(needle)))
1095
+ .filter((candidate) => candidate >= 0);
1096
+ const headerEnd = headerEndCandidates.length > 0 ? Math.min(...headerEndCandidates) : value.length;
1097
+ const header = value.subarray(0, headerEnd).toString("latin1");
1098
+ const deletion = /^deleted file mode /m.test(header);
1099
+ return { value, path: paths[index]!, deletion, index };
1100
+ });
1101
+ sections.sort((left, right) => {
1102
+ if (left.deletion !== right.deletion) return left.deletion ? -1 : 1;
1103
+ const depthDifference = left.deletion
1104
+ ? pathDepth(right.path) - pathDepth(left.path)
1105
+ : pathDepth(left.path) - pathDepth(right.path);
1106
+ return depthDifference || left.index - right.index;
1107
+ });
1108
+ return Buffer.concat([prefix, ...sections.map((section) => section.value)]);
1109
+ }
1110
+
1111
+ function deduplicatePaths(paths: Buffer[]): Buffer[] {
1112
+ const unique = new Map<string, Buffer>();
1113
+ for (const entry of paths) {
1114
+ const key = entry.toString("base64");
1115
+ if (!unique.has(key)) unique.set(key, Buffer.from(entry));
1116
+ }
1117
+ return [...unique.values()];
1118
+ }
1119
+
1120
+ function preserveUnrepresentedFileModes(repoRoot: string, snapshot: ApplySnapshot): void {
1121
+ for (const entry of snapshot.paths) {
1122
+ if (entry.kind !== "file") continue;
1123
+ const actual = readFilesystemEntryNoFollow(repoRoot, entry.relative);
1124
+ if (
1125
+ !actual
1126
+ || !actual.path.equals(entry.relative)
1127
+ || (actual.mode !== "100644" && actual.mode !== "100755")
1128
+ ) continue;
1129
+ let stat: fs.Stats;
1130
+ try {
1131
+ stat = fs.lstatSync(entry.path);
1132
+ } catch (error: any) {
1133
+ if (["ENOENT", "ENOTDIR"].includes(error?.code)) continue;
1134
+ throw error;
1135
+ }
1136
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
1137
+ const before = entry.mode & 0o7777;
1138
+ const after = stat.mode & 0o7777;
1139
+ const beforeExecutable = (before & 0o111) !== 0;
1140
+ const afterExecutable = (after & 0o111) !== 0;
1141
+ const desired = beforeExecutable === afterExecutable
1142
+ ? before
1143
+ : (before & ~0o111) | (after & 0o111);
1144
+ if (desired !== after) fs.chmodSync(entry.path, desired);
1145
+ }
1146
+ }
1147
+
1148
+ function assertPatchPostModes(repoRoot: string, expectations: PatchPostMode[]): void {
1149
+ for (const expectation of expectations) {
1150
+ const actual = readFilesystemEntryNoFollow(repoRoot, expectation.path);
1151
+ if (expectation.mode === null) {
1152
+ if (actual?.path.equals(expectation.path)) {
1153
+ throw new Error("applied patch did not preserve its deleted path postcondition");
1154
+ }
199
1155
  continue;
200
1156
  }
201
- m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
202
- if (m) {
203
- push(m[1]);
204
- push(m[2]);
1157
+ if (!actual || !actual.path.equals(expectation.path) || actual.mode !== expectation.mode) {
1158
+ throw new Error("applied patch mode postcondition is not present in the target tree");
205
1159
  }
206
1160
  }
207
- return out;
208
1161
  }
209
1162
 
210
- /** Restore patched paths to their pre-apply state, clearing conflict markers + UU index. */
211
- function revertPatchedPaths(
212
- cwd: string,
213
- paths: string[],
214
- before: Map<string, Buffer | null>,
1163
+ function assertPatchPostContents(
1164
+ repository: PatchRepository,
1165
+ repoRoot: string,
1166
+ patch: Buffer,
1167
+ paths: Buffer[],
215
1168
  ): void {
216
- for (const p of paths) {
217
- // Clear any unmerged index entry left by `git apply --3way` (idempotent).
218
- tryGit(cwd, ["reset", "HEAD", "--", p]);
219
- const prev = before.get(p);
220
- if (prev == null) {
221
- // Path did not exist before apply; remove any marker file the conflict wrote.
222
- try {
223
- fs.rmSync(path.join(cwd, p), { force: true });
224
- } catch {
225
- // ignore
226
- }
227
- } else {
228
- try {
229
- fs.writeFileSync(path.join(cwd, p), prev);
230
- } catch {
231
- // best-effort revert
1169
+ const sections = splitPatchSections(patch, paths.length).sections;
1170
+ const oidLength = repository.objectFormat === "sha256" ? 64 : 40;
1171
+ const byPath = new Map<string, { path: Buffer; oid: string | null }>();
1172
+ for (let index = 0; index < sections.length; index++) {
1173
+ const text = sections[index]!.toString("latin1");
1174
+ const match = text.match(/^index ([0-9a-f]+)\.\.([0-9a-f]+)(?: [0-7]{6})?$/m);
1175
+ const hasContent = /\n(?:--- |GIT binary patch)/.test(text);
1176
+ if (!match) {
1177
+ if (hasContent) throw new Error("delivery patch content lacks a full index postcondition");
1178
+ continue;
1179
+ }
1180
+ const nextOid = match[2]!;
1181
+ if (match[1]!.length > oidLength || nextOid.length > oidLength) {
1182
+ throw new Error("delivery patch does not use the target repository object format");
1183
+ }
1184
+ const entryPath = Buffer.from(paths[index]!);
1185
+ byPath.set(entryPath.toString("base64"), {
1186
+ path: entryPath,
1187
+ oid: /^0+$/.test(nextOid) ? null : nextOid,
1188
+ });
1189
+ }
1190
+ for (const expectation of byPath.values()) {
1191
+ const actual = readFilesystemEntryNoFollow(repoRoot, expectation.path);
1192
+ if (expectation.oid === null) {
1193
+ if (actual?.path.equals(expectation.path)) {
1194
+ throw new Error("applied patch deleted-content postcondition is not present");
232
1195
  }
1196
+ continue;
1197
+ }
1198
+ if (!actual || !actual.path.equals(expectation.path)) {
1199
+ throw new Error("applied patch content postcondition is not present");
1200
+ }
1201
+ const oid = stripOneLineEnding(runPatchGit(repository, [
1202
+ "hash-object",
1203
+ "--no-filters",
1204
+ "--stdin",
1205
+ ], actual.content).toString("ascii"));
1206
+ if (!oid.startsWith(expectation.oid)) {
1207
+ throw new Error("applied patch content postcondition changed in the target tree");
233
1208
  }
234
1209
  }
235
1210
  }
236
1211
 
237
- /**
238
- * Persist a patch that could not be auto-applied (3-way conflict) to a durable
239
- * file under `<dir>/<runId>-<id>-<label>.patch`. Called before the worktree is
240
- * force-removed so the agent's changes are recoverable instead of lost. Includes
241
- * the agent sequence id so two same-label agents in one run don't overwrite each
242
- * other's rescue patch.
243
- */
1212
+ function splitPatchSections(
1213
+ patch: Buffer,
1214
+ expectedCount: number,
1215
+ ): { prefix: Buffer; sections: Buffer[] } {
1216
+ const marker = Buffer.from("diff --git ");
1217
+ const newlineMarker = Buffer.from("\ndiff --git ");
1218
+ const starts: number[] = [];
1219
+ if (patch.subarray(0, marker.length).equals(marker)) starts.push(0);
1220
+ let offset = 0;
1221
+ while (true) {
1222
+ const index = patch.indexOf(newlineMarker, offset);
1223
+ if (index < 0) break;
1224
+ starts.push(index + 1);
1225
+ offset = index + newlineMarker.length;
1226
+ }
1227
+ if (starts.length !== expectedCount) {
1228
+ throw new Error("patch sections do not match NUL-delimited changed paths");
1229
+ }
1230
+ return {
1231
+ prefix: starts.length > 0 ? Buffer.from(patch.subarray(0, starts[0])) : Buffer.from(patch),
1232
+ sections: starts.map((start, index) => Buffer.from(
1233
+ patch.subarray(start, index + 1 < starts.length ? starts[index + 1]! : patch.length),
1234
+ )),
1235
+ };
1236
+ }
1237
+
244
1238
  export function writeRescuePatch(
245
1239
  dir: string,
246
1240
  runId: string,
247
1241
  id: number,
248
1242
  label: string,
249
- patch: string,
1243
+ patchInput: PatchInput,
250
1244
  ): string {
251
1245
  ensurePrivateArtifactDirectory(dir);
252
1246
  const safeRun = runId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 24) || "run";
253
1247
  const safeLabel = label.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) || "agent";
254
1248
  const nonce = randomBytes(6).toString("hex");
255
1249
  const file = path.join(dir, `${safeRun}-${id}-${safeLabel}-${nonce}.patch`);
256
- writeArtifactFile(file, patch.endsWith("\n") ? patch : `${patch}\n`, { trustedRoot: dir });
1250
+ writeArtifactFile(file, withTrailingNewline(patchInput), { trustedRoot: dir });
257
1251
  return file;
258
1252
  }
259
1253
 
1254
+ interface DirectoryIdentity {
1255
+ device: number;
1256
+ inode: number;
1257
+ }
1258
+
1259
+ interface RegistrationIdentity extends DirectoryIdentity {
1260
+ gitDir: string;
1261
+ commonDir: string;
1262
+ worktreePath: string;
1263
+ }
1264
+
1265
+ /** Remove only the supplied owned worktree from its canonical repository. */
260
1266
  export function removeWorktree(worktree: Worktree): void {
261
- const toplevel = tryGit(worktree.path, ["rev-parse", "--show-toplevel"]);
262
- const repo = toplevel ?? path.dirname(worktree.path);
263
- removeWorktreeQuiet(repo, worktree.path, worktree.branch);
1267
+ const root = directoryIdentity(worktree.path);
1268
+ if (root && (root.device !== worktree.device || root.inode !== worktree.inode)) return;
1269
+ if (!root && pathExistsNoFollow(worktree.path)) return;
1270
+
1271
+ const adminParent = path.dirname(worktree.gitDir);
1272
+ const savedAdminIdentity = { device: worktree.gitDirDevice, inode: worktree.gitDirInode };
1273
+ let registration: RegistrationIdentity | undefined;
1274
+ if (root) {
1275
+ const discovered = discoverRegistrationAtPath(worktree.path, worktree.gitCommonDir);
1276
+ if (discovered) {
1277
+ if (
1278
+ discovered.device !== worktree.gitDirDevice
1279
+ || discovered.inode !== worktree.gitDirInode
1280
+ || !samePotentialPath(path.dirname(discovered.gitDir), adminParent)
1281
+ ) return;
1282
+ registration = discovered;
1283
+ } else {
1284
+ const search = findDirectoryByIdentity(adminParent, savedAdminIdentity);
1285
+ if (search.state === "unverifiable") return;
1286
+ if (search.state === "found") return;
1287
+ if (pathExistsNoFollow(path.join(worktree.path, ".git"))) return;
1288
+ }
1289
+ } else {
1290
+ const search = findDirectoryByIdentity(adminParent, savedAdminIdentity);
1291
+ if (search.state === "unverifiable") return;
1292
+ if (search.state === "found") {
1293
+ registration = validateRegistrationDirectory(
1294
+ search.path,
1295
+ worktree.gitCommonDir,
1296
+ worktree.path,
1297
+ savedAdminIdentity,
1298
+ );
1299
+ if (!registration) return;
1300
+ }
1301
+ }
1302
+
1303
+ if (registration && !unregisterRegistration(worktree.repoRoot, registration, worktree.path)) return;
1304
+ if (root) tryRemoveOwnedDirectory(worktree.path, root);
1305
+ const raw = directoryIdentity(worktree.rawObjectDirectory);
1306
+ if (
1307
+ raw
1308
+ && raw.device === worktree.rawObjectDevice
1309
+ && raw.inode === worktree.rawObjectInode
1310
+ && ownsRawObjectDirectory(worktree)
1311
+ ) {
1312
+ tryRemoveOwnedDirectory(worktree.rawObjectDirectory, raw);
1313
+ }
264
1314
  }
265
1315
 
266
- function removeWorktreeQuiet(repo: string, worktreePath: string, branch: string): void {
267
- tryGit(repo, ["worktree", "remove", "--force", worktreePath]);
268
- // worktree remove may fail if the dir was deleted manually; prune + rmrf to be safe.
269
- tryGit(repo, ["worktree", "prune"]);
1316
+ function discoverRegistrationAtPath(
1317
+ worktreePath: string,
1318
+ expectedCommonDir: string,
1319
+ ): RegistrationIdentity | undefined {
1320
+ const gitDirPointer = readWorktreeGitDirPointer(worktreePath);
1321
+ if (!gitDirPointer) return undefined;
1322
+ let gitDir: string;
270
1323
  try {
271
- fs.rmSync(worktreePath, { recursive: true, force: true });
1324
+ gitDir = fs.realpathSync(gitDirPointer);
272
1325
  } catch {
273
- // ignore
1326
+ return undefined;
274
1327
  }
275
- tryGit(repo, ["branch", "-D", branch]);
1328
+ return validateRegistrationDirectory(gitDir, expectedCommonDir, worktreePath);
276
1329
  }
277
1330
 
278
- /**
279
- * Reap stale ultracode worktrees + leaked patch files from tmpdir. Entries
280
- * older than the staleness threshold (24h default) are removed: tracked
281
- * worktrees (registered with `toplevel`) via full `git worktree remove` + branch
282
- * delete; untracked `ultracode-wt-*` dirs via rmSync; `ultracode-patch-*` files
283
- * via unlink.
284
- *
285
- * Limitation: `git worktree list` is repo-scoped, so a worktree owned by a
286
- * DIFFERENT repo is "untracked" here and is rmSync'd after the threshold (its
287
- * branch in the owning repo is NOT cleaned — there is no safe way to find the
288
- * owner from the dir alone). The same 24h threshold applies to untracked dirs as
289
- * to tracked worktrees, so cross-repo in-flight and kept worktrees survive as
290
- * long as same-repo ones. If git can't list worktrees, the reap bails safe
291
- * rather than treating every entry as orphaned.
292
- *
293
- * Exported for tests; createWorktree calls this rate-limited.
294
- */
295
- export function reapStaleWorktrees(
296
- toplevel: string,
297
- opts: { maxAgeMs?: number } = {},
298
- ): void {
299
- const maxAgeMs = opts.maxAgeMs ?? STALE_WORKTREE_MAX_AGE_MS;
300
- // Drop git's tracking of worktrees whose dirs are already gone.
301
- tryGit(toplevel, ["worktree", "prune"]);
302
- // Bail safe if we can't list worktrees: treating every entry as orphaned would
303
- // destroy in-flight/kept worktrees from other repos (git worktree list is
304
- // repo-scoped, so only the current repo's worktrees are `tracked` below).
305
- const list = tryGit(toplevel, ["worktree", "list", "--porcelain"]);
306
- if (list == null) return;
307
- const canonicalPath = (value: string): string => {
308
- try {
309
- return fs.realpathSync(value);
310
- } catch {
311
- return path.resolve(value);
1331
+ function readWorktreeGitDirPointer(worktreePath: string): string | undefined {
1332
+ try {
1333
+ const dotGit = path.join(worktreePath, ".git");
1334
+ const stat = fs.lstatSync(dotGit);
1335
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return undefined;
1336
+ const match = fs.readFileSync(dotGit, "utf8").trim().match(/^gitdir:\s+(.+)$/);
1337
+ if (!match) return undefined;
1338
+ return path.resolve(worktreePath, match[1]);
1339
+ } catch {
1340
+ return undefined;
1341
+ }
1342
+ }
1343
+
1344
+ function validateRegistrationDirectory(
1345
+ gitDir: string,
1346
+ expectedCommonDir: string,
1347
+ expectedWorktreePath: string,
1348
+ expectedIdentity?: DirectoryIdentity,
1349
+ ): RegistrationIdentity | undefined {
1350
+ try {
1351
+ const stat = fs.lstatSync(gitDir);
1352
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return undefined;
1353
+ if (expectedIdentity && (
1354
+ stat.dev !== expectedIdentity.device || stat.ino !== expectedIdentity.inode
1355
+ )) return undefined;
1356
+ const commonFile = path.join(gitDir, "commondir");
1357
+ const commonStat = fs.lstatSync(commonFile);
1358
+ if (!commonStat.isFile() || commonStat.isSymbolicLink() || commonStat.size > 4096) return undefined;
1359
+ const commonPath = fs.readFileSync(commonFile, "utf8").trim();
1360
+ const commonDir = fs.realpathSync(path.resolve(gitDir, commonPath));
1361
+ if (commonDir !== expectedCommonDir) return undefined;
1362
+ const registeredPath = readRegisteredWorktreePath(gitDir);
1363
+ if (!registeredPath || !samePotentialPath(registeredPath, expectedWorktreePath)) return undefined;
1364
+ return { gitDir, commonDir, device: stat.dev, inode: stat.ino, worktreePath: registeredPath };
1365
+ } catch {
1366
+ return undefined;
1367
+ }
1368
+ }
1369
+
1370
+ function readRegisteredWorktreePath(gitDir: string): string | undefined {
1371
+ try {
1372
+ const pointer = path.join(gitDir, "gitdir");
1373
+ const stat = fs.lstatSync(pointer);
1374
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return undefined;
1375
+ const dotGit = path.resolve(gitDir, fs.readFileSync(pointer, "utf8").trim());
1376
+ if (path.basename(dotGit) !== ".git") return undefined;
1377
+ return path.dirname(dotGit);
1378
+ } catch {
1379
+ return undefined;
1380
+ }
1381
+ }
1382
+
1383
+ type DirectoryIdentitySearch =
1384
+ | { state: "found"; path: string }
1385
+ | { state: "not-found" }
1386
+ | { state: "unverifiable" };
1387
+
1388
+ function findDirectoryByIdentity(parent: string, identity: DirectoryIdentity): DirectoryIdentitySearch {
1389
+ try {
1390
+ for (const name of fs.readdirSync(parent)) {
1391
+ const candidate = path.join(parent, name);
1392
+ const current = directoryIdentity(candidate);
1393
+ if (current?.device === identity.device && current.inode === identity.inode) {
1394
+ return { state: "found", path: candidate };
1395
+ }
312
1396
  }
313
- };
314
- const tracked = new Set(
315
- list
316
- .split("\n")
317
- .filter((l) => l.startsWith("worktree "))
318
- .map((l) => canonicalPath(l.slice("worktree ".length).trim())),
1397
+ return { state: "not-found" };
1398
+ } catch {
1399
+ return { state: "unverifiable" };
1400
+ }
1401
+ }
1402
+
1403
+ function unregisterRegistration(
1404
+ repoRoot: string,
1405
+ registration: RegistrationIdentity,
1406
+ expectedPath: string,
1407
+ ): boolean {
1408
+ const current = validateRegistrationDirectory(
1409
+ registration.gitDir,
1410
+ registration.commonDir,
1411
+ expectedPath,
1412
+ registration,
319
1413
  );
320
- let entries: string[];
1414
+ if (!current) return !pathExistsNoFollow(registration.gitDir);
1415
+ tryGit(repoRoot, ["worktree", "remove", "--force", "--force", current.worktreePath]);
1416
+ const remaining = directoryIdentity(registration.gitDir);
1417
+ return !remaining && !pathExistsNoFollow(registration.gitDir);
1418
+ }
1419
+
1420
+ function samePotentialPath(left: string, right: string): boolean {
321
1421
  try {
322
- entries = fs.readdirSync(os.tmpdir());
1422
+ return canonicalPotentialPath(left) === canonicalPotentialPath(right);
323
1423
  } catch {
324
- return;
1424
+ return false;
1425
+ }
1426
+ }
1427
+
1428
+ function canonicalPotentialPath(value: string): string {
1429
+ try {
1430
+ return fs.realpathSync(value);
1431
+ } catch (error: any) {
1432
+ if (error?.code !== "ENOENT") throw error;
1433
+ return path.join(fs.realpathSync(path.dirname(value)), path.basename(value));
325
1434
  }
326
- const now = Date.now();
327
- for (const name of entries) {
328
- if (!name.startsWith("ultracode-wt-")) continue;
329
- const wtPath = path.join(os.tmpdir(), name);
330
- let mtime: number;
1435
+ }
1436
+
1437
+ function directoryIdentity(value: string): DirectoryIdentity | undefined {
1438
+ try {
1439
+ const stat = fs.lstatSync(value);
1440
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return undefined;
1441
+ return { device: stat.dev, inode: stat.ino };
1442
+ } catch (error: any) {
1443
+ if (error?.code === "ENOENT") return undefined;
1444
+ throw error;
1445
+ }
1446
+ }
1447
+
1448
+ function pathExistsNoFollow(value: string): boolean {
1449
+ try {
1450
+ fs.lstatSync(value);
1451
+ return true;
1452
+ } catch (error: any) {
1453
+ if (error?.code === "ENOENT") return false;
1454
+ throw error;
1455
+ }
1456
+ }
1457
+
1458
+ function tryRemoveOwnedDirectory(value: string, identity: DirectoryIdentity): void {
1459
+ try {
1460
+ const current = directoryIdentity(value);
1461
+ if (!current) return;
1462
+ if (current.device !== identity.device || current.inode !== identity.inode) return;
1463
+ removeDirectoryTreeNoFollow(Buffer.from(value));
1464
+ } catch {
1465
+ // Best effort after exact ownership validation; retained paths remain diagnosable.
1466
+ }
1467
+ }
1468
+
1469
+ function removeDirectoryTreeNoFollow(root: Buffer): void {
1470
+ const deadline = Date.now() + DEFAULT_GIT_TIMEOUT_MS;
1471
+ const stack: Array<{ value: Buffer; visited: boolean }> = [{ value: root, visited: false }];
1472
+ let nodes = 0;
1473
+ while (stack.length > 0) {
1474
+ if (Date.now() >= deadline) throw new Error("owned worktree cleanup exceeded its deadline");
1475
+ const entry = stack.pop()!;
1476
+ let stat: fs.Stats;
331
1477
  try {
332
- mtime = fs.statSync(wtPath).mtimeMs;
333
- } catch {
1478
+ stat = fs.lstatSync(entry.value);
1479
+ } catch (error: any) {
1480
+ if (error?.code === "ENOENT") continue;
1481
+ throw error;
1482
+ }
1483
+ nodes++;
1484
+ if (nodes > 200_000) throw new Error("owned worktree cleanup exceeds 200000 nodes");
1485
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
1486
+ fs.unlinkSync(entry.value);
334
1487
  continue;
335
1488
  }
336
- if (now - mtime <= maxAgeMs) continue; // recent: in-flight or within the recovery window
337
- if (tracked.has(canonicalPath(wtPath))) {
338
- // Tracked + stale (kept from an old run in THIS repo): full remove + branch delete.
339
- const branch = `ultracode/${name.slice("ultracode-wt-".length)}`;
340
- removeWorktreeQuiet(toplevel, wtPath, branch);
1489
+ if (entry.visited) {
1490
+ fs.rmdirSync(entry.value);
1491
+ continue;
1492
+ }
1493
+ fs.chmodSync(entry.value, (stat.mode & 0o7777) | 0o700);
1494
+ stack.push({ value: entry.value, visited: true });
1495
+ const children = fs.readdirSync(entry.value, { encoding: "buffer" }) as Buffer[];
1496
+ const separator = Buffer.from(path.sep);
1497
+ for (const child of children) {
1498
+ stack.push({ value: Buffer.concat([entry.value, separator, child]), visited: false });
1499
+ }
1500
+ }
1501
+ }
1502
+
1503
+ function assertOwnedWorktree(worktree: Worktree, deadline: number): void {
1504
+ if (!ownsWorktree(worktree, deadline)) {
1505
+ throw new Error(`worktree ownership changed or path was replaced: ${worktree.path}`);
1506
+ }
1507
+ }
1508
+
1509
+ function ownsWorktree(
1510
+ worktree: Worktree,
1511
+ deadline = Date.now() + DEFAULT_GIT_TIMEOUT_MS,
1512
+ ): boolean {
1513
+ try {
1514
+ if (Date.now() >= deadline) return false;
1515
+ const stat = fs.lstatSync(worktree.path);
1516
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
1517
+ if (stat.dev !== worktree.device || stat.ino !== worktree.inode) return false;
1518
+ const registration = discoverRegistrationAtPath(worktree.path, worktree.gitCommonDir);
1519
+ if (
1520
+ !registration
1521
+ || registration.gitDir !== worktree.gitDir
1522
+ || registration.device !== worktree.gitDirDevice
1523
+ || registration.inode !== worktree.gitDirInode
1524
+ ) return false;
1525
+ const rawObjectStat = fs.lstatSync(worktree.rawObjectDirectory);
1526
+ if (
1527
+ !rawObjectStat.isDirectory()
1528
+ || rawObjectStat.isSymbolicLink()
1529
+ || rawObjectStat.dev !== worktree.rawObjectDevice
1530
+ || rawObjectStat.ino !== worktree.rawObjectInode
1531
+ || !ownsRawObjectDirectory(worktree)
1532
+ ) return false;
1533
+ return true;
1534
+ } catch {
1535
+ return false;
1536
+ }
1537
+ }
1538
+
1539
+ function writeRawObjectOwnerMarker(directory: string, token: string): void {
1540
+ fs.writeFileSync(path.join(directory, RAW_OBJECT_OWNER_MARKER), `${token}\n`, {
1541
+ encoding: "utf8",
1542
+ mode: 0o600,
1543
+ flag: "wx",
1544
+ });
1545
+ }
1546
+
1547
+ function ownsRawObjectDirectory(worktree: Worktree): boolean {
1548
+ try {
1549
+ const markerPath = path.join(worktree.rawObjectDirectory, RAW_OBJECT_OWNER_MARKER);
1550
+ const stat = fs.lstatSync(markerPath);
1551
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128) return false;
1552
+ return fs.readFileSync(markerPath, "utf8").trim() === worktree.rawObjectToken;
1553
+ } catch {
1554
+ return false;
1555
+ }
1556
+ }
1557
+
1558
+ interface PatchRepository {
1559
+ path: string;
1560
+ identity: DirectoryIdentity;
1561
+ objectFormat: "sha1" | "sha256";
1562
+ env: NodeJS.ProcessEnv;
1563
+ deadline: number;
1564
+ }
1565
+
1566
+ function createPatchRepository(repoRoot: string, deadline: number): PatchRepository {
1567
+ const canonicalRoot = fs.realpathSync(repoRoot);
1568
+ const objectFormat = gitLine(canonicalRoot, ["rev-parse", "--show-object-format"], {
1569
+ timeoutMs: remainingTimeout(deadline),
1570
+ });
1571
+ if (objectFormat !== "sha1" && objectFormat !== "sha256") {
1572
+ throw new Error(`unsupported Git object format: ${objectFormat}`);
1573
+ }
1574
+ const repositoryPath = fs.mkdtempSync(path.join(os.tmpdir(), "ultracode-git-apply-"));
1575
+ const identity = directoryIdentity(repositoryPath)!;
1576
+ try {
1577
+ gitBuffer(path.dirname(repositoryPath), [
1578
+ "init",
1579
+ "--bare",
1580
+ "--quiet",
1581
+ `--object-format=${objectFormat}`,
1582
+ repositoryPath,
1583
+ ], { timeoutMs: remainingTimeout(deadline) });
1584
+ return {
1585
+ path: repositoryPath,
1586
+ identity,
1587
+ objectFormat,
1588
+ deadline,
1589
+ env: {
1590
+ GIT_DIR: repositoryPath,
1591
+ GIT_INDEX_FILE: path.join(repositoryPath, "index"),
1592
+ GIT_OPTIONAL_LOCKS: "0",
1593
+ },
1594
+ };
1595
+ } catch (error) {
1596
+ tryRemoveOwnedDirectory(repositoryPath, identity);
1597
+ throw error;
1598
+ }
1599
+ }
1600
+
1601
+ function removePatchRepository(repository: PatchRepository | undefined): void {
1602
+ if (repository) tryRemoveOwnedDirectory(repository.path, repository.identity);
1603
+ }
1604
+
1605
+ function runPatchGit(
1606
+ repository: PatchRepository,
1607
+ args: string[],
1608
+ input?: Uint8Array,
1609
+ ): Buffer {
1610
+ return gitBuffer(repository.path, args, {
1611
+ timeoutMs: remainingTimeout(repository.deadline),
1612
+ env: repository.env,
1613
+ input,
1614
+ });
1615
+ }
1616
+
1617
+ function patchApplyArgs(args: string[]): string[] {
1618
+ return [
1619
+ "-c", "apply.whitespace=nowarn",
1620
+ "-c", "apply.ignoreWhitespace=false",
1621
+ "-c", "core.filemode=true",
1622
+ "-c", "core.autocrlf=false",
1623
+ "-c", "core.eol=lf",
1624
+ "-c", "core.safecrlf=false",
1625
+ "-c", `core.attributesFile=${os.devNull}`,
1626
+ "apply",
1627
+ "--cached",
1628
+ "--whitespace=nowarn",
1629
+ ...args,
1630
+ ];
1631
+ }
1632
+
1633
+ function readPatchPaths(repository: PatchRepository, patchPath: string): Buffer[] {
1634
+ const output = runPatchGit(repository, patchApplyArgs(["--numstat", "-z", patchPath]));
1635
+ return parseNumstat(output).paths;
1636
+ }
1637
+
1638
+ function populatePatchIndex(
1639
+ repository: PatchRepository,
1640
+ repoRoot: string,
1641
+ paths: Buffer[],
1642
+ ): void {
1643
+ runPatchGit(repository, ["read-tree", "--empty"]);
1644
+ const records: Buffer[] = [];
1645
+ for (const entryPath of paths) {
1646
+ const entry = readFilesystemEntryNoFollow(repoRoot, entryPath);
1647
+ if (!entry) continue;
1648
+ const oid = stripOneLineEnding(runPatchGit(repository, [
1649
+ "hash-object",
1650
+ "-w",
1651
+ "--no-filters",
1652
+ "--stdin",
1653
+ ], entry.content).toString("ascii"));
1654
+ records.push(
1655
+ Buffer.from(`${entry.mode} ${oid}\t`, "ascii"),
1656
+ entry.path,
1657
+ Buffer.from([0]),
1658
+ );
1659
+ }
1660
+ if (records.length > 0) {
1661
+ runPatchGit(repository, ["update-index", "-z", "--index-info"], Buffer.concat(records));
1662
+ }
1663
+ }
1664
+
1665
+ function materializePatchIndex(
1666
+ repository: PatchRepository,
1667
+ repoRoot: string,
1668
+ paths: Buffer[],
1669
+ ): void {
1670
+ const entries = parseIndexEntries(runPatchGit(repository, ["ls-files", "--stage", "-z"]));
1671
+ if (entries.some((entry) => entry.stage !== "0")) {
1672
+ throw new Error("applied patch produced an unmerged private index");
1673
+ }
1674
+ const expected = new Set(paths.map((entry) => entry.toString("base64")));
1675
+ const byPath = new Map(entries.map((entry) => [entry.path.toString("base64"), entry]));
1676
+ if ([...byPath.keys()].some((key) => !expected.has(key))) {
1677
+ throw new Error("applied patch produced an unexpected private-index path");
1678
+ }
1679
+ const root = Buffer.from(repoRoot);
1680
+ const deepestFirst = [...paths].sort((left, right) =>
1681
+ pathDepth(right) - pathDepth(left) || Buffer.compare(right, left));
1682
+ for (const entryPath of deepestFirst) {
1683
+ if (byPath.has(entryPath.toString("base64"))) continue;
1684
+ const target = repositoryPath(root, entryPath);
1685
+ try {
1686
+ const stat = fs.lstatSync(target);
1687
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
1688
+ try {
1689
+ fs.rmdirSync(target);
1690
+ } catch (error: any) {
1691
+ if (error?.code !== "ENOTEMPTY") throw error;
1692
+ }
1693
+ } else {
1694
+ fs.unlinkSync(target);
1695
+ }
1696
+ } catch (error: any) {
1697
+ if (error?.code !== "ENOENT") throw error;
1698
+ }
1699
+ }
1700
+ pruneDirectoriesReplacedByLeaves(root, paths, byPath.values());
1701
+
1702
+ const shallowestFirst = [...byPath.values()].sort((left, right) =>
1703
+ pathDepth(left.path) - pathDepth(right.path) || Buffer.compare(left.path, right.path));
1704
+ for (const entry of shallowestFirst) {
1705
+ if (!["100644", "100755", "120000"].includes(entry.mode)) {
1706
+ throw new Error(`applied patch produced unsupported mode ${entry.mode}`);
1707
+ }
1708
+ const target = repositoryPath(root, entry.path);
1709
+ try {
1710
+ const stat = fs.lstatSync(target);
1711
+ if (stat.isDirectory() && !stat.isSymbolicLink()) fs.rmdirSync(target);
1712
+ else fs.unlinkSync(target);
1713
+ } catch (error: any) {
1714
+ if (error?.code !== "ENOENT") throw error;
1715
+ }
1716
+ const content = readPatchBlob(repository, entry.oid);
1717
+ const parent = bufferParent(target);
1718
+ fs.mkdirSync(parent, { recursive: true, mode: 0o777 });
1719
+ if (entry.mode === "120000") {
1720
+ fs.symlinkSync(content as any, target as any);
341
1721
  } else {
342
- // Untracked by this repo: could be a crash orphan, OR a worktree owned by a
343
- // DIFFERENT repo (git worktree list is repo-scoped, so we can't tell). The
344
- // 24h threshold matches the same-repo kept-worktree window, so cross-repo
345
- // in-flight/kept worktrees survive as long as same-repo ones. The owning
346
- // repo's branch is NOT cleaned here (no safe way to find it).
347
- try {
348
- fs.rmSync(wtPath, { recursive: true, force: true });
349
- } catch {
350
- // ignore
1722
+ fs.writeFileSync(target, content, {
1723
+ flag: "wx",
1724
+ mode: entry.mode === "100755" ? 0o777 : 0o666,
1725
+ });
1726
+ }
1727
+ }
1728
+ pruneEmptyPatchParents(root, paths);
1729
+ }
1730
+
1731
+ function readPatchBlob(repository: PatchRepository, oid: string): Buffer {
1732
+ const content = gitBuffer(repository.path, ["cat-file", "blob", oid], {
1733
+ timeoutMs: remainingTimeout(repository.deadline),
1734
+ env: repository.env,
1735
+ maxBuffer: MAX_ROLLBACK_SNAPSHOT_BYTES + 1024,
1736
+ });
1737
+ if (content.length > MAX_ROLLBACK_SNAPSHOT_BYTES) {
1738
+ throw new Error(`applied patch blob exceeds ${MAX_ROLLBACK_SNAPSHOT_BYTES} bytes`);
1739
+ }
1740
+ return content;
1741
+ }
1742
+
1743
+ function pathDepth(value: Buffer): number {
1744
+ let depth = 1;
1745
+ for (const byte of value) if (byte === 0x2f) depth++;
1746
+ return depth;
1747
+ }
1748
+
1749
+ function pruneDirectoriesReplacedByLeaves(
1750
+ root: Buffer,
1751
+ paths: Buffer[],
1752
+ finalEntries: Iterable<IndexEntry>,
1753
+ ): void {
1754
+ const candidates = new Map<string, Buffer>();
1755
+ for (const entry of finalEntries) {
1756
+ if (!paths.some((candidate) => isDescendantPath(candidate, entry.path))) continue;
1757
+ const target = repositoryPath(root, entry.path);
1758
+ for (const relative of paths) {
1759
+ if (!isDescendantPath(relative, entry.path)) continue;
1760
+ for (const directory of parentDirectories(root, relative)) {
1761
+ if (directory.equals(target) || (
1762
+ directory.length > target.length
1763
+ && directory.subarray(0, target.length).equals(target)
1764
+ && directory[target.length] === Buffer.from(path.sep)[0]
1765
+ )) {
1766
+ candidates.set(directory.toString("hex"), directory);
1767
+ }
351
1768
  }
352
1769
  }
353
1770
  }
354
- // Also reap crash-leaked patch tmp files (ultracode-patch-*), same threshold.
355
- for (const name of entries) {
356
- if (!name.startsWith("ultracode-patch-")) continue;
357
- const fp = path.join(os.tmpdir(), name);
358
- let mtime: number;
1771
+ for (const directory of [...candidates.values()].sort((left, right) => right.length - left.length)) {
359
1772
  try {
360
- mtime = fs.statSync(fp).mtimeMs;
361
- } catch {
1773
+ const stat = fs.lstatSync(directory);
1774
+ if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
1775
+ fs.rmdirSync(directory);
1776
+ } catch (error: any) {
1777
+ if (!["ENOENT", "ENOTEMPTY"].includes(error?.code)) throw error;
1778
+ }
1779
+ }
1780
+ }
1781
+
1782
+ function pruneEmptyPatchParents(root: Buffer, paths: Buffer[]): void {
1783
+ const candidates = new Map<string, Buffer>();
1784
+ for (const relative of paths) {
1785
+ for (const directory of parentDirectories(root, relative)) {
1786
+ if (directory.equals(root)) continue;
1787
+ candidates.set(directory.toString("hex"), directory);
1788
+ }
1789
+ }
1790
+ for (const directory of [...candidates.values()].sort((left, right) => right.length - left.length)) {
1791
+ try {
1792
+ const stat = fs.lstatSync(directory);
1793
+ if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
1794
+ fs.rmdirSync(directory);
1795
+ } catch (error: any) {
1796
+ if (!["ENOENT", "ENOTDIR", "ENOTEMPTY"].includes(error?.code)) throw error;
1797
+ }
1798
+ }
1799
+ }
1800
+
1801
+ function parseNumstat(value: Buffer): Pick<WorktreeDiff, "filesChanged" | "insertions" | "deletions" | "paths"> {
1802
+ let filesChanged = 0;
1803
+ let insertions = 0;
1804
+ let deletions = 0;
1805
+ const paths: Buffer[] = [];
1806
+ let start = 0;
1807
+ for (let index = 0; index <= value.length; index++) {
1808
+ if (index < value.length && value[index] !== 0) continue;
1809
+ if (index === start) {
1810
+ start = index + 1;
362
1811
  continue;
363
1812
  }
364
- if (now - mtime > maxAgeMs) {
1813
+ const record = value.subarray(start, index);
1814
+ start = index + 1;
1815
+ const firstTab = record.indexOf(0x09);
1816
+ const secondTab = firstTab < 0 ? -1 : record.indexOf(0x09, firstTab + 1);
1817
+ if (firstTab < 0 || secondTab < 0 || secondTab === record.length - 1) {
1818
+ throw new Error("git returned malformed NUL-delimited numstat output");
1819
+ }
1820
+ filesChanged++;
1821
+ const added = record.subarray(0, firstTab).toString("ascii");
1822
+ const removed = record.subarray(firstTab + 1, secondTab).toString("ascii");
1823
+ if (added !== "-") insertions += Number(added) || 0;
1824
+ if (removed !== "-") deletions += Number(removed) || 0;
1825
+ paths.push(Buffer.from(record.subarray(secondTab + 1)));
1826
+ }
1827
+ return { filesChanged, insertions, deletions, paths };
1828
+ }
1829
+
1830
+ function containsGitlink(patch: Buffer): boolean {
1831
+ return /^(?:(?:old|new|new file|deleted file) mode 160000|index [0-9a-f]+\.\.[0-9a-f]+ 160000)$/m
1832
+ .test(patch.toString("latin1"));
1833
+ }
1834
+
1835
+ function withTrailingNewline(input: PatchInput): Buffer {
1836
+ const value = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);
1837
+ if (value.length === 0 || value[value.length - 1] === 0x0a) return value;
1838
+ return Buffer.concat([value, Buffer.from("\n")]);
1839
+ }
1840
+
1841
+ function snapshotApplyPaths(repoRoot: string, paths: Buffer[]): ApplySnapshot {
1842
+ const root = Buffer.from(repoRoot);
1843
+ const roots = minimalPaths(paths).map((relative) => ({
1844
+ path: repositoryPath(root, relative),
1845
+ relative,
1846
+ }));
1847
+ const snapshots: PathSnapshot[] = [];
1848
+ const directories = new Map<string, { path: Buffer; existed: boolean; mode?: number }>();
1849
+ let totalBytes = 0;
1850
+ let nodes = 0;
1851
+ for (const snapshotRoot of roots) {
1852
+ for (const directory of parentDirectories(root, snapshotRoot.relative)) {
1853
+ const key = directory.toString("hex");
1854
+ if (directories.has(key)) continue;
1855
+ try {
1856
+ const directoryStat = fs.lstatSync(directory);
1857
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
1858
+ throw new Error("patch target parent is not a real directory");
1859
+ }
1860
+ directories.set(key, {
1861
+ path: directory,
1862
+ existed: true,
1863
+ mode: directoryStat.mode & 0o7777,
1864
+ });
1865
+ } catch (error: any) {
1866
+ if (error?.code !== "ENOENT") throw error;
1867
+ directories.set(key, { path: directory, existed: false });
1868
+ }
1869
+ }
1870
+ const stack = [{ path: snapshotRoot.path, relative: snapshotRoot.relative, root: true }];
1871
+ while (stack.length > 0) {
1872
+ const entry = stack.pop()!;
1873
+ let stat: fs.Stats;
365
1874
  try {
366
- fs.unlinkSync(fp);
367
- } catch {
368
- // ignore
1875
+ stat = fs.lstatSync(entry.path);
1876
+ } catch (error: any) {
1877
+ if (error?.code === "ENOENT" && entry.root) {
1878
+ snapshots.push({ path: entry.path, relative: entry.relative, kind: "missing" });
1879
+ continue;
1880
+ }
1881
+ throw error;
369
1882
  }
1883
+ nodes++;
1884
+ if (nodes > 200_000) throw new Error("patch rollback snapshot exceeds 200000 nodes");
1885
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
1886
+ snapshots.push({
1887
+ path: entry.path,
1888
+ relative: entry.relative,
1889
+ kind: "directory",
1890
+ mode: stat.mode & 0o7777,
1891
+ });
1892
+ const children = fs.readdirSync(entry.path, { encoding: "buffer" }) as Buffer[];
1893
+ for (const child of children) {
1894
+ stack.push({
1895
+ path: Buffer.concat([entry.path, Buffer.from(path.sep), child]),
1896
+ relative: Buffer.concat([entry.relative, Buffer.from("/"), child]),
1897
+ root: false,
1898
+ });
1899
+ }
1900
+ continue;
1901
+ }
1902
+ if (stat.isFile()) {
1903
+ if (stat.nlink > 1) throw new Error("patch rollback cannot preserve a hard-linked file");
1904
+ totalBytes += stat.size;
1905
+ if (totalBytes > MAX_ROLLBACK_SNAPSHOT_BYTES) {
1906
+ throw new Error(`patch rollback snapshot exceeds ${MAX_ROLLBACK_SNAPSHOT_BYTES} bytes`);
1907
+ }
1908
+ snapshots.push({
1909
+ path: entry.path,
1910
+ relative: entry.relative,
1911
+ kind: "file",
1912
+ data: fs.readFileSync(entry.path),
1913
+ mode: stat.mode & 0o7777,
1914
+ });
1915
+ continue;
1916
+ }
1917
+ if (stat.isSymbolicLink()) {
1918
+ const linkTarget = fs.readlinkSync(entry.path, { encoding: "buffer" }) as Buffer;
1919
+ snapshots.push({
1920
+ path: entry.path,
1921
+ relative: entry.relative,
1922
+ kind: "symlink",
1923
+ target: Buffer.from(linkTarget),
1924
+ });
1925
+ continue;
1926
+ }
1927
+ throw new Error("patch rollback supports only files, directories, symlinks, and missing paths");
370
1928
  }
371
1929
  }
1930
+ return {
1931
+ roots,
1932
+ paths: snapshots,
1933
+ directories: [...directories.values()].sort((left, right) => right.path.length - left.path.length),
1934
+ };
372
1935
  }
373
1936
 
374
- function linkNodeModules(toplevel: string, worktreePath: string): void {
375
- const src = path.join(toplevel, "node_modules");
376
- const dest = path.join(worktreePath, "node_modules");
377
- if (!fs.existsSync(src) || fs.existsSync(dest)) return;
1937
+ function minimalPaths(paths: Buffer[]): Buffer[] {
1938
+ const sorted = deduplicatePaths(paths).sort((left, right) =>
1939
+ left.length - right.length || Buffer.compare(left, right));
1940
+ const roots: Buffer[] = [];
1941
+ for (const candidate of sorted) {
1942
+ if (!roots.some((root) => isDescendantPath(candidate, root))) roots.push(candidate);
1943
+ }
1944
+ return roots;
1945
+ }
1946
+
1947
+ function restoreApplySnapshot(repoRoot: string, snapshot: ApplySnapshot): void {
1948
+ for (const root of [...snapshot.roots].sort((left, right) => right.path.length - left.path.length)) {
1949
+ removeDirectoryTreeNoFollow(root.path);
1950
+ }
1951
+ const snapshotDirectories = snapshot.paths
1952
+ .filter((entry): entry is Extract<PathSnapshot, { kind: "directory" }> => entry.kind === "directory")
1953
+ .sort((left, right) => left.path.length - right.path.length);
1954
+ for (const entry of snapshotDirectories) {
1955
+ fs.mkdirSync(entry.path, { recursive: true, mode: 0o700 });
1956
+ }
1957
+ for (const entry of snapshot.paths) {
1958
+ if (entry.kind === "missing" || entry.kind === "directory") continue;
1959
+ const parent = bufferParent(entry.path);
1960
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
1961
+ if (entry.kind === "file") {
1962
+ fs.writeFileSync(entry.path, entry.data, { mode: entry.mode });
1963
+ fs.chmodSync(entry.path, entry.mode);
1964
+ } else {
1965
+ fs.symlinkSync(entry.target as any, entry.path as any);
1966
+ }
1967
+ }
1968
+ for (const entry of snapshotDirectories.sort((left, right) => right.path.length - left.path.length)) {
1969
+ fs.chmodSync(entry.path, entry.mode);
1970
+ }
1971
+ for (const directory of snapshot.directories) {
1972
+ if (directory.existed) continue;
1973
+ try {
1974
+ fs.rmdirSync(directory.path);
1975
+ } catch (error: any) {
1976
+ if (!["ENOENT", "ENOTEMPTY"].includes(error?.code)) throw error;
1977
+ }
1978
+ }
1979
+ for (const directory of snapshot.directories) {
1980
+ if (!directory.existed || directory.mode === undefined) continue;
1981
+ fs.chmodSync(directory.path, directory.mode);
1982
+ }
1983
+ fsyncAppliedPaths(repoRoot, snapshot.paths.map((entry) => entry.relative));
1984
+ }
1985
+
1986
+ function bufferParent(value: Buffer): Buffer {
1987
+ const separator = Buffer.from(path.sep)[0]!;
1988
+ const index = value.lastIndexOf(separator);
1989
+ if (index <= 0) throw new Error("cannot resolve patch path parent");
1990
+ return Buffer.from(value.subarray(0, index));
1991
+ }
1992
+
1993
+ function fsyncAppliedPaths(repoRoot: string, paths: Buffer[]): void {
1994
+ const root = Buffer.from(repoRoot);
1995
+ const directories = new Map<string, Buffer>();
1996
+ directories.set(root.toString("hex"), root);
1997
+ for (const relative of paths) {
1998
+ const target = repositoryPath(root, relative);
1999
+ try {
2000
+ const stat = fs.lstatSync(target);
2001
+ if (stat.isFile()) fsyncFile(target);
2002
+ } catch (error: any) {
2003
+ if (!["ENOENT", "ENOTDIR"].includes(error?.code)) throw error;
2004
+ }
2005
+ for (const directory of parentDirectories(root, relative)) {
2006
+ directories.set(directory.toString("hex"), directory);
2007
+ }
2008
+ }
2009
+ for (const directory of directories.values()) fsyncDirectory(directory);
2010
+ }
2011
+
2012
+ function fsyncFile(file: Buffer): void {
2013
+ const fd = fs.openSync(file, fs.constants.O_RDONLY);
378
2014
  try {
379
- fs.symlinkSync(src, dest, "dir");
380
- } catch {
381
- // unsupported filesystem; subagents just won't have node_modules linked
2015
+ fs.fsyncSync(fd);
2016
+ } finally {
2017
+ fs.closeSync(fd);
2018
+ }
2019
+ }
2020
+
2021
+ function fsyncDirectory(directory: Buffer): void {
2022
+ let fd: number | undefined;
2023
+ try {
2024
+ const stat = fs.lstatSync(directory);
2025
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return;
2026
+ const constants = fs.constants as Record<string, number>;
2027
+ const flags = fs.constants.O_RDONLY
2028
+ | (constants.O_NOFOLLOW ?? 0)
2029
+ | (constants.O_DIRECTORY ?? 0);
2030
+ fd = fs.openSync(directory, flags);
2031
+ if (!fs.fstatSync(fd).isDirectory()) throw new Error("fsync target is not a directory");
2032
+ fs.fsyncSync(fd);
2033
+ } catch (error: any) {
2034
+ if (![
2035
+ "ENOENT",
2036
+ "ENOTDIR",
2037
+ "ELOOP",
2038
+ "EINVAL",
2039
+ "ENOTSUP",
2040
+ "EISDIR",
2041
+ "EBADF",
2042
+ ].includes(error?.code)) throw error;
2043
+ } finally {
2044
+ if (fd !== undefined) fs.closeSync(fd);
2045
+ }
2046
+ }
2047
+
2048
+ function repositoryPath(root: Buffer, relative: Buffer): Buffer {
2049
+ const components = splitPath(relative);
2050
+ if (components.length === 0 || components.some((part) =>
2051
+ part.length === 0 || part.equals(Buffer.from(".")) || part.equals(Buffer.from("..")))) {
2052
+ throw new Error("patch contains an unsafe repository-relative path");
2053
+ }
2054
+ const separator = Buffer.from(path.sep);
2055
+ return Buffer.concat([root, separator, ...interleave(components, separator)]);
2056
+ }
2057
+
2058
+ function parentDirectories(root: Buffer, relative: Buffer): Buffer[] {
2059
+ const components = splitPath(relative);
2060
+ const separator = Buffer.from(path.sep);
2061
+ const result = [root];
2062
+ for (let count = 1; count < components.length; count++) {
2063
+ result.push(Buffer.concat([root, separator, ...interleave(components.slice(0, count), separator)]));
2064
+ }
2065
+ return result;
2066
+ }
2067
+
2068
+ function splitPath(value: Buffer): Buffer[] {
2069
+ if (value.length === 0 || value[0] === 0x2f || value.includes(0)) {
2070
+ throw new Error("patch contains an unsafe repository-relative path");
2071
+ }
2072
+ const result: Buffer[] = [];
2073
+ let start = 0;
2074
+ for (let index = 0; index <= value.length; index++) {
2075
+ if (index < value.length && value[index] !== 0x2f) continue;
2076
+ result.push(Buffer.from(value.subarray(start, index)));
2077
+ start = index + 1;
2078
+ }
2079
+ return result;
2080
+ }
2081
+
2082
+ function interleave(parts: Buffer[], separator: Buffer): Buffer[] {
2083
+ const result: Buffer[] = [];
2084
+ for (let index = 0; index < parts.length; index++) {
2085
+ if (index > 0) result.push(separator);
2086
+ result.push(parts[index]!);
382
2087
  }
2088
+ return result;
2089
+ }
2090
+
2091
+ function normalizeTimeout(value: number): number {
2092
+ if (!Number.isFinite(value) || value <= 0) throw new Error("Git operation timeout must be positive");
2093
+ return Math.max(1, Math.floor(value));
2094
+ }
2095
+
2096
+ function remainingTimeout(deadline: number): number {
2097
+ const remaining = deadline - Date.now();
2098
+ if (remaining <= 0) throw new Error("Git operation exceeded its cleanup deadline");
2099
+ return remaining;
2100
+ }
2101
+
2102
+ function errorMessage(error: unknown): string {
2103
+ return error instanceof Error ? error.message : String(error);
383
2104
  }