agentrinse 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +92 -13
  2. package/dist/adapters/artifacts/adapter.d.ts +3 -1
  3. package/dist/adapters/artifacts/adapter.d.ts.map +1 -1
  4. package/dist/adapters/artifacts/adapter.js +23 -1
  5. package/dist/adapters/artifacts/adapter.js.map +1 -1
  6. package/dist/adapters/git/adapter.d.ts +21 -1
  7. package/dist/adapters/git/adapter.d.ts.map +1 -1
  8. package/dist/adapters/git/adapter.js +414 -19
  9. package/dist/adapters/git/adapter.js.map +1 -1
  10. package/dist/adapters/git/refs.d.ts +6 -0
  11. package/dist/adapters/git/refs.d.ts.map +1 -0
  12. package/dist/adapters/git/refs.js +24 -0
  13. package/dist/adapters/git/refs.js.map +1 -0
  14. package/dist/adapters/git/status.d.ts +15 -0
  15. package/dist/adapters/git/status.d.ts.map +1 -0
  16. package/dist/adapters/git/status.js +87 -0
  17. package/dist/adapters/git/status.js.map +1 -0
  18. package/dist/adapters/provider-adapter.d.ts +3 -0
  19. package/dist/adapters/provider-adapter.d.ts.map +1 -1
  20. package/dist/adapters/provider-adapter.js +16 -0
  21. package/dist/adapters/provider-adapter.js.map +1 -1
  22. package/dist/adapters/provider-reachability.d.ts +6 -0
  23. package/dist/adapters/provider-reachability.d.ts.map +1 -0
  24. package/dist/adapters/provider-reachability.js +207 -0
  25. package/dist/adapters/provider-reachability.js.map +1 -0
  26. package/dist/adapters/provider-specs.d.ts +1 -1
  27. package/dist/adapters/provider-specs.d.ts.map +1 -1
  28. package/dist/adapters/registry.d.ts +8 -1
  29. package/dist/adapters/registry.d.ts.map +1 -1
  30. package/dist/adapters/registry.js +48 -4
  31. package/dist/adapters/registry.js.map +1 -1
  32. package/dist/adapters/runtime/adapter.d.ts +24 -0
  33. package/dist/adapters/runtime/adapter.d.ts.map +1 -0
  34. package/dist/adapters/runtime/adapter.js +319 -0
  35. package/dist/adapters/runtime/adapter.js.map +1 -0
  36. package/dist/commands/adapters.d.ts.map +1 -1
  37. package/dist/commands/adapters.js +1 -0
  38. package/dist/commands/adapters.js.map +1 -1
  39. package/dist/commands/apply.d.ts.map +1 -1
  40. package/dist/commands/apply.js +4 -0
  41. package/dist/commands/apply.js.map +1 -1
  42. package/dist/commands/clean.d.ts +63 -0
  43. package/dist/commands/clean.d.ts.map +1 -0
  44. package/dist/commands/clean.js +230 -0
  45. package/dist/commands/clean.js.map +1 -0
  46. package/dist/commands/completion.d.ts.map +1 -1
  47. package/dist/commands/completion.js +4 -1
  48. package/dist/commands/completion.js.map +1 -1
  49. package/dist/commands/confirmation.d.ts +6 -0
  50. package/dist/commands/confirmation.d.ts.map +1 -0
  51. package/dist/commands/confirmation.js +21 -0
  52. package/dist/commands/confirmation.js.map +1 -0
  53. package/dist/commands/doctor.d.ts.map +1 -1
  54. package/dist/commands/doctor.js +4 -2
  55. package/dist/commands/doctor.js.map +1 -1
  56. package/dist/commands/purge.d.ts +29 -0
  57. package/dist/commands/purge.d.ts.map +1 -0
  58. package/dist/commands/purge.js +168 -0
  59. package/dist/commands/purge.js.map +1 -0
  60. package/dist/commands/show.d.ts.map +1 -1
  61. package/dist/commands/show.js +14 -2
  62. package/dist/commands/show.js.map +1 -1
  63. package/dist/commands/undo.d.ts +21 -0
  64. package/dist/commands/undo.d.ts.map +1 -0
  65. package/dist/commands/undo.js +66 -0
  66. package/dist/commands/undo.js.map +1 -0
  67. package/dist/config/defaults.d.ts.map +1 -1
  68. package/dist/config/defaults.js +6 -0
  69. package/dist/config/defaults.js.map +1 -1
  70. package/dist/config/load.d.ts.map +1 -1
  71. package/dist/config/load.js +4 -0
  72. package/dist/config/load.js.map +1 -1
  73. package/dist/config/schema.d.ts +27 -0
  74. package/dist/config/schema.d.ts.map +1 -1
  75. package/dist/config/schema.js +66 -0
  76. package/dist/config/schema.js.map +1 -1
  77. package/dist/contracts/action.d.ts +59 -0
  78. package/dist/contracts/action.d.ts.map +1 -1
  79. package/dist/contracts/action.js +28 -1
  80. package/dist/contracts/action.js.map +1 -1
  81. package/dist/contracts/finding.d.ts +22 -0
  82. package/dist/contracts/finding.d.ts.map +1 -1
  83. package/dist/contracts/plan.d.ts +23 -0
  84. package/dist/contracts/plan.d.ts.map +1 -1
  85. package/dist/contracts/plan.js +20 -1
  86. package/dist/contracts/plan.js.map +1 -1
  87. package/dist/contracts/quarantine.d.ts +79 -0
  88. package/dist/contracts/quarantine.d.ts.map +1 -0
  89. package/dist/contracts/quarantine.js +60 -0
  90. package/dist/contracts/quarantine.js.map +1 -0
  91. package/dist/contracts/report.d.ts +22 -0
  92. package/dist/contracts/report.d.ts.map +1 -1
  93. package/dist/contracts/run.d.ts +133 -5
  94. package/dist/contracts/run.d.ts.map +1 -1
  95. package/dist/contracts/run.js +17 -3
  96. package/dist/contracts/run.js.map +1 -1
  97. package/dist/core/apply.d.ts +8 -1
  98. package/dist/core/apply.d.ts.map +1 -1
  99. package/dist/core/apply.js +138 -48
  100. package/dist/core/apply.js.map +1 -1
  101. package/dist/core/git-operation-state.d.ts +3 -0
  102. package/dist/core/git-operation-state.d.ts.map +1 -0
  103. package/dist/core/git-operation-state.js +23 -0
  104. package/dist/core/git-operation-state.js.map +1 -0
  105. package/dist/core/measure.d.ts +1 -0
  106. package/dist/core/measure.d.ts.map +1 -1
  107. package/dist/core/measure.js +3 -0
  108. package/dist/core/measure.js.map +1 -1
  109. package/dist/core/no-clobber-rename.d.ts +2 -0
  110. package/dist/core/no-clobber-rename.d.ts.map +1 -0
  111. package/dist/core/no-clobber-rename.js +58 -0
  112. package/dist/core/no-clobber-rename.js.map +1 -0
  113. package/dist/core/plan.d.ts.map +1 -1
  114. package/dist/core/plan.js +1 -0
  115. package/dist/core/plan.js.map +1 -1
  116. package/dist/core/reachability.d.ts +29 -0
  117. package/dist/core/reachability.d.ts.map +1 -0
  118. package/dist/core/reachability.js +121 -0
  119. package/dist/core/reachability.js.map +1 -0
  120. package/dist/core/worktree-executor.d.ts +51 -0
  121. package/dist/core/worktree-executor.d.ts.map +1 -0
  122. package/dist/core/worktree-executor.js +676 -0
  123. package/dist/core/worktree-executor.js.map +1 -0
  124. package/dist/core/worktree-lock.d.ts +15 -0
  125. package/dist/core/worktree-lock.d.ts.map +1 -0
  126. package/dist/core/worktree-lock.js +139 -0
  127. package/dist/core/worktree-lock.js.map +1 -0
  128. package/dist/core/worktree-recovery.d.ts +37 -0
  129. package/dist/core/worktree-recovery.d.ts.map +1 -0
  130. package/dist/core/worktree-recovery.js +1018 -0
  131. package/dist/core/worktree-recovery.js.map +1 -0
  132. package/dist/core/worktree-revalidation.d.ts +25 -0
  133. package/dist/core/worktree-revalidation.d.ts.map +1 -0
  134. package/dist/core/worktree-revalidation.js +107 -0
  135. package/dist/core/worktree-revalidation.js.map +1 -0
  136. package/dist/index.d.ts +3 -2
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +3 -2
  139. package/dist/index.js.map +1 -1
  140. package/dist/main.d.ts.map +1 -1
  141. package/dist/main.js +85 -0
  142. package/dist/main.js.map +1 -1
  143. package/dist/state/layout.d.ts +1 -0
  144. package/dist/state/layout.d.ts.map +1 -1
  145. package/dist/state/layout.js +1 -0
  146. package/dist/state/layout.js.map +1 -1
  147. package/dist/state/records.d.ts +6 -0
  148. package/dist/state/records.d.ts.map +1 -1
  149. package/dist/state/records.js +9 -1
  150. package/dist/state/records.js.map +1 -1
  151. package/dist/state/run-journal.d.ts.map +1 -1
  152. package/dist/state/run-journal.js +3 -0
  153. package/dist/state/run-journal.js.map +1 -1
  154. package/dist/version.d.ts +1 -1
  155. package/dist/version.js +1 -1
  156. package/package.json +2 -1
  157. package/schemas/audit.schema.json +112 -0
  158. package/schemas/plan.schema.json +117 -0
  159. package/schemas/quarantine.schema.json +238 -0
  160. package/schemas/run.schema.json +152 -54
