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