agentrinse 0.2.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.
- package/README.md +58 -16
- package/dist/adapters/git/adapter.d.ts +14 -1
- package/dist/adapters/git/adapter.d.ts.map +1 -1
- package/dist/adapters/git/adapter.js +217 -56
- package/dist/adapters/git/adapter.js.map +1 -1
- package/dist/adapters/git/refs.d.ts +6 -0
- package/dist/adapters/git/refs.d.ts.map +1 -0
- package/dist/adapters/git/refs.js +24 -0
- package/dist/adapters/git/refs.js.map +1 -0
- package/dist/adapters/git/status.d.ts +1 -0
- package/dist/adapters/git/status.d.ts.map +1 -1
- package/dist/adapters/git/status.js +6 -0
- package/dist/adapters/git/status.js.map +1 -1
- package/dist/adapters/registry.d.ts +2 -1
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/registry.js +6 -2
- package/dist/adapters/registry.js.map +1 -1
- package/dist/commands/apply.d.ts.map +1 -1
- package/dist/commands/apply.js +4 -0
- package/dist/commands/apply.js.map +1 -1
- package/dist/commands/clean.d.ts +2 -0
- package/dist/commands/clean.d.ts.map +1 -1
- package/dist/commands/clean.js +9 -3
- package/dist/commands/clean.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +3 -1
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/confirmation.d.ts +6 -0
- package/dist/commands/confirmation.d.ts.map +1 -0
- package/dist/commands/confirmation.js +21 -0
- package/dist/commands/confirmation.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/purge.d.ts +29 -0
- package/dist/commands/purge.d.ts.map +1 -0
- package/dist/commands/purge.js +168 -0
- package/dist/commands/purge.js.map +1 -0
- package/dist/commands/show.d.ts.map +1 -1
- package/dist/commands/show.js +14 -2
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/undo.d.ts +21 -0
- package/dist/commands/undo.d.ts.map +1 -0
- package/dist/commands/undo.js +66 -0
- package/dist/commands/undo.js.map +1 -0
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +4 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +4 -0
- package/dist/config/load.js.map +1 -1
- package/dist/config/schema.d.ts +4 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +18 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/contracts/action.d.ts +59 -0
- package/dist/contracts/action.d.ts.map +1 -1
- package/dist/contracts/action.js +28 -1
- package/dist/contracts/action.js.map +1 -1
- package/dist/contracts/finding.d.ts +22 -0
- package/dist/contracts/finding.d.ts.map +1 -1
- package/dist/contracts/plan.d.ts +23 -0
- package/dist/contracts/plan.d.ts.map +1 -1
- package/dist/contracts/plan.js +20 -1
- package/dist/contracts/plan.js.map +1 -1
- package/dist/contracts/quarantine.d.ts +79 -0
- package/dist/contracts/quarantine.d.ts.map +1 -0
- package/dist/contracts/quarantine.js +60 -0
- package/dist/contracts/quarantine.js.map +1 -0
- package/dist/contracts/report.d.ts +22 -0
- package/dist/contracts/report.d.ts.map +1 -1
- package/dist/contracts/run.d.ts +133 -5
- package/dist/contracts/run.d.ts.map +1 -1
- package/dist/contracts/run.js +17 -3
- package/dist/contracts/run.js.map +1 -1
- package/dist/core/apply.d.ts +8 -1
- package/dist/core/apply.d.ts.map +1 -1
- package/dist/core/apply.js +138 -48
- package/dist/core/apply.js.map +1 -1
- package/dist/core/git-operation-state.d.ts +3 -0
- package/dist/core/git-operation-state.d.ts.map +1 -0
- package/dist/core/git-operation-state.js +23 -0
- package/dist/core/git-operation-state.js.map +1 -0
- package/dist/core/measure.d.ts +1 -0
- package/dist/core/measure.d.ts.map +1 -1
- package/dist/core/measure.js +3 -0
- package/dist/core/measure.js.map +1 -1
- package/dist/core/no-clobber-rename.d.ts +2 -0
- package/dist/core/no-clobber-rename.d.ts.map +1 -0
- package/dist/core/no-clobber-rename.js +58 -0
- package/dist/core/no-clobber-rename.js.map +1 -0
- package/dist/core/plan.d.ts.map +1 -1
- package/dist/core/plan.js +1 -0
- package/dist/core/plan.js.map +1 -1
- package/dist/core/worktree-executor.d.ts +51 -0
- package/dist/core/worktree-executor.d.ts.map +1 -0
- package/dist/core/worktree-executor.js +676 -0
- package/dist/core/worktree-executor.js.map +1 -0
- package/dist/core/worktree-lock.d.ts +15 -0
- package/dist/core/worktree-lock.d.ts.map +1 -0
- package/dist/core/worktree-lock.js +139 -0
- package/dist/core/worktree-lock.js.map +1 -0
- package/dist/core/worktree-recovery.d.ts +37 -0
- package/dist/core/worktree-recovery.d.ts.map +1 -0
- package/dist/core/worktree-recovery.js +1018 -0
- package/dist/core/worktree-recovery.js.map +1 -0
- package/dist/core/worktree-revalidation.d.ts +25 -0
- package/dist/core/worktree-revalidation.d.ts.map +1 -0
- package/dist/core/worktree-revalidation.js +107 -0
- package/dist/core/worktree-revalidation.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +47 -0
- package/dist/main.js.map +1 -1
- package/dist/state/layout.d.ts +1 -0
- package/dist/state/layout.d.ts.map +1 -1
- package/dist/state/layout.js +1 -0
- package/dist/state/layout.js.map +1 -1
- package/dist/state/records.d.ts +6 -0
- package/dist/state/records.d.ts.map +1 -1
- package/dist/state/records.js +9 -1
- package/dist/state/records.js.map +1 -1
- package/dist/state/run-journal.d.ts.map +1 -1
- package/dist/state/run-journal.js +3 -0
- package/dist/state/run-journal.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/schemas/audit.schema.json +112 -0
- package/schemas/plan.schema.json +117 -0
- package/schemas/quarantine.schema.json +238 -0
- package/schemas/run.schema.json +152 -54
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, rm } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { parseWorktreePorcelain } from "../adapters/git/porcelain.js";
|
|
7
|
+
import { countStatusSuppressedIndexEntries, parseGitStatusPorcelainV2, } from "../adapters/git/status.js";
|
|
8
|
+
import { listGitRefsForCommit } from "../adapters/git/refs.js";
|
|
9
|
+
import { quarantineEntryIdSchema, quarantineEntrySchema, quarantineRecoveryRef, } from "../contracts/quarantine.js";
|
|
10
|
+
import { ensurePrivateDirectory, writeJsonAtomic } from "../state/json-file.js";
|
|
11
|
+
import { findGitOperations } from "./git-operation-state.js";
|
|
12
|
+
import { measurePath } from "./measure.js";
|
|
13
|
+
import { findMountBoundaries } from "./mount-boundaries.js";
|
|
14
|
+
import { renameNoReplace } from "./no-clobber-rename.js";
|
|
15
|
+
import { findProcessesUsingPath } from "./process-ownership.js";
|
|
16
|
+
import { lockOwnedWorktree, unlockOwnedWorktree } from "./worktree-lock.js";
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const QUARANTINE_OWNER_DIRECTORY = ".agentrinse-owner";
|
|
19
|
+
export class WorktreeExecutionError extends Error {
|
|
20
|
+
outcome;
|
|
21
|
+
entry;
|
|
22
|
+
name = "WorktreeExecutionError";
|
|
23
|
+
constructor(message, outcome, entry, options) {
|
|
24
|
+
super(message, options);
|
|
25
|
+
this.outcome = outcome;
|
|
26
|
+
this.entry = entry;
|
|
27
|
+
this.diagnosticCode = options?.diagnosticCode;
|
|
28
|
+
this.quarantinedBytes = options?.quarantinedBytes;
|
|
29
|
+
}
|
|
30
|
+
diagnosticCode;
|
|
31
|
+
quarantinedBytes;
|
|
32
|
+
}
|
|
33
|
+
async function defaultGitRunner(args) {
|
|
34
|
+
const result = await execFileAsync("git", args, {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
37
|
+
timeout: 15_000,
|
|
38
|
+
});
|
|
39
|
+
return result.stdout;
|
|
40
|
+
}
|
|
41
|
+
function isMissing(error) {
|
|
42
|
+
return (error instanceof Error && "code" in error && error.code === "ENOENT");
|
|
43
|
+
}
|
|
44
|
+
async function pathExists(path, inspect) {
|
|
45
|
+
try {
|
|
46
|
+
await inspect(path);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (isMissing(error)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function matchesFilesystemIdentity(stats, action) {
|
|
57
|
+
return (stats.isDirectory() &&
|
|
58
|
+
!stats.isSymbolicLink() &&
|
|
59
|
+
stats.dev === action.target.device &&
|
|
60
|
+
stats.ino === action.target.inode &&
|
|
61
|
+
stats.mtimeMs === action.target.mtimeMs);
|
|
62
|
+
}
|
|
63
|
+
function branchRef(branch) {
|
|
64
|
+
if (branch === undefined) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
|
|
68
|
+
}
|
|
69
|
+
function cleanStatusMatches(output, action) {
|
|
70
|
+
const status = parseGitStatusPorcelainV2(output);
|
|
71
|
+
return (status.head === action.target.head &&
|
|
72
|
+
branchRef(status.branch) === action.target.branch &&
|
|
73
|
+
status.staged + status.modified + status.untracked + status.conflicted + status.ignored === 0);
|
|
74
|
+
}
|
|
75
|
+
function assertAuthorized(authorization, entry) {
|
|
76
|
+
if (authorization !== undefined && authorization.now().getTime() >= authorization.expiresAtMs) {
|
|
77
|
+
throw new WorktreeExecutionError("cleanup plan authorization expired before worktree quarantine", "skipped-stale", entry, { diagnosticCode: "PLAN_EXPIRED_DURING_APPLY" });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function worktreeQuarantinePath(action, entryId) {
|
|
81
|
+
return join(dirname(action.target.path), ".agentrinse-quarantine", entryId);
|
|
82
|
+
}
|
|
83
|
+
export function worktreeRecoveryRef(action, runId) {
|
|
84
|
+
return quarantineRecoveryRef(runId, action.resourceId);
|
|
85
|
+
}
|
|
86
|
+
function worktreeLockReason(entryId) {
|
|
87
|
+
return `AgentRinse quarantine ${entryId}`;
|
|
88
|
+
}
|
|
89
|
+
function registeredWorktree(output, path, action, expectedLockReason) {
|
|
90
|
+
const expectedPath = resolve(path);
|
|
91
|
+
return parseWorktreePorcelain(output).some((record) => resolve(record.path) === expectedPath &&
|
|
92
|
+
record.head === action.target.head &&
|
|
93
|
+
record.branch === action.target.branch &&
|
|
94
|
+
record.locked === expectedLockReason);
|
|
95
|
+
}
|
|
96
|
+
function targetRegistrationLockMatches(output, action, expectedPath, expectedLockReason) {
|
|
97
|
+
const records = parseWorktreePorcelain(output).filter((record) => record.head === action.target.head && record.branch === action.target.branch);
|
|
98
|
+
return (records.length === 1 &&
|
|
99
|
+
resolve(records[0].path) === resolve(expectedPath) &&
|
|
100
|
+
records[0]?.locked === expectedLockReason);
|
|
101
|
+
}
|
|
102
|
+
function pathContains(parent, candidate) {
|
|
103
|
+
const relativePath = relative(resolve(parent), resolve(candidate));
|
|
104
|
+
return (relativePath !== "" &&
|
|
105
|
+
relativePath !== ".." &&
|
|
106
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
107
|
+
!isAbsolute(relativePath));
|
|
108
|
+
}
|
|
109
|
+
async function assertQuarantineContainerAvailable(quarantineParent, repositoryCommonDir, runGit, inspect, entry) {
|
|
110
|
+
const gitMarker = join(quarantineParent, ".git");
|
|
111
|
+
if (await pathExists(gitMarker, inspect)) {
|
|
112
|
+
throw new WorktreeExecutionError("quarantine container is itself a Git worktree", "failed", entry, { diagnosticCode: "QUARANTINE_CONTAINER_WORKTREE" });
|
|
113
|
+
}
|
|
114
|
+
const registrations = parseWorktreePorcelain(await runGit(["--git-dir", repositoryCommonDir, "worktree", "list", "--porcelain", "-z"]));
|
|
115
|
+
if (registrations.some((registration) => resolve(registration.path) === resolve(quarantineParent)) ||
|
|
116
|
+
(await pathExists(gitMarker, inspect))) {
|
|
117
|
+
throw new WorktreeExecutionError("quarantine container is itself a registered Git worktree", "failed", entry, { diagnosticCode: "QUARANTINE_CONTAINER_WORKTREE" });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function reserveQuarantineContainer(quarantineParent, repositoryCommonDir, runGit, inspect, platform) {
|
|
121
|
+
await assertQuarantineContainerAvailable(quarantineParent, repositoryCommonDir, runGit, inspect);
|
|
122
|
+
const ownerDirectory = join(quarantineParent, QUARANTINE_OWNER_DIRECTORY);
|
|
123
|
+
const stagingDirectory = join(dirname(quarantineParent), `.${basename(quarantineParent)}.agentrinse-${randomUUID()}`);
|
|
124
|
+
let installedStagingDirectory = false;
|
|
125
|
+
try {
|
|
126
|
+
await mkdir(stagingDirectory, { mode: 0o700 });
|
|
127
|
+
await mkdir(join(stagingDirectory, QUARANTINE_OWNER_DIRECTORY), { mode: 0o700 });
|
|
128
|
+
try {
|
|
129
|
+
await renameNoReplace(stagingDirectory, quarantineParent, platform);
|
|
130
|
+
installedStagingDirectory = true;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (!(error instanceof Error) ||
|
|
134
|
+
!("code" in error) ||
|
|
135
|
+
!["EEXIST", "ENOTEMPTY"].includes(String(error.code))) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
if (!installedStagingDirectory) {
|
|
142
|
+
await rm(stagingDirectory, { recursive: true, force: true }).catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await assertQuarantineContainerAvailable(quarantineParent, repositoryCommonDir, runGit, inspect);
|
|
146
|
+
const [containerStats, ownerStats] = await Promise.all([
|
|
147
|
+
inspect(quarantineParent),
|
|
148
|
+
inspect(ownerDirectory),
|
|
149
|
+
]).catch((error) => {
|
|
150
|
+
throw new WorktreeExecutionError("existing quarantine container is not owned by AgentRinse", "failed", undefined, { cause: error, diagnosticCode: "QUARANTINE_CONTAINER_UNSAFE" });
|
|
151
|
+
});
|
|
152
|
+
if (!containerStats.isDirectory() ||
|
|
153
|
+
containerStats.isSymbolicLink() ||
|
|
154
|
+
!ownerStats.isDirectory() ||
|
|
155
|
+
ownerStats.isSymbolicLink()) {
|
|
156
|
+
throw new WorktreeExecutionError("quarantine container ownership marker is not a real directory", "failed", undefined, { diagnosticCode: "QUARANTINE_CONTAINER_UNSAFE" });
|
|
157
|
+
}
|
|
158
|
+
await ensurePrivateDirectory(quarantineParent);
|
|
159
|
+
await ensurePrivateDirectory(ownerDirectory);
|
|
160
|
+
const [container, owner] = await Promise.all([
|
|
161
|
+
inspect(quarantineParent),
|
|
162
|
+
inspect(ownerDirectory),
|
|
163
|
+
]);
|
|
164
|
+
return { container, owner };
|
|
165
|
+
}
|
|
166
|
+
async function assertNoGitOperations(worktreePath, entry, runGit, inspect) {
|
|
167
|
+
const operations = await findGitOperations(worktreePath, runGit, (path) => pathExists(path, inspect));
|
|
168
|
+
if (operations.length > 0) {
|
|
169
|
+
throw new WorktreeExecutionError(`Git operation started before quarantine: ${operations.join(", ")}`, "skipped-stale", entry, { diagnosticCode: "GIT_OPERATION_IN_PROGRESS" });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function assertCleanPushedGitState(action, worktreePath, entry, runGit, inspect) {
|
|
173
|
+
const statusOutput = await runGit([
|
|
174
|
+
"-C",
|
|
175
|
+
worktreePath,
|
|
176
|
+
"status",
|
|
177
|
+
"--porcelain=v2",
|
|
178
|
+
"--branch",
|
|
179
|
+
"-z",
|
|
180
|
+
"--untracked-files=all",
|
|
181
|
+
"--ignored=matching",
|
|
182
|
+
]);
|
|
183
|
+
const status = parseGitStatusPorcelainV2(statusOutput);
|
|
184
|
+
const indexFlags = await runGit(["-C", worktreePath, "ls-files", "-z", "-v"]);
|
|
185
|
+
if (!cleanStatusMatches(statusOutput, action) ||
|
|
186
|
+
countStatusSuppressedIndexEntries(indexFlags) > 0) {
|
|
187
|
+
throw new WorktreeExecutionError("worktree Git state changed before quarantine", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
188
|
+
}
|
|
189
|
+
const { containingRefs } = await listGitRefsForCommit((args) => runGit(["--git-dir", action.target.repositoryCommonDir, ...args]), action.target.head);
|
|
190
|
+
if (status.ahead > 0 ||
|
|
191
|
+
action.target.branch === undefined ||
|
|
192
|
+
!containingRefs.includes(action.target.branch) ||
|
|
193
|
+
!containingRefs.some((ref) => ref.startsWith("refs/remotes/"))) {
|
|
194
|
+
throw new WorktreeExecutionError("worktree HEAD is no longer proven pushed before quarantine", "skipped-stale", entry, { diagnosticCode: "WORKTREE_UNPUSHED" });
|
|
195
|
+
}
|
|
196
|
+
await assertNoGitOperations(worktreePath, entry, runGit, inspect);
|
|
197
|
+
}
|
|
198
|
+
async function writeManifest(manifestPath, quarantineDirectory, entry) {
|
|
199
|
+
const parsed = quarantineEntrySchema.parse(entry);
|
|
200
|
+
await writeJsonAtomic(manifestPath, parsed, {
|
|
201
|
+
privateDirectories: [quarantineDirectory],
|
|
202
|
+
});
|
|
203
|
+
return parsed;
|
|
204
|
+
}
|
|
205
|
+
export async function executeWorktreeQuarantine(action, options) {
|
|
206
|
+
const dependencies = options.dependencies ?? {};
|
|
207
|
+
const runGit = dependencies.runGit ?? defaultGitRunner;
|
|
208
|
+
const inspect = dependencies.inspect ?? lstat;
|
|
209
|
+
const platform = dependencies.platform ?? process.platform;
|
|
210
|
+
const move = dependencies.move ?? ((source, destination) => renameNoReplace(source, destination, platform));
|
|
211
|
+
const measure = dependencies.measure ?? measurePath;
|
|
212
|
+
const processProbe = dependencies.processProbe ?? findProcessesUsingPath;
|
|
213
|
+
const mountProbe = dependencies.mountProbe ?? findMountBoundaries;
|
|
214
|
+
const clock = dependencies.clock ?? (() => new Date());
|
|
215
|
+
if (!["darwin", "linux"].includes(platform)) {
|
|
216
|
+
throw new WorktreeExecutionError(`worktree quarantine mutation is unsupported on ${platform}`, "failed", undefined, { diagnosticCode: "WORKTREE_PLATFORM_UNSUPPORTED" });
|
|
217
|
+
}
|
|
218
|
+
const entryId = quarantineEntryIdSchema.parse(options.entryId);
|
|
219
|
+
const quarantinePath = worktreeQuarantinePath(action, entryId);
|
|
220
|
+
if (pathContains(action.target.path, quarantinePath)) {
|
|
221
|
+
throw new WorktreeExecutionError("worktree occupies AgentRinse's reserved quarantine container", "failed", undefined, { diagnosticCode: "QUARANTINE_PATH_CONFLICT" });
|
|
222
|
+
}
|
|
223
|
+
const quarantineParent = dirname(quarantinePath);
|
|
224
|
+
const recoveryRef = worktreeRecoveryRef(action, options.runId);
|
|
225
|
+
const manifestPath = join(options.quarantineDirectory, `${entryId}.json`);
|
|
226
|
+
const createdAt = clock();
|
|
227
|
+
const measurementMaxEntries = dependencies.maxEntries ?? 100_000;
|
|
228
|
+
let entry = quarantineEntrySchema.parse({
|
|
229
|
+
schemaVersion: 1,
|
|
230
|
+
entryId,
|
|
231
|
+
runId: options.runId,
|
|
232
|
+
actionId: action.actionId,
|
|
233
|
+
resourceId: action.resourceId,
|
|
234
|
+
status: "preparing",
|
|
235
|
+
originalPath: action.target.path,
|
|
236
|
+
quarantinePath,
|
|
237
|
+
recoveryRef,
|
|
238
|
+
createdAt: createdAt.toISOString(),
|
|
239
|
+
expiresAt: new Date(createdAt.getTime() + action.quarantineTtlMinutes * 60_000).toISOString(),
|
|
240
|
+
measurementMaxEntries,
|
|
241
|
+
target: action.target,
|
|
242
|
+
});
|
|
243
|
+
let refCreated = false;
|
|
244
|
+
let moved = false;
|
|
245
|
+
let repaired = false;
|
|
246
|
+
let locked = false;
|
|
247
|
+
const quarantineParentIdentity = await reserveQuarantineContainer(quarantineParent, action.target.repositoryCommonDir, runGit, inspect, platform);
|
|
248
|
+
await ensurePrivateDirectory(options.quarantineDirectory);
|
|
249
|
+
entry = await writeManifest(manifestPath, options.quarantineDirectory, entry);
|
|
250
|
+
try {
|
|
251
|
+
const [targetStats, quarantineParentStats, commonDirStats] = await Promise.all([
|
|
252
|
+
inspect(action.target.path),
|
|
253
|
+
inspect(quarantineParent),
|
|
254
|
+
inspect(action.target.repositoryCommonDir),
|
|
255
|
+
]);
|
|
256
|
+
if (!matchesFilesystemIdentity(targetStats, action)) {
|
|
257
|
+
throw new WorktreeExecutionError("worktree filesystem identity changed before quarantine", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
258
|
+
}
|
|
259
|
+
if (!quarantineParentStats.isDirectory() ||
|
|
260
|
+
quarantineParentStats.isSymbolicLink() ||
|
|
261
|
+
quarantineParentStats.dev !== targetStats.dev ||
|
|
262
|
+
quarantineParentStats.dev !== quarantineParentIdentity.container.dev ||
|
|
263
|
+
quarantineParentStats.ino !== quarantineParentIdentity.container.ino) {
|
|
264
|
+
throw new WorktreeExecutionError("quarantine directory is not a real directory on the worktree filesystem", "failed", entry, { diagnosticCode: "QUARANTINE_CROSS_DEVICE" });
|
|
265
|
+
}
|
|
266
|
+
if (!commonDirStats.isDirectory() || commonDirStats.isSymbolicLink()) {
|
|
267
|
+
throw new WorktreeExecutionError("Git common directory is no longer a real directory", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
268
|
+
}
|
|
269
|
+
if (await pathExists(quarantinePath, inspect)) {
|
|
270
|
+
throw new WorktreeExecutionError(`refusing to overwrite existing quarantine path ${quarantinePath}`, "failed", entry);
|
|
271
|
+
}
|
|
272
|
+
const before = await runGit([
|
|
273
|
+
"--git-dir",
|
|
274
|
+
action.target.repositoryCommonDir,
|
|
275
|
+
"worktree",
|
|
276
|
+
"list",
|
|
277
|
+
"--porcelain",
|
|
278
|
+
"-z",
|
|
279
|
+
]);
|
|
280
|
+
if (!registeredWorktree(before, action.target.path, action)) {
|
|
281
|
+
throw new WorktreeExecutionError("Git worktree registration changed before quarantine", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
282
|
+
}
|
|
283
|
+
assertAuthorized(dependencies.authorization, entry);
|
|
284
|
+
await runGit([
|
|
285
|
+
"--git-dir",
|
|
286
|
+
action.target.repositoryCommonDir,
|
|
287
|
+
"update-ref",
|
|
288
|
+
recoveryRef,
|
|
289
|
+
action.target.head,
|
|
290
|
+
"",
|
|
291
|
+
]);
|
|
292
|
+
refCreated = true;
|
|
293
|
+
entry = await writeManifest(manifestPath, options.quarantineDirectory, {
|
|
294
|
+
...entry,
|
|
295
|
+
status: "recovery-ref-created",
|
|
296
|
+
});
|
|
297
|
+
const boundaryStats = await inspect(action.target.path);
|
|
298
|
+
if (!matchesFilesystemIdentity(boundaryStats, action)) {
|
|
299
|
+
throw new WorktreeExecutionError("worktree filesystem identity changed at the quarantine boundary", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
300
|
+
}
|
|
301
|
+
const boundaryMeasurement = await measure(action.target.path, {
|
|
302
|
+
maxEntries: measurementMaxEntries,
|
|
303
|
+
excludeRootEntries: [".git"],
|
|
304
|
+
});
|
|
305
|
+
if (boundaryMeasurement.truncated ||
|
|
306
|
+
boundaryMeasurement.specialEntries > 0 ||
|
|
307
|
+
boundaryMeasurement.mountBoundaries > 0 ||
|
|
308
|
+
boundaryMeasurement.bytes !== action.target.measuredBytes ||
|
|
309
|
+
boundaryMeasurement.newestMtimeMs !== action.target.newestMtimeMs ||
|
|
310
|
+
boundaryMeasurement.fingerprint !== action.target.fingerprint) {
|
|
311
|
+
throw new WorktreeExecutionError("worktree contents changed at the quarantine boundary", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
312
|
+
}
|
|
313
|
+
const boundaryMounts = await mountProbe(action.target.path);
|
|
314
|
+
if (boundaryMounts.status !== "clear") {
|
|
315
|
+
throw new WorktreeExecutionError(boundaryMounts.status === "blocked"
|
|
316
|
+
? "worktree gained a mount boundary before quarantine"
|
|
317
|
+
: "mount boundaries could not be proven absent before quarantine", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
318
|
+
}
|
|
319
|
+
await assertCleanPushedGitState(action, action.target.path, entry, runGit, inspect);
|
|
320
|
+
const boundaryOwnership = await processProbe(action.target.path);
|
|
321
|
+
if (boundaryOwnership.status !== "idle") {
|
|
322
|
+
throw new WorktreeExecutionError(boundaryOwnership.status === "busy"
|
|
323
|
+
? "a live process acquired the worktree before quarantine"
|
|
324
|
+
: "live process ownership could not be proven idle before quarantine", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
325
|
+
}
|
|
326
|
+
if (await pathExists(quarantinePath, inspect)) {
|
|
327
|
+
throw new WorktreeExecutionError(`refusing to overwrite existing quarantine path ${quarantinePath}`, "failed", entry);
|
|
328
|
+
}
|
|
329
|
+
assertAuthorized(dependencies.authorization, entry);
|
|
330
|
+
await assertQuarantineContainerAvailable(quarantineParent, action.target.repositoryCommonDir, runGit, inspect, entry);
|
|
331
|
+
const [finalQuarantineParentStats, finalQuarantineOwnerStats] = await Promise.all([
|
|
332
|
+
inspect(quarantineParent),
|
|
333
|
+
inspect(join(quarantineParent, QUARANTINE_OWNER_DIRECTORY)),
|
|
334
|
+
]);
|
|
335
|
+
if (!finalQuarantineParentStats.isDirectory() ||
|
|
336
|
+
finalQuarantineParentStats.isSymbolicLink() ||
|
|
337
|
+
finalQuarantineParentStats.dev !== quarantineParentIdentity.container.dev ||
|
|
338
|
+
finalQuarantineParentStats.ino !== quarantineParentIdentity.container.ino ||
|
|
339
|
+
!finalQuarantineOwnerStats.isDirectory() ||
|
|
340
|
+
finalQuarantineOwnerStats.isSymbolicLink() ||
|
|
341
|
+
finalQuarantineOwnerStats.dev !== quarantineParentIdentity.owner.dev ||
|
|
342
|
+
finalQuarantineOwnerStats.ino !== quarantineParentIdentity.owner.ino) {
|
|
343
|
+
throw new WorktreeExecutionError("quarantine container identity changed before the atomic move", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await dependencies.revalidateProtection?.();
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
throw new WorktreeExecutionError("worktree became protected before quarantine", "skipped-stale", entry, {
|
|
350
|
+
cause: error,
|
|
351
|
+
diagnosticCode: "WORKTREE_PROTECTION_CHANGED",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
assertAuthorized(dependencies.authorization, entry);
|
|
355
|
+
const finalBoundaryStats = await inspect(action.target.path);
|
|
356
|
+
if (!matchesFilesystemIdentity(finalBoundaryStats, action)) {
|
|
357
|
+
throw new WorktreeExecutionError("worktree filesystem identity changed after the protection refresh", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
358
|
+
}
|
|
359
|
+
const finalBoundaryMounts = await mountProbe(action.target.path);
|
|
360
|
+
if (finalBoundaryMounts.status !== "clear") {
|
|
361
|
+
throw new WorktreeExecutionError(finalBoundaryMounts.status === "blocked"
|
|
362
|
+
? "worktree gained a mount boundary during the protection refresh"
|
|
363
|
+
: "mount boundaries could not be proven absent after the protection refresh", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
364
|
+
}
|
|
365
|
+
await assertCleanPushedGitState(action, action.target.path, entry, runGit, inspect);
|
|
366
|
+
const finalBoundaryOwnership = await processProbe(action.target.path);
|
|
367
|
+
if (finalBoundaryOwnership.status !== "idle") {
|
|
368
|
+
throw new WorktreeExecutionError(finalBoundaryOwnership.status === "busy"
|
|
369
|
+
? "a live process acquired the worktree during the protection refresh"
|
|
370
|
+
: "live process ownership could not be proven idle after the protection refresh", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
371
|
+
}
|
|
372
|
+
if (await pathExists(quarantinePath, inspect)) {
|
|
373
|
+
throw new WorktreeExecutionError(`refusing to overwrite existing quarantine path ${quarantinePath}`, "failed", entry);
|
|
374
|
+
}
|
|
375
|
+
await assertQuarantineContainerAvailable(quarantineParent, action.target.repositoryCommonDir, runGit, inspect, entry);
|
|
376
|
+
const [moveQuarantineParentStats, moveQuarantineOwnerStats] = await Promise.all([
|
|
377
|
+
inspect(quarantineParent),
|
|
378
|
+
inspect(join(quarantineParent, QUARANTINE_OWNER_DIRECTORY)),
|
|
379
|
+
]);
|
|
380
|
+
if (!moveQuarantineParentStats.isDirectory() ||
|
|
381
|
+
moveQuarantineParentStats.isSymbolicLink() ||
|
|
382
|
+
moveQuarantineParentStats.dev !== quarantineParentIdentity.container.dev ||
|
|
383
|
+
moveQuarantineParentStats.ino !== quarantineParentIdentity.container.ino ||
|
|
384
|
+
!moveQuarantineOwnerStats.isDirectory() ||
|
|
385
|
+
moveQuarantineOwnerStats.isSymbolicLink() ||
|
|
386
|
+
moveQuarantineOwnerStats.dev !== quarantineParentIdentity.owner.dev ||
|
|
387
|
+
moveQuarantineOwnerStats.ino !== quarantineParentIdentity.owner.ino) {
|
|
388
|
+
throw new WorktreeExecutionError("quarantine container identity changed during the protection refresh", "skipped-stale", entry, { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" });
|
|
389
|
+
}
|
|
390
|
+
await lockOwnedWorktree({
|
|
391
|
+
worktreePath: action.target.path,
|
|
392
|
+
repositoryCommonDir: action.target.repositoryCommonDir,
|
|
393
|
+
expectedReason: worktreeLockReason(entryId),
|
|
394
|
+
claimId: entryId,
|
|
395
|
+
runGit,
|
|
396
|
+
platform,
|
|
397
|
+
});
|
|
398
|
+
locked = true;
|
|
399
|
+
const claimedRegistration = await runGit([
|
|
400
|
+
"--git-dir",
|
|
401
|
+
action.target.repositoryCommonDir,
|
|
402
|
+
"worktree",
|
|
403
|
+
"list",
|
|
404
|
+
"--porcelain",
|
|
405
|
+
"-z",
|
|
406
|
+
]);
|
|
407
|
+
if (!targetRegistrationLockMatches(claimedRegistration, action, action.target.path, worktreeLockReason(entryId))) {
|
|
408
|
+
throw new WorktreeExecutionError("AgentRinse worktree lock claim could not be verified before quarantine", "skipped-stale", entry, { diagnosticCode: "QUARANTINE_REGISTRATION_CHANGED" });
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
await move(action.target.path, quarantinePath);
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
throw new WorktreeExecutionError("worktree could not be atomically moved into quarantine", isMissing(error) ? "skipped-stale" : "failed", entry, {
|
|
415
|
+
cause: error,
|
|
416
|
+
...(error instanceof Error &&
|
|
417
|
+
"code" in error &&
|
|
418
|
+
error.code === "EXDEV"
|
|
419
|
+
? { diagnosticCode: "QUARANTINE_CROSS_DEVICE" }
|
|
420
|
+
: isMissing(error)
|
|
421
|
+
? { diagnosticCode: "WORKTREE_IDENTITY_CHANGED" }
|
|
422
|
+
: {}),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
moved = true;
|
|
426
|
+
entry = await writeManifest(manifestPath, options.quarantineDirectory, {
|
|
427
|
+
...entry,
|
|
428
|
+
status: "moved",
|
|
429
|
+
});
|
|
430
|
+
const [quarantinedStats, isolatedMeasurement] = await Promise.all([
|
|
431
|
+
inspect(quarantinePath),
|
|
432
|
+
measure(quarantinePath, {
|
|
433
|
+
maxEntries: measurementMaxEntries,
|
|
434
|
+
excludeRootEntries: [".git"],
|
|
435
|
+
}),
|
|
436
|
+
]);
|
|
437
|
+
if (!matchesFilesystemIdentity(quarantinedStats, action) ||
|
|
438
|
+
isolatedMeasurement.truncated ||
|
|
439
|
+
isolatedMeasurement.specialEntries > 0 ||
|
|
440
|
+
isolatedMeasurement.mountBoundaries > 0 ||
|
|
441
|
+
isolatedMeasurement.bytes !== action.target.measuredBytes ||
|
|
442
|
+
isolatedMeasurement.newestMtimeMs !== action.target.newestMtimeMs ||
|
|
443
|
+
isolatedMeasurement.fingerprint !== action.target.fingerprint) {
|
|
444
|
+
throw new WorktreeExecutionError("worktree contents or identity changed during quarantine", "partially-applied", entry);
|
|
445
|
+
}
|
|
446
|
+
await assertNoGitOperations(quarantinePath, entry, runGit, inspect);
|
|
447
|
+
const beforeRepair = await runGit([
|
|
448
|
+
"--git-dir",
|
|
449
|
+
action.target.repositoryCommonDir,
|
|
450
|
+
"worktree",
|
|
451
|
+
"list",
|
|
452
|
+
"--porcelain",
|
|
453
|
+
"-z",
|
|
454
|
+
]);
|
|
455
|
+
if (!targetRegistrationLockMatches(beforeRepair, action, action.target.path, worktreeLockReason(entryId))) {
|
|
456
|
+
throw new WorktreeExecutionError("worktree registration or lock ownership changed before repair", "partially-applied", entry, { diagnosticCode: "QUARANTINE_REGISTRATION_CHANGED" });
|
|
457
|
+
}
|
|
458
|
+
await runGit([
|
|
459
|
+
"--git-dir",
|
|
460
|
+
action.target.repositoryCommonDir,
|
|
461
|
+
"worktree",
|
|
462
|
+
"repair",
|
|
463
|
+
quarantinePath,
|
|
464
|
+
]);
|
|
465
|
+
repaired = true;
|
|
466
|
+
const after = await runGit([
|
|
467
|
+
"--git-dir",
|
|
468
|
+
action.target.repositoryCommonDir,
|
|
469
|
+
"worktree",
|
|
470
|
+
"list",
|
|
471
|
+
"--porcelain",
|
|
472
|
+
"-z",
|
|
473
|
+
]);
|
|
474
|
+
if (!registeredWorktree(after, quarantinePath, action, worktreeLockReason(entryId))) {
|
|
475
|
+
throw new WorktreeExecutionError("quarantined worktree registration could not be verified", "partially-applied", entry);
|
|
476
|
+
}
|
|
477
|
+
const verifiedRef = (await runGit([
|
|
478
|
+
"--git-dir",
|
|
479
|
+
action.target.repositoryCommonDir,
|
|
480
|
+
"rev-parse",
|
|
481
|
+
"--verify",
|
|
482
|
+
recoveryRef,
|
|
483
|
+
])).trim();
|
|
484
|
+
if (verifiedRef !== action.target.head) {
|
|
485
|
+
throw new WorktreeExecutionError("quarantine recovery ref does not match the planned HEAD", "partially-applied", entry);
|
|
486
|
+
}
|
|
487
|
+
const [quarantineStats, quarantineMeasurement] = await Promise.all([
|
|
488
|
+
inspect(quarantinePath),
|
|
489
|
+
measure(quarantinePath, {
|
|
490
|
+
maxEntries: measurementMaxEntries,
|
|
491
|
+
excludeRootEntries: [".git"],
|
|
492
|
+
}),
|
|
493
|
+
]);
|
|
494
|
+
if (!quarantineStats.isDirectory() ||
|
|
495
|
+
quarantineStats.isSymbolicLink() ||
|
|
496
|
+
quarantineStats.dev !== action.target.device ||
|
|
497
|
+
quarantineStats.ino !== action.target.inode ||
|
|
498
|
+
quarantineMeasurement.truncated ||
|
|
499
|
+
quarantineMeasurement.specialEntries > 0 ||
|
|
500
|
+
quarantineMeasurement.mountBoundaries > 0 ||
|
|
501
|
+
quarantineMeasurement.bytes !== action.target.measuredBytes ||
|
|
502
|
+
quarantineMeasurement.newestMtimeMs !== action.target.newestMtimeMs ||
|
|
503
|
+
quarantineMeasurement.fingerprint !== action.target.fingerprint) {
|
|
504
|
+
throw new WorktreeExecutionError("post-repair quarantine identity could not be proven", "partially-applied", entry);
|
|
505
|
+
}
|
|
506
|
+
const finalStatus = await runGit([
|
|
507
|
+
"-C",
|
|
508
|
+
quarantinePath,
|
|
509
|
+
"status",
|
|
510
|
+
"--porcelain=v2",
|
|
511
|
+
"--branch",
|
|
512
|
+
"-z",
|
|
513
|
+
"--untracked-files=all",
|
|
514
|
+
"--ignored=matching",
|
|
515
|
+
]);
|
|
516
|
+
const finalIndexFlags = await runGit(["-C", quarantinePath, "ls-files", "-z", "-v"]);
|
|
517
|
+
if (!cleanStatusMatches(finalStatus, action) ||
|
|
518
|
+
countStatusSuppressedIndexEntries(finalIndexFlags) > 0) {
|
|
519
|
+
throw new WorktreeExecutionError("quarantined worktree Git state changed before commit", "partially-applied", entry);
|
|
520
|
+
}
|
|
521
|
+
await assertNoGitOperations(quarantinePath, entry, runGit, inspect);
|
|
522
|
+
const finalMounts = await mountProbe(quarantinePath);
|
|
523
|
+
if (finalMounts.status !== "clear") {
|
|
524
|
+
throw new WorktreeExecutionError(finalMounts.status === "blocked"
|
|
525
|
+
? "quarantined worktree gained a mount boundary before commit"
|
|
526
|
+
: "mount boundaries could not be proven absent before quarantine commit", "partially-applied", entry);
|
|
527
|
+
}
|
|
528
|
+
const finalOwnership = await processProbe(quarantinePath);
|
|
529
|
+
if (finalOwnership.status !== "idle") {
|
|
530
|
+
throw new WorktreeExecutionError(finalOwnership.status === "busy"
|
|
531
|
+
? "a live process acquired the quarantined worktree before commit"
|
|
532
|
+
: "live process ownership could not be proven idle before quarantine commit", "partially-applied", entry);
|
|
533
|
+
}
|
|
534
|
+
entry = await writeManifest(manifestPath, options.quarantineDirectory, {
|
|
535
|
+
...entry,
|
|
536
|
+
status: "quarantined",
|
|
537
|
+
quarantineIdentity: {
|
|
538
|
+
...action.target,
|
|
539
|
+
path: quarantinePath,
|
|
540
|
+
device: quarantineStats.dev,
|
|
541
|
+
inode: quarantineStats.ino,
|
|
542
|
+
mtimeMs: quarantineStats.mtimeMs,
|
|
543
|
+
measuredBytes: quarantineMeasurement.bytes,
|
|
544
|
+
newestMtimeMs: quarantineMeasurement.newestMtimeMs,
|
|
545
|
+
fingerprint: quarantineMeasurement.fingerprint,
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
return {
|
|
549
|
+
quarantineEntryId: entry.entryId,
|
|
550
|
+
quarantinePath: entry.quarantinePath,
|
|
551
|
+
recoveryRef: entry.recoveryRef,
|
|
552
|
+
quarantinedBytes: action.target.measuredBytes,
|
|
553
|
+
manifestPath,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
const executionError = error instanceof WorktreeExecutionError
|
|
558
|
+
? error
|
|
559
|
+
: new WorktreeExecutionError(error instanceof Error ? error.message : String(error), moved ? "partially-applied" : "failed", entry, { cause: error });
|
|
560
|
+
const diagnostic = {
|
|
561
|
+
severity: "error",
|
|
562
|
+
code: executionError.diagnosticCode ?? "WORKTREE_QUARANTINE_FAILED",
|
|
563
|
+
message: executionError.message,
|
|
564
|
+
adapter: action.adapter,
|
|
565
|
+
resourceId: action.resourceId,
|
|
566
|
+
};
|
|
567
|
+
let rollbackFinalizing = false;
|
|
568
|
+
try {
|
|
569
|
+
if (locked) {
|
|
570
|
+
await unlockOwnedWorktree({
|
|
571
|
+
worktreePath: moved ? quarantinePath : action.target.path,
|
|
572
|
+
repositoryCommonDir: action.target.repositoryCommonDir,
|
|
573
|
+
expectedReason: worktreeLockReason(entryId),
|
|
574
|
+
claimId: entryId,
|
|
575
|
+
runGit,
|
|
576
|
+
platform,
|
|
577
|
+
});
|
|
578
|
+
locked = false;
|
|
579
|
+
}
|
|
580
|
+
if (moved) {
|
|
581
|
+
await assertNoGitOperations(quarantinePath, entry, runGit, inspect);
|
|
582
|
+
const beforeRollbackRepair = await runGit([
|
|
583
|
+
"--git-dir",
|
|
584
|
+
action.target.repositoryCommonDir,
|
|
585
|
+
"worktree",
|
|
586
|
+
"list",
|
|
587
|
+
"--porcelain",
|
|
588
|
+
"-z",
|
|
589
|
+
]);
|
|
590
|
+
if (!targetRegistrationLockMatches(beforeRollbackRepair, action, repaired ? quarantinePath : action.target.path)) {
|
|
591
|
+
throw new Error("worktree lock ownership changed before automatic rollback");
|
|
592
|
+
}
|
|
593
|
+
if ((await pathExists(action.target.path, inspect)) ||
|
|
594
|
+
!(await pathExists(quarantinePath, inspect))) {
|
|
595
|
+
throw new Error("worktree paths are not safe for automatic rollback");
|
|
596
|
+
}
|
|
597
|
+
await move(quarantinePath, action.target.path);
|
|
598
|
+
moved = false;
|
|
599
|
+
await runGit([
|
|
600
|
+
"--git-dir",
|
|
601
|
+
action.target.repositoryCommonDir,
|
|
602
|
+
"worktree",
|
|
603
|
+
"repair",
|
|
604
|
+
action.target.path,
|
|
605
|
+
]);
|
|
606
|
+
const restored = await runGit([
|
|
607
|
+
"--git-dir",
|
|
608
|
+
action.target.repositoryCommonDir,
|
|
609
|
+
"worktree",
|
|
610
|
+
"list",
|
|
611
|
+
"--porcelain",
|
|
612
|
+
"-z",
|
|
613
|
+
]);
|
|
614
|
+
if (!registeredWorktree(restored, action.target.path, action)) {
|
|
615
|
+
throw new Error("restored Git worktree registration could not be verified");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
rollbackFinalizing = true;
|
|
619
|
+
if (refCreated) {
|
|
620
|
+
await runGit([
|
|
621
|
+
"--git-dir",
|
|
622
|
+
action.target.repositoryCommonDir,
|
|
623
|
+
"update-ref",
|
|
624
|
+
"-d",
|
|
625
|
+
recoveryRef,
|
|
626
|
+
action.target.head,
|
|
627
|
+
]);
|
|
628
|
+
refCreated = false;
|
|
629
|
+
}
|
|
630
|
+
entry = await writeManifest(manifestPath, options.quarantineDirectory, {
|
|
631
|
+
...entry,
|
|
632
|
+
status: "restored",
|
|
633
|
+
restoredAt: clock().toISOString(),
|
|
634
|
+
diagnostic,
|
|
635
|
+
});
|
|
636
|
+
throw new WorktreeExecutionError(moved ? `${executionError.message}; worktree rollback completed` : executionError.message, executionError.outcome === "skipped-stale"
|
|
637
|
+
? "skipped-stale"
|
|
638
|
+
: executionError.entry?.status === "moved" ||
|
|
639
|
+
executionError.outcome === "partially-applied"
|
|
640
|
+
? "rolled-back"
|
|
641
|
+
: "failed", entry, {
|
|
642
|
+
cause: executionError,
|
|
643
|
+
...(executionError.diagnosticCode === undefined
|
|
644
|
+
? {}
|
|
645
|
+
: { diagnosticCode: executionError.diagnosticCode }),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
catch (rollbackError) {
|
|
649
|
+
if (rollbackError instanceof WorktreeExecutionError &&
|
|
650
|
+
rollbackError.cause === executionError) {
|
|
651
|
+
throw rollbackError;
|
|
652
|
+
}
|
|
653
|
+
if (rollbackFinalizing) {
|
|
654
|
+
throw new WorktreeExecutionError("worktree rollback restored the original path but finalization is pending; retry undo", "partially-applied", entry, {
|
|
655
|
+
cause: rollbackError,
|
|
656
|
+
diagnosticCode: "WORKTREE_ROLLBACK_FINALIZE_PENDING",
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
entry = await writeManifest(manifestPath, options.quarantineDirectory, {
|
|
660
|
+
...entry,
|
|
661
|
+
status: "partial",
|
|
662
|
+
diagnostic: {
|
|
663
|
+
...diagnostic,
|
|
664
|
+
code: "WORKTREE_QUARANTINE_PARTIAL",
|
|
665
|
+
message: `${executionError.message}; automatic rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
|
666
|
+
},
|
|
667
|
+
}).catch(() => entry);
|
|
668
|
+
throw new WorktreeExecutionError(`worktree quarantine failed with partial state; inspect ${manifestPath}`, "partially-applied", entry, {
|
|
669
|
+
cause: rollbackError,
|
|
670
|
+
diagnosticCode: "WORKTREE_QUARANTINE_PARTIAL",
|
|
671
|
+
quarantinedBytes: moved ? action.target.measuredBytes : 0,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
//# sourceMappingURL=worktree-executor.js.map
|