@@ -0,0 +1,1018 @@
1
+ import { execFile } from "node:child_process";
2
+ import { lstat } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { parseWorktreePorcelain } from "../adapters/git/porcelain.js";
6
+ import { countStatusSuppressedIndexEntries, parseGitStatusPorcelainV2, } from "../adapters/git/status.js";
7
+ import { quarantineEntrySchema, } from "../contracts/quarantine.js";
8
+ import { writeJsonAtomic } from "../state/json-file.js";
9
+ import { findGitOperations } from "./git-operation-state.js";
10
+ import { measurePath } from "./measure.js";
11
+ import { findMountBoundaries } from "./mount-boundaries.js";
12
+ import { renameNoReplace } from "./no-clobber-rename.js";
13
+ import { findProcessesUsingPath } from "./process-ownership.js";
14
+ import { reconcileOwnedWorktreeLockClaim, unlockOwnedWorktree } from "./worktree-lock.js";
15
+ const execFileAsync = promisify(execFile);
16
+ export class WorktreeRecoveryError extends Error {
17
+ code;
18
+ entry;
19
+ name = "WorktreeRecoveryError";
20
+ constructor(code, message, entry, options) {
21
+ super(message, options);
22
+ this.code = code;
23
+ this.entry = entry;
24
+ }
25
+ }
26
+ async function defaultGitRunner(args) {
27
+ const result = await execFileAsync("git", args, {
28
+ encoding: "utf8",
29
+ maxBuffer: 4 * 1024 * 1024,
30
+ });
31
+ return result.stdout;
32
+ }
33
+ function isMissing(error) {
34
+ return (error instanceof Error && "code" in error && error.code === "ENOENT");
35
+ }
36
+ function isMissingGitRef(error) {
37
+ if (!(error instanceof Error) || !("code" in error)) {
38
+ return false;
39
+ }
40
+ return error.code === 1;
41
+ }
42
+ async function pathExists(path, inspect) {
43
+ try {
44
+ await inspect(path);
45
+ return true;
46
+ }
47
+ catch (error) {
48
+ if (isMissing(error)) {
49
+ return false;
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+ function branchRef(branch) {
55
+ if (branch === undefined) {
56
+ return undefined;
57
+ }
58
+ return branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
59
+ }
60
+ function assertManifestPath(entry, options) {
61
+ const expected = resolve(options.quarantineDirectory, `${entry.entryId}.json`);
62
+ if (resolve(options.manifestPath) !== expected) {
63
+ throw new WorktreeRecoveryError("QUARANTINE_MANIFEST_PATH_MISMATCH", "quarantine manifest filename does not match its entry ID", entry);
64
+ }
65
+ }
66
+ function quarantineLockReason(entry) {
67
+ return `AgentRinse quarantine ${entry.entryId}`;
68
+ }
69
+ function lockMatches(actual, expectation, entry) {
70
+ const owned = actual === quarantineLockReason(entry);
71
+ if (expectation === "owned") {
72
+ return owned;
73
+ }
74
+ if (expectation === "unlocked") {
75
+ return actual === undefined;
76
+ }
77
+ return actual === undefined || owned;
78
+ }
79
+ function registrationMatches(output, path, entry, lockExpectation) {
80
+ return parseWorktreePorcelain(output).some((record) => record.path === path &&
81
+ record.head === entry.target.head &&
82
+ record.branch === entry.target.branch &&
83
+ lockMatches(record.locked, lockExpectation, entry));
84
+ }
85
+ async function assertNoGitOperations(entry, worktreePath, dependencies) {
86
+ const operations = await findGitOperations(worktreePath, dependencies.runGit, (path) => pathExists(path, dependencies.inspect));
87
+ if (operations.length > 0) {
88
+ throw new WorktreeRecoveryError("QUARANTINE_GIT_OPERATION_IN_PROGRESS", `Git operation is in progress: ${operations.join(", ")}`, entry);
89
+ }
90
+ }
91
+ async function assertTargetRegistrationLock(entry, dependencies, lockExpectation, expectedPaths) {
92
+ const records = parseWorktreePorcelain(await dependencies.runGit([
93
+ "--git-dir",
94
+ entry.target.repositoryCommonDir,
95
+ "worktree",
96
+ "list",
97
+ "--porcelain",
98
+ "-z",
99
+ ])).filter((record) => record.head === entry.target.head && record.branch === entry.target.branch);
100
+ const registration = records[0];
101
+ if (records.length !== 1 ||
102
+ registration === undefined ||
103
+ !expectedPaths.some((path) => resolve(path) === resolve(registration.path)) ||
104
+ !lockMatches(registration.locked, lockExpectation, entry)) {
105
+ throw new WorktreeRecoveryError("QUARANTINE_REGISTRATION_CHANGED", "target worktree registration or lock ownership changed before repair", entry);
106
+ }
107
+ }
108
+ function isImmutableRecoveryProtection(error) {
109
+ return (error instanceof WorktreeRecoveryError &&
110
+ ["QUARANTINE_GIT_OPERATION_IN_PROGRESS", "QUARANTINE_REGISTRATION_CHANGED"].includes(error.code));
111
+ }
112
+ function resolveDependencies(options) {
113
+ return {
114
+ runGit: options.dependencies?.runGit ?? defaultGitRunner,
115
+ inspect: options.dependencies?.inspect ?? lstat,
116
+ move: options.dependencies?.move ??
117
+ ((source, destination) => renameNoReplace(source, destination, options.dependencies?.platform ?? process.platform)),
118
+ measure: options.dependencies?.measure ?? measurePath,
119
+ processProbe: options.dependencies?.processProbe ?? findProcessesUsingPath,
120
+ mountProbe: options.dependencies?.mountProbe ?? findMountBoundaries,
121
+ clock: options.dependencies?.clock ?? (() => new Date()),
122
+ };
123
+ }
124
+ function assertSupportedPlatform(entry, options) {
125
+ const platform = options.dependencies?.platform ?? process.platform;
126
+ if (!["darwin", "linux"].includes(platform)) {
127
+ throw new WorktreeRecoveryError("WORKTREE_PLATFORM_UNSUPPORTED", `worktree recovery mutation is unsupported on ${platform}`, entry);
128
+ }
129
+ }
130
+ async function persist(entry, options) {
131
+ const parsed = quarantineEntrySchema.parse(entry);
132
+ assertManifestPath(parsed, options);
133
+ await writeJsonAtomic(options.manifestPath, parsed, {
134
+ privateDirectories: [options.quarantineDirectory],
135
+ });
136
+ return parsed;
137
+ }
138
+ async function validateQuarantinedEntry(entry, options, requireOriginalVacant, acceptedStatuses = ["quarantined"], lockExpectation = "owned", worktreePath = entry.quarantinePath) {
139
+ const dependencies = resolveDependencies(options);
140
+ const fail = (code, message) => {
141
+ throw new WorktreeRecoveryError(code, message, entry);
142
+ };
143
+ assertSupportedPlatform(entry, options);
144
+ if (!acceptedStatuses.includes(entry.status)) {
145
+ fail("QUARANTINE_NOT_LIVE", `quarantine entry status is ${entry.status}`);
146
+ }
147
+ assertManifestPath(entry, options);
148
+ const quarantineIdentity = entry.quarantineIdentity ??
149
+ fail("QUARANTINE_IDENTITY_MISSING", "post-repair quarantine identity is missing");
150
+ if (requireOriginalVacant && (await pathExists(entry.originalPath, dependencies.inspect))) {
151
+ fail("UNDO_DESTINATION_OCCUPIED", `original path is occupied: ${entry.originalPath}`);
152
+ }
153
+ const [quarantineStats, commonDirStats] = await Promise.all([
154
+ dependencies.inspect(worktreePath),
155
+ dependencies.inspect(entry.target.repositoryCommonDir),
156
+ ]);
157
+ if (!quarantineStats.isDirectory() ||
158
+ quarantineStats.isSymbolicLink() ||
159
+ quarantineStats.dev !== quarantineIdentity.device ||
160
+ quarantineStats.ino !== quarantineIdentity.inode ||
161
+ quarantineStats.mtimeMs !== quarantineIdentity.mtimeMs) {
162
+ fail("QUARANTINE_IDENTITY_CHANGED", "quarantined worktree filesystem identity changed");
163
+ }
164
+ if (!commonDirStats.isDirectory() || commonDirStats.isSymbolicLink()) {
165
+ fail("QUARANTINE_REPOSITORY_CHANGED", "Git common directory is no longer a real directory");
166
+ }
167
+ await reconcileOwnedWorktreeLockClaim({
168
+ worktreePath,
169
+ repositoryCommonDir: entry.target.repositoryCommonDir,
170
+ expectedReason: quarantineLockReason(entry),
171
+ claimId: entry.entryId,
172
+ runGit: dependencies.runGit,
173
+ platform: options.dependencies?.platform ?? process.platform,
174
+ });
175
+ const measurement = await dependencies.measure(worktreePath, {
176
+ maxEntries: entry.measurementMaxEntries,
177
+ excludeRootEntries: [".git"],
178
+ });
179
+ if (measurement.truncated ||
180
+ measurement.specialEntries > 0 ||
181
+ measurement.mountBoundaries > 0 ||
182
+ measurement.bytes !== quarantineIdentity.measuredBytes ||
183
+ measurement.newestMtimeMs !== quarantineIdentity.newestMtimeMs ||
184
+ measurement.fingerprint !== quarantineIdentity.fingerprint) {
185
+ fail("QUARANTINE_CONTENT_CHANGED", "quarantined worktree contents changed after apply");
186
+ }
187
+ const ownership = await dependencies.processProbe(worktreePath);
188
+ if (ownership.status !== "idle") {
189
+ fail(ownership.status === "busy" ? "QUARANTINE_PROCESS_ACTIVE" : "QUARANTINE_PROCESS_UNKNOWN", ownership.status === "busy"
190
+ ? "a live process owns the quarantined worktree"
191
+ : "live process ownership could not be proven idle");
192
+ }
193
+ const mounts = await dependencies.mountProbe(worktreePath);
194
+ if (mounts.status !== "clear") {
195
+ fail(mounts.status === "blocked"
196
+ ? "QUARANTINE_MOUNT_BOUNDARY"
197
+ : "QUARANTINE_MOUNT_INSPECTION_UNKNOWN", mounts.status === "blocked"
198
+ ? "quarantined worktree contains a mount boundary"
199
+ : "mount boundaries could not be proven absent");
200
+ }
201
+ const status = parseGitStatusPorcelainV2(await dependencies.runGit([
202
+ "-C",
203
+ worktreePath,
204
+ "status",
205
+ "--porcelain=v2",
206
+ "--branch",
207
+ "-z",
208
+ "--untracked-files=all",
209
+ "--ignored=matching",
210
+ ]));
211
+ const statusSuppressedEntries = countStatusSuppressedIndexEntries(await dependencies.runGit(["-C", worktreePath, "ls-files", "-z", "-v"]));
212
+ if (status.head !== entry.target.head ||
213
+ branchRef(status.branch) !== entry.target.branch ||
214
+ status.staged + status.modified + status.untracked + status.conflicted + status.ignored > 0 ||
215
+ statusSuppressedEntries > 0) {
216
+ fail("QUARANTINE_GIT_STATE_CHANGED", "quarantined worktree is no longer clean at planned HEAD");
217
+ }
218
+ await assertNoGitOperations(entry, worktreePath, dependencies);
219
+ const records = parseWorktreePorcelain(await dependencies.runGit([
220
+ "--git-dir",
221
+ entry.target.repositoryCommonDir,
222
+ "worktree",
223
+ "list",
224
+ "--porcelain",
225
+ "-z",
226
+ ]));
227
+ const registration = records.find((record) => record.path === worktreePath &&
228
+ record.head === entry.target.head &&
229
+ record.branch === entry.target.branch);
230
+ if (registration === undefined) {
231
+ throw new WorktreeRecoveryError("QUARANTINE_REGISTRATION_CHANGED", "quarantined worktree is no longer registered", entry);
232
+ }
233
+ if (!lockMatches(registration.locked, lockExpectation, entry)) {
234
+ fail("QUARANTINE_REGISTRATION_CHANGED", "quarantined worktree lock ownership changed");
235
+ }
236
+ const recoveryHead = await readRecoveryRef(entry, dependencies);
237
+ if (recoveryHead === undefined || recoveryHead !== entry.target.head) {
238
+ fail("QUARANTINE_RECOVERY_REF_CHANGED", "recovery ref no longer points to the planned HEAD");
239
+ }
240
+ return {
241
+ dependencies,
242
+ registrationLocked: registration.locked === quarantineLockReason(entry),
243
+ };
244
+ }
245
+ async function deleteRecoveryRef(entry, dependencies) {
246
+ const recoveryHead = await readRecoveryRef(entry, dependencies);
247
+ if (recoveryHead === undefined) {
248
+ return;
249
+ }
250
+ if (recoveryHead !== entry.target.head) {
251
+ throw new WorktreeRecoveryError("QUARANTINE_RECOVERY_REF_CHANGED", "recovery ref no longer points to the planned HEAD", entry);
252
+ }
253
+ await dependencies.runGit([
254
+ "--git-dir",
255
+ entry.target.repositoryCommonDir,
256
+ "update-ref",
257
+ "-d",
258
+ entry.recoveryRef,
259
+ entry.target.head,
260
+ ]);
261
+ }
262
+ async function ensureRecoveryRef(entry, dependencies) {
263
+ const recoveryHead = await readRecoveryRef(entry, dependencies);
264
+ if (recoveryHead === entry.target.head) {
265
+ return;
266
+ }
267
+ if (recoveryHead !== undefined) {
268
+ throw new WorktreeRecoveryError("QUARANTINE_RECOVERY_REF_CHANGED", "recovery ref no longer points to the planned HEAD", entry);
269
+ }
270
+ await dependencies.runGit([
271
+ "--git-dir",
272
+ entry.target.repositoryCommonDir,
273
+ "update-ref",
274
+ entry.recoveryRef,
275
+ entry.target.head,
276
+ "",
277
+ ]);
278
+ }
279
+ async function readRecoveryRef(entry, dependencies) {
280
+ try {
281
+ return (await dependencies.runGit([
282
+ "--git-dir",
283
+ entry.target.repositoryCommonDir,
284
+ "rev-parse",
285
+ "--verify",
286
+ "--quiet",
287
+ entry.recoveryRef,
288
+ ])).trim();
289
+ }
290
+ catch (error) {
291
+ if (isMissingGitRef(error)) {
292
+ return undefined;
293
+ }
294
+ throw error;
295
+ }
296
+ }
297
+ async function verifyLockedRegistration(entry, dependencies) {
298
+ const records = await dependencies.runGit([
299
+ "--git-dir",
300
+ entry.target.repositoryCommonDir,
301
+ "worktree",
302
+ "list",
303
+ "--porcelain",
304
+ "-z",
305
+ ]);
306
+ if (!registrationMatches(records, entry.quarantinePath, entry, "owned")) {
307
+ throw new WorktreeRecoveryError("QUARANTINE_REGISTRATION_CHANGED", "quarantined worktree could not be relocked during recovery", entry);
308
+ }
309
+ }
310
+ async function verifyRecoveryPath(entry, options, dependencies, path, identity, allowMissingRecoveryRef, lockExpectation, expectedRegistrationPaths) {
311
+ assertSupportedPlatform(entry, options);
312
+ assertManifestPath(entry, options);
313
+ const [stats, commonDirStats] = await Promise.all([
314
+ dependencies.inspect(path),
315
+ dependencies.inspect(entry.target.repositoryCommonDir),
316
+ ]);
317
+ if (!stats.isDirectory() ||
318
+ stats.isSymbolicLink() ||
319
+ stats.dev !== identity.device ||
320
+ stats.ino !== identity.inode ||
321
+ stats.mtimeMs !== identity.mtimeMs ||
322
+ !commonDirStats.isDirectory() ||
323
+ commonDirStats.isSymbolicLink()) {
324
+ throw new WorktreeRecoveryError("QUARANTINE_IDENTITY_CHANGED", "recovery worktree filesystem identity changed", entry);
325
+ }
326
+ await reconcileOwnedWorktreeLockClaim({
327
+ worktreePath: path,
328
+ repositoryCommonDir: entry.target.repositoryCommonDir,
329
+ expectedReason: quarantineLockReason(entry),
330
+ claimId: entry.entryId,
331
+ runGit: dependencies.runGit,
332
+ platform: options.dependencies?.platform ?? process.platform,
333
+ });
334
+ const measurement = await dependencies.measure(path, {
335
+ maxEntries: entry.measurementMaxEntries,
336
+ excludeRootEntries: [".git"],
337
+ });
338
+ if (measurement.truncated ||
339
+ measurement.specialEntries > 0 ||
340
+ measurement.mountBoundaries > 0 ||
341
+ measurement.bytes !== identity.measuredBytes ||
342
+ measurement.newestMtimeMs !== identity.newestMtimeMs ||
343
+ measurement.fingerprint !== identity.fingerprint) {
344
+ throw new WorktreeRecoveryError("QUARANTINE_CONTENT_CHANGED", "recovery worktree contents changed", entry);
345
+ }
346
+ const ownership = await dependencies.processProbe(path);
347
+ if (ownership.status !== "idle") {
348
+ throw new WorktreeRecoveryError(ownership.status === "busy" ? "QUARANTINE_PROCESS_ACTIVE" : "QUARANTINE_PROCESS_UNKNOWN", "recovery worktree process ownership is not idle", entry);
349
+ }
350
+ const mounts = await dependencies.mountProbe(path);
351
+ if (mounts.status !== "clear") {
352
+ throw new WorktreeRecoveryError(mounts.status === "blocked"
353
+ ? "QUARANTINE_MOUNT_BOUNDARY"
354
+ : "QUARANTINE_MOUNT_INSPECTION_UNKNOWN", "recovery worktree mount state is not clear", entry);
355
+ }
356
+ await assertNoGitOperations(entry, path, dependencies);
357
+ await assertTargetRegistrationLock(entry, dependencies, lockExpectation, expectedRegistrationPaths);
358
+ await dependencies.runGit([
359
+ "--git-dir",
360
+ entry.target.repositoryCommonDir,
361
+ "worktree",
362
+ "repair",
363
+ path,
364
+ ]);
365
+ const status = parseGitStatusPorcelainV2(await dependencies.runGit([
366
+ "-C",
367
+ path,
368
+ "status",
369
+ "--porcelain=v2",
370
+ "--branch",
371
+ "-z",
372
+ "--untracked-files=all",
373
+ "--ignored=matching",
374
+ ]));
375
+ const statusSuppressedEntries = countStatusSuppressedIndexEntries(await dependencies.runGit(["-C", path, "ls-files", "-z", "-v"]));
376
+ if (status.head !== entry.target.head ||
377
+ branchRef(status.branch) !== entry.target.branch ||
378
+ status.staged + status.modified + status.untracked + status.conflicted + status.ignored > 0 ||
379
+ statusSuppressedEntries > 0) {
380
+ throw new WorktreeRecoveryError("QUARANTINE_GIT_STATE_CHANGED", "recovery worktree Git state changed", entry);
381
+ }
382
+ await assertNoGitOperations(entry, path, dependencies);
383
+ const records = await dependencies.runGit([
384
+ "--git-dir",
385
+ entry.target.repositoryCommonDir,
386
+ "worktree",
387
+ "list",
388
+ "--porcelain",
389
+ "-z",
390
+ ]);
391
+ if (!registrationMatches(records, path, entry, lockExpectation)) {
392
+ throw new WorktreeRecoveryError("QUARANTINE_REGISTRATION_CHANGED", "recovery worktree registration could not be repaired", entry);
393
+ }
394
+ const recoveryHead = await readRecoveryRef(entry, dependencies);
395
+ if (recoveryHead !== entry.target.head &&
396
+ !(allowMissingRecoveryRef && recoveryHead === undefined)) {
397
+ throw new WorktreeRecoveryError("QUARANTINE_RECOVERY_REF_CHANGED", "recovery ref no longer points to the planned HEAD", entry);
398
+ }
399
+ }
400
+ async function recoverInitialQuarantineForUndo(entry, options) {
401
+ const dependencies = resolveDependencies(options);
402
+ const [originalExists, quarantineExists] = await Promise.all([
403
+ pathExists(entry.originalPath, dependencies.inspect),
404
+ pathExists(entry.quarantinePath, dependencies.inspect),
405
+ ]);
406
+ if (originalExists && !quarantineExists) {
407
+ const expectedRegistrationPaths = entry.status === "moved" ? [entry.originalPath, entry.quarantinePath] : [entry.originalPath];
408
+ await verifyRecoveryPath(entry, options, dependencies, entry.originalPath, entry.target, true, "owned-or-unlocked", expectedRegistrationPaths);
409
+ await releaseOwnedRegistrationLock(entry, options, dependencies, entry.originalPath);
410
+ await deleteRecoveryRef(entry, dependencies);
411
+ return persist({
412
+ ...entry,
413
+ status: "restored",
414
+ restoredAt: dependencies.clock().toISOString(),
415
+ }, options);
416
+ }
417
+ if (quarantineExists && !originalExists) {
418
+ await verifyRecoveryPath(entry, options, dependencies, entry.quarantinePath, entry.target, true, "owned-or-unlocked", [entry.originalPath, entry.quarantinePath]);
419
+ await ensureRecoveryRef(entry, dependencies);
420
+ const records = await dependencies.runGit([
421
+ "--git-dir",
422
+ entry.target.repositoryCommonDir,
423
+ "worktree",
424
+ "list",
425
+ "--porcelain",
426
+ "-z",
427
+ ]);
428
+ if (!registrationMatches(records, entry.quarantinePath, entry, "owned")) {
429
+ await dependencies.runGit([
430
+ "--git-dir",
431
+ entry.target.repositoryCommonDir,
432
+ "worktree",
433
+ "lock",
434
+ "--reason",
435
+ quarantineLockReason(entry),
436
+ entry.quarantinePath,
437
+ ]);
438
+ await verifyLockedRegistration(entry, dependencies);
439
+ }
440
+ const [stats, measurement] = await Promise.all([
441
+ dependencies.inspect(entry.quarantinePath),
442
+ dependencies.measure(entry.quarantinePath, {
443
+ maxEntries: entry.measurementMaxEntries,
444
+ excludeRootEntries: [".git"],
445
+ }),
446
+ ]);
447
+ return persist({
448
+ ...entry,
449
+ status: "quarantined",
450
+ quarantineIdentity: {
451
+ ...entry.target,
452
+ path: entry.quarantinePath,
453
+ device: stats.dev,
454
+ inode: stats.ino,
455
+ mtimeMs: stats.mtimeMs,
456
+ measuredBytes: measurement.bytes,
457
+ newestMtimeMs: measurement.newestMtimeMs,
458
+ fingerprint: measurement.fingerprint,
459
+ },
460
+ }, options);
461
+ }
462
+ throw new WorktreeRecoveryError("QUARANTINE_INITIAL_TRANSITION_AMBIGUOUS", "interrupted quarantine has ambiguous source and destination paths", entry);
463
+ }
464
+ async function releaseOwnedRegistrationLock(entry, options, dependencies, worktreePath) {
465
+ const registrations = await dependencies.runGit([
466
+ "--git-dir",
467
+ entry.target.repositoryCommonDir,
468
+ "worktree",
469
+ "list",
470
+ "--porcelain",
471
+ "-z",
472
+ ]);
473
+ if (!registrationMatches(registrations, worktreePath, entry, "owned")) {
474
+ return;
475
+ }
476
+ await unlockOwnedWorktree({
477
+ worktreePath,
478
+ repositoryCommonDir: entry.target.repositoryCommonDir,
479
+ expectedReason: quarantineLockReason(entry),
480
+ claimId: entry.entryId,
481
+ runGit: dependencies.runGit,
482
+ platform: options.dependencies?.platform ?? process.platform,
483
+ });
484
+ await assertTargetRegistrationLock(entry, dependencies, "unlocked", [worktreePath]);
485
+ }
486
+ async function resumeInterruptedUndo(entry, options) {
487
+ const dependencies = resolveDependencies(options);
488
+ const [originalExists, quarantineExists] = await Promise.all([
489
+ pathExists(entry.originalPath, dependencies.inspect),
490
+ pathExists(entry.quarantinePath, dependencies.inspect),
491
+ ]);
492
+ if (quarantineExists && !originalExists) {
493
+ const validated = await validateQuarantinedEntry(entry, options, false, ["restoring"], "owned-or-unlocked");
494
+ if (!validated.registrationLocked) {
495
+ await dependencies.runGit([
496
+ "--git-dir",
497
+ entry.target.repositoryCommonDir,
498
+ "worktree",
499
+ "lock",
500
+ "--reason",
501
+ quarantineLockReason(entry),
502
+ entry.quarantinePath,
503
+ ]);
504
+ await verifyLockedRegistration(entry, dependencies);
505
+ }
506
+ return persist({ ...entry, status: "quarantined" }, options);
507
+ }
508
+ if (originalExists && !quarantineExists) {
509
+ const identity = entry.quarantineIdentity ??
510
+ (() => {
511
+ throw new WorktreeRecoveryError("QUARANTINE_IDENTITY_MISSING", "post-repair quarantine identity is missing", entry);
512
+ })();
513
+ await verifyRecoveryPath(entry, options, dependencies, entry.originalPath, identity, true, "unlocked", [entry.quarantinePath, entry.originalPath]);
514
+ await deleteRecoveryRef(entry, dependencies);
515
+ const restored = await persist({
516
+ ...entry,
517
+ status: "restored",
518
+ restoredAt: dependencies.clock().toISOString(),
519
+ }, options);
520
+ return restored;
521
+ }
522
+ throw new WorktreeRecoveryError("QUARANTINE_UNDO_TRANSITION_AMBIGUOUS", "interrupted undo has ambiguous source and destination paths", entry);
523
+ }
524
+ export function worktreePurgeIsolationPath(entry) {
525
+ return `${entry.quarantinePath}.purging`;
526
+ }
527
+ async function recoverPartialForUndo(entry, options) {
528
+ const dependencies = resolveDependencies(options);
529
+ const isolationPath = worktreePurgeIsolationPath(entry);
530
+ const [originalExists, quarantineExists, isolationExists] = await Promise.all([
531
+ pathExists(entry.originalPath, dependencies.inspect),
532
+ pathExists(entry.quarantinePath, dependencies.inspect),
533
+ pathExists(isolationPath, dependencies.inspect),
534
+ ]);
535
+ if ([originalExists, quarantineExists, isolationExists].filter(Boolean).length !== 1) {
536
+ throw new WorktreeRecoveryError("QUARANTINE_PARTIAL_TRANSITION_AMBIGUOUS", "partial quarantine recovery requires exactly one known worktree path", entry);
537
+ }
538
+ if (originalExists) {
539
+ await verifyRecoveryPath(entry, options, dependencies, entry.originalPath, entry.quarantineIdentity ?? entry.target, true, "owned-or-unlocked", [entry.originalPath, entry.quarantinePath, isolationPath]);
540
+ await releaseOwnedRegistrationLock(entry, options, dependencies, entry.originalPath);
541
+ await deleteRecoveryRef(entry, dependencies);
542
+ return persist({
543
+ ...entry,
544
+ status: "restored",
545
+ restoredAt: dependencies.clock().toISOString(),
546
+ }, options);
547
+ }
548
+ if (isolationExists) {
549
+ const identity = entry.quarantineIdentity ??
550
+ (() => {
551
+ throw new WorktreeRecoveryError("QUARANTINE_IDENTITY_MISSING", "partial purge recovery requires post-repair quarantine identity", entry);
552
+ })();
553
+ await verifyRecoveryPath(entry, options, dependencies, isolationPath, identity, false, "unlocked", [entry.quarantinePath, isolationPath]);
554
+ await dependencies.move(isolationPath, entry.quarantinePath);
555
+ }
556
+ const identity = entry.quarantineIdentity ?? entry.target;
557
+ await verifyRecoveryPath(entry, options, dependencies, entry.quarantinePath, identity, true, "owned-or-unlocked", [entry.originalPath, entry.quarantinePath, isolationPath]);
558
+ await ensureRecoveryRef(entry, dependencies);
559
+ const records = await dependencies.runGit([
560
+ "--git-dir",
561
+ entry.target.repositoryCommonDir,
562
+ "worktree",
563
+ "list",
564
+ "--porcelain",
565
+ "-z",
566
+ ]);
567
+ if (!registrationMatches(records, entry.quarantinePath, entry, "owned")) {
568
+ await dependencies.runGit([
569
+ "--git-dir",
570
+ entry.target.repositoryCommonDir,
571
+ "worktree",
572
+ "lock",
573
+ "--reason",
574
+ quarantineLockReason(entry),
575
+ entry.quarantinePath,
576
+ ]);
577
+ await verifyLockedRegistration(entry, dependencies);
578
+ }
579
+ const [stats, measurement] = await Promise.all([
580
+ dependencies.inspect(entry.quarantinePath),
581
+ dependencies.measure(entry.quarantinePath, {
582
+ maxEntries: entry.measurementMaxEntries,
583
+ excludeRootEntries: [".git"],
584
+ }),
585
+ ]);
586
+ return persist({
587
+ ...entry,
588
+ status: "quarantined",
589
+ quarantineIdentity: {
590
+ ...identity,
591
+ path: entry.quarantinePath,
592
+ device: stats.dev,
593
+ inode: stats.ino,
594
+ mtimeMs: stats.mtimeMs,
595
+ measuredBytes: measurement.bytes,
596
+ newestMtimeMs: measurement.newestMtimeMs,
597
+ fingerprint: measurement.fingerprint,
598
+ },
599
+ }, options);
600
+ }
601
+ async function rollbackPurgeIsolation(entry, isolationPath, options, dependencies, error) {
602
+ try {
603
+ if ((await pathExists(entry.quarantinePath, dependencies.inspect)) ||
604
+ !(await pathExists(isolationPath, dependencies.inspect))) {
605
+ throw new Error("worktree paths are not safe for purge isolation rollback");
606
+ }
607
+ await assertNoGitOperations(entry, isolationPath, dependencies);
608
+ await assertTargetRegistrationLock(entry, dependencies, "unlocked", [
609
+ entry.quarantinePath,
610
+ isolationPath,
611
+ ]);
612
+ await dependencies.move(isolationPath, entry.quarantinePath);
613
+ await assertNoGitOperations(entry, entry.quarantinePath, dependencies);
614
+ await dependencies.runGit([
615
+ "--git-dir",
616
+ entry.target.repositoryCommonDir,
617
+ "worktree",
618
+ "repair",
619
+ entry.quarantinePath,
620
+ ]);
621
+ await dependencies.runGit([
622
+ "--git-dir",
623
+ entry.target.repositoryCommonDir,
624
+ "worktree",
625
+ "lock",
626
+ "--reason",
627
+ quarantineLockReason(entry),
628
+ entry.quarantinePath,
629
+ ]);
630
+ await verifyLockedRegistration(entry, dependencies);
631
+ const recovered = await persist({
632
+ ...entry,
633
+ status: "quarantined",
634
+ diagnostic: {
635
+ severity: "error",
636
+ code: "QUARANTINE_PURGE_FAILED",
637
+ message: error instanceof Error ? error.message : String(error),
638
+ adapter: "git",
639
+ resourceId: entry.resourceId,
640
+ },
641
+ }, options);
642
+ throw new WorktreeRecoveryError("QUARANTINE_PURGE_FAILED", "purge failed and the isolated worktree was returned to locked quarantine", recovered, { cause: error });
643
+ }
644
+ catch (recoveryError) {
645
+ if (recoveryError instanceof WorktreeRecoveryError && recoveryError.cause === error) {
646
+ throw recoveryError;
647
+ }
648
+ const partial = await persist({
649
+ ...entry,
650
+ status: "partial",
651
+ diagnostic: {
652
+ severity: "error",
653
+ code: "QUARANTINE_PURGE_PARTIAL",
654
+ message: `purge left partial state: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
655
+ adapter: "git",
656
+ resourceId: entry.resourceId,
657
+ },
658
+ }, options).catch(() => entry);
659
+ throw new WorktreeRecoveryError("QUARANTINE_PURGE_PARTIAL", `purge left partial state; inspect ${options.manifestPath}`, partial, { cause: recoveryError });
660
+ }
661
+ }
662
+ async function resumeInterruptedPurge(entry, options) {
663
+ const dependencies = resolveDependencies(options);
664
+ const isolationPath = worktreePurgeIsolationPath(entry);
665
+ const [quarantineExists, isolationExists] = await Promise.all([
666
+ pathExists(entry.quarantinePath, dependencies.inspect),
667
+ pathExists(isolationPath, dependencies.inspect),
668
+ ]);
669
+ if (quarantineExists && isolationExists) {
670
+ throw new WorktreeRecoveryError("QUARANTINE_PURGE_TRANSITION_AMBIGUOUS", "interrupted purge has both source and isolation paths", entry);
671
+ }
672
+ if (quarantineExists) {
673
+ const validated = await validateQuarantinedEntry(entry, options, false, ["purging"], "owned-or-unlocked");
674
+ if (!validated.registrationLocked) {
675
+ await dependencies.runGit([
676
+ "--git-dir",
677
+ entry.target.repositoryCommonDir,
678
+ "worktree",
679
+ "lock",
680
+ "--reason",
681
+ quarantineLockReason(entry),
682
+ entry.quarantinePath,
683
+ ]);
684
+ await verifyLockedRegistration(entry, dependencies);
685
+ }
686
+ return persist({ ...entry, status: "quarantined" }, options);
687
+ }
688
+ if (isolationExists) {
689
+ try {
690
+ await assertNoGitOperations(entry, isolationPath, dependencies);
691
+ await assertTargetRegistrationLock(entry, dependencies, "unlocked", [
692
+ entry.quarantinePath,
693
+ isolationPath,
694
+ ]);
695
+ await dependencies.runGit([
696
+ "--git-dir",
697
+ entry.target.repositoryCommonDir,
698
+ "worktree",
699
+ "repair",
700
+ isolationPath,
701
+ ]);
702
+ await validateQuarantinedEntry(entry, options, false, ["purging"], "unlocked", isolationPath);
703
+ await options.revalidateProtection?.(entry);
704
+ await validateQuarantinedEntry(entry, options, false, ["purging"], "unlocked", isolationPath);
705
+ await dependencies.runGit([
706
+ "--git-dir",
707
+ entry.target.repositoryCommonDir,
708
+ "worktree",
709
+ "remove",
710
+ isolationPath,
711
+ ]);
712
+ if (await pathExists(isolationPath, dependencies.inspect)) {
713
+ throw new WorktreeRecoveryError("QUARANTINE_PURGE_TRANSITION_AMBIGUOUS", "Git reported success but the purge isolation path remains", entry);
714
+ }
715
+ }
716
+ catch (error) {
717
+ if (isImmutableRecoveryProtection(error)) {
718
+ throw error;
719
+ }
720
+ return rollbackPurgeIsolation(entry, isolationPath, options, dependencies, error);
721
+ }
722
+ }
723
+ if (!quarantineExists && !isolationExists) {
724
+ assertSupportedPlatform(entry, options);
725
+ assertManifestPath(entry, options);
726
+ const records = await dependencies.runGit([
727
+ "--git-dir",
728
+ entry.target.repositoryCommonDir,
729
+ "worktree",
730
+ "list",
731
+ "--porcelain",
732
+ "-z",
733
+ ]);
734
+ if (parseWorktreePorcelain(records).some((record) => record.path === entry.quarantinePath ||
735
+ record.path === isolationPath ||
736
+ (record.head === entry.target.head && record.branch === entry.target.branch))) {
737
+ throw new WorktreeRecoveryError("QUARANTINE_REGISTRATION_CHANGED", "interrupted purge left or moved the target worktree registration", entry);
738
+ }
739
+ const recoveryHead = await readRecoveryRef(entry, dependencies);
740
+ if (recoveryHead !== undefined && recoveryHead !== entry.target.head) {
741
+ throw new WorktreeRecoveryError("QUARANTINE_RECOVERY_REF_CHANGED", "recovery ref no longer points to the planned HEAD", entry);
742
+ }
743
+ await deleteRecoveryRef(entry, dependencies);
744
+ const purged = await persist({
745
+ ...entry,
746
+ status: "purged",
747
+ purgedAt: dependencies.clock().toISOString(),
748
+ }, options);
749
+ return { entry: purged, reclaimedBytes: purged.target.measuredBytes };
750
+ }
751
+ return resumeInterruptedPurge(entry, options);
752
+ }
753
+ export async function undoWorktreeQuarantine(input, options) {
754
+ let entry = quarantineEntrySchema.parse(input);
755
+ if (entry.status === "partial") {
756
+ entry = await recoverPartialForUndo(entry, options);
757
+ if (entry.status === "restored") {
758
+ return entry;
759
+ }
760
+ }
761
+ if (["preparing", "recovery-ref-created", "moved"].includes(entry.status)) {
762
+ entry = await recoverInitialQuarantineForUndo(entry, options);
763
+ if (entry.status === "restored") {
764
+ return entry;
765
+ }
766
+ }
767
+ if (entry.status === "restoring") {
768
+ entry = await resumeInterruptedUndo(entry, options);
769
+ if (entry.status === "restored") {
770
+ return entry;
771
+ }
772
+ }
773
+ const { dependencies } = await validateQuarantinedEntry(entry, options, true);
774
+ entry = await persist({ ...entry, status: "restoring" }, options);
775
+ let unlocked = false;
776
+ let moved = false;
777
+ let finalizing = false;
778
+ try {
779
+ await unlockOwnedWorktree({
780
+ worktreePath: entry.quarantinePath,
781
+ repositoryCommonDir: entry.target.repositoryCommonDir,
782
+ expectedReason: quarantineLockReason(entry),
783
+ claimId: entry.entryId,
784
+ runGit: dependencies.runGit,
785
+ platform: options.dependencies?.platform ?? process.platform,
786
+ });
787
+ unlocked = true;
788
+ await assertTargetRegistrationLock(entry, dependencies, "unlocked", [entry.quarantinePath]);
789
+ if (await pathExists(entry.originalPath, dependencies.inspect)) {
790
+ throw new Error(`original path became occupied before undo: ${entry.originalPath}`);
791
+ }
792
+ await dependencies.move(entry.quarantinePath, entry.originalPath);
793
+ moved = true;
794
+ const identity = entry.quarantineIdentity ??
795
+ (() => {
796
+ throw new WorktreeRecoveryError("QUARANTINE_IDENTITY_MISSING", "post-repair quarantine identity is missing", entry);
797
+ })();
798
+ await verifyRecoveryPath(entry, options, dependencies, entry.originalPath, identity, false, "unlocked", [entry.quarantinePath, entry.originalPath]);
799
+ await deleteRecoveryRef(entry, dependencies);
800
+ finalizing = true;
801
+ entry = await persist({
802
+ ...entry,
803
+ status: "restored",
804
+ restoredAt: dependencies.clock().toISOString(),
805
+ }, options);
806
+ return entry;
807
+ }
808
+ catch (error) {
809
+ if (finalizing) {
810
+ throw new WorktreeRecoveryError("QUARANTINE_UNDO_FINALIZE_PENDING", "undo restored the worktree but could not persist terminal state; retry undo", entry, { cause: error });
811
+ }
812
+ if (moved && isImmutableRecoveryProtection(error)) {
813
+ throw error;
814
+ }
815
+ try {
816
+ if (moved) {
817
+ await assertNoGitOperations(entry, entry.originalPath, dependencies);
818
+ await assertTargetRegistrationLock(entry, dependencies, "unlocked", [
819
+ entry.quarantinePath,
820
+ entry.originalPath,
821
+ ]);
822
+ if ((await pathExists(entry.quarantinePath, dependencies.inspect)) ||
823
+ !(await pathExists(entry.originalPath, dependencies.inspect))) {
824
+ throw new Error("worktree paths are not safe for undo rollback");
825
+ }
826
+ await dependencies.move(entry.originalPath, entry.quarantinePath);
827
+ await dependencies.runGit([
828
+ "--git-dir",
829
+ entry.target.repositoryCommonDir,
830
+ "worktree",
831
+ "repair",
832
+ entry.quarantinePath,
833
+ ]);
834
+ }
835
+ if (unlocked) {
836
+ await dependencies.runGit([
837
+ "--git-dir",
838
+ entry.target.repositoryCommonDir,
839
+ "worktree",
840
+ "lock",
841
+ "--reason",
842
+ quarantineLockReason(entry),
843
+ entry.quarantinePath,
844
+ ]);
845
+ }
846
+ entry = await persist({
847
+ ...entry,
848
+ status: "quarantined",
849
+ diagnostic: {
850
+ severity: "error",
851
+ code: "QUARANTINE_UNDO_FAILED",
852
+ message: error instanceof Error ? error.message : String(error),
853
+ adapter: "git",
854
+ resourceId: entry.resourceId,
855
+ },
856
+ }, options);
857
+ throw new WorktreeRecoveryError("QUARANTINE_UNDO_FAILED", "undo failed and the quarantine entry was restored", entry, { cause: error });
858
+ }
859
+ catch (rollbackError) {
860
+ if (rollbackError instanceof WorktreeRecoveryError && rollbackError.cause === error) {
861
+ throw rollbackError;
862
+ }
863
+ entry = await persist({
864
+ ...entry,
865
+ status: "partial",
866
+ diagnostic: {
867
+ severity: "error",
868
+ code: "QUARANTINE_UNDO_PARTIAL",
869
+ message: `undo failed and rollback was incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
870
+ adapter: "git",
871
+ resourceId: entry.resourceId,
872
+ },
873
+ }, options).catch(() => entry);
874
+ throw new WorktreeRecoveryError("QUARANTINE_UNDO_PARTIAL", `undo left partial state; inspect ${options.manifestPath}`, entry, { cause: rollbackError });
875
+ }
876
+ }
877
+ }
878
+ export async function purgeWorktreeQuarantine(input, options) {
879
+ let entry = quarantineEntrySchema.parse(input);
880
+ if (entry.status === "purging") {
881
+ const resumed = await resumeInterruptedPurge(entry, options);
882
+ if ("entry" in resumed) {
883
+ return resumed;
884
+ }
885
+ entry = resumed;
886
+ }
887
+ const { dependencies } = await validateQuarantinedEntry(entry, options, false);
888
+ if (options.allowUnexpired !== true &&
889
+ dependencies.clock().getTime() < Date.parse(entry.expiresAt)) {
890
+ throw new WorktreeRecoveryError("QUARANTINE_NOT_EXPIRED", `quarantine entry does not expire until ${entry.expiresAt}`, entry);
891
+ }
892
+ entry = await persist({ ...entry, status: "purging" }, options);
893
+ const isolationPath = worktreePurgeIsolationPath(entry);
894
+ let unlocked = false;
895
+ let isolated = false;
896
+ let removed = false;
897
+ try {
898
+ await unlockOwnedWorktree({
899
+ worktreePath: entry.quarantinePath,
900
+ repositoryCommonDir: entry.target.repositoryCommonDir,
901
+ expectedReason: quarantineLockReason(entry),
902
+ claimId: entry.entryId,
903
+ runGit: dependencies.runGit,
904
+ platform: options.dependencies?.platform ?? process.platform,
905
+ });
906
+ unlocked = true;
907
+ await validateQuarantinedEntry(entry, options, false, ["purging"], "unlocked");
908
+ if (await pathExists(isolationPath, dependencies.inspect)) {
909
+ throw new Error(`purge isolation path already exists: ${isolationPath}`);
910
+ }
911
+ await dependencies.move(entry.quarantinePath, isolationPath);
912
+ isolated = true;
913
+ await assertNoGitOperations(entry, isolationPath, dependencies);
914
+ await assertTargetRegistrationLock(entry, dependencies, "unlocked", [
915
+ entry.quarantinePath,
916
+ isolationPath,
917
+ ]);
918
+ await dependencies.runGit([
919
+ "--git-dir",
920
+ entry.target.repositoryCommonDir,
921
+ "worktree",
922
+ "repair",
923
+ isolationPath,
924
+ ]);
925
+ await validateQuarantinedEntry(entry, options, false, ["purging"], "unlocked", isolationPath);
926
+ await options.revalidateProtection?.(entry);
927
+ await validateQuarantinedEntry(entry, options, false, ["purging"], "unlocked", isolationPath);
928
+ await dependencies.runGit([
929
+ "--git-dir",
930
+ entry.target.repositoryCommonDir,
931
+ "worktree",
932
+ "remove",
933
+ isolationPath,
934
+ ]);
935
+ if (await pathExists(isolationPath, dependencies.inspect)) {
936
+ throw new Error("Git reported success but the purge isolation path still exists");
937
+ }
938
+ removed = true;
939
+ const records = await dependencies.runGit([
940
+ "--git-dir",
941
+ entry.target.repositoryCommonDir,
942
+ "worktree",
943
+ "list",
944
+ "--porcelain",
945
+ "-z",
946
+ ]);
947
+ if (parseWorktreePorcelain(records).some((record) => record.path === isolationPath ||
948
+ (record.head === entry.target.head && record.branch === entry.target.branch))) {
949
+ throw new Error("Git reported success but the target worktree registration remains or moved");
950
+ }
951
+ await deleteRecoveryRef(entry, dependencies);
952
+ entry = await persist({
953
+ ...entry,
954
+ status: "purged",
955
+ purgedAt: dependencies.clock().toISOString(),
956
+ }, options);
957
+ return { entry, reclaimedBytes: entry.target.measuredBytes };
958
+ }
959
+ catch (error) {
960
+ if (removed) {
961
+ throw new WorktreeRecoveryError("QUARANTINE_PURGE_FINALIZE_PENDING", "purge removed the worktree but could not persist terminal state; retry purge", entry, { cause: error });
962
+ }
963
+ if (isolated && isImmutableRecoveryProtection(error)) {
964
+ throw error;
965
+ }
966
+ try {
967
+ if (isolated) {
968
+ return rollbackPurgeIsolation(entry, isolationPath, options, dependencies, error);
969
+ }
970
+ if (await pathExists(entry.quarantinePath, dependencies.inspect)) {
971
+ if (unlocked) {
972
+ await dependencies.runGit([
973
+ "--git-dir",
974
+ entry.target.repositoryCommonDir,
975
+ "worktree",
976
+ "lock",
977
+ "--reason",
978
+ quarantineLockReason(entry),
979
+ entry.quarantinePath,
980
+ ]);
981
+ }
982
+ entry = await persist({
983
+ ...entry,
984
+ status: "quarantined",
985
+ diagnostic: {
986
+ severity: "error",
987
+ code: "QUARANTINE_PURGE_FAILED",
988
+ message: error instanceof Error ? error.message : String(error),
989
+ adapter: "git",
990
+ resourceId: entry.resourceId,
991
+ },
992
+ }, options);
993
+ throw new WorktreeRecoveryError("QUARANTINE_PURGE_FAILED", unlocked
994
+ ? "purge failed and the quarantine entry was relocked"
995
+ : "purge failed before mutation and the quarantine entry remains retryable", entry, { cause: error });
996
+ }
997
+ throw error;
998
+ }
999
+ catch (recoveryError) {
1000
+ if (recoveryError instanceof WorktreeRecoveryError && recoveryError.cause === error) {
1001
+ throw recoveryError;
1002
+ }
1003
+ entry = await persist({
1004
+ ...entry,
1005
+ status: "partial",
1006
+ diagnostic: {
1007
+ severity: "error",
1008
+ code: "QUARANTINE_PURGE_PARTIAL",
1009
+ message: `purge left partial state: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
1010
+ adapter: "git",
1011
+ resourceId: entry.resourceId,
1012
+ },
1013
+ }, options).catch(() => entry);
1014
+ throw new WorktreeRecoveryError("QUARANTINE_PURGE_PARTIAL", `purge left partial state; inspect ${options.manifestPath}`, entry, { cause: recoveryError });
1015
+ }
1016
+ }
1017
+ }
1018
+ //# sourceMappingURL=worktree-recovery.js.map