harnery 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/commander.js +1 -0
  2. package/dist/commands/files.d.ts +2 -2
  3. package/dist/commands/files.d.ts.map +1 -1
  4. package/dist/commands/files.js +7 -4
  5. package/dist/commands/servers.d.ts.map +1 -1
  6. package/dist/commands/servers.js +11 -17
  7. package/dist/commands/tunnel.d.ts +15 -0
  8. package/dist/commands/tunnel.d.ts.map +1 -1
  9. package/dist/commands/tunnel.js +453 -153
  10. package/dist/commands/turn-rollback.d.ts +5 -0
  11. package/dist/commands/turn-rollback.d.ts.map +1 -0
  12. package/dist/commands/turn-rollback.js +76 -0
  13. package/dist/commands/workflow.d.ts.map +1 -1
  14. package/dist/commands/workflow.js +44 -0
  15. package/dist/core/agents/coord-client.d.ts.map +1 -1
  16. package/dist/core/agents/coord-client.js +18 -0
  17. package/dist/core/agents/state/proc-start.d.ts +5 -6
  18. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  19. package/dist/core/agents/state/proc-start.js +13 -9
  20. package/dist/core/agents/state/process-handle.d.ts +26 -0
  21. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  22. package/dist/core/agents/state/process-handle.js +121 -0
  23. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  24. package/dist/core/artifacts/delivery-card.js +2 -1
  25. package/dist/core/resources/service.d.ts +2 -16
  26. package/dist/core/resources/service.d.ts.map +1 -1
  27. package/dist/core/resources/service.js +1 -78
  28. package/dist/core/semantic/service.d.ts.map +1 -1
  29. package/dist/core/semantic/service.js +2 -9
  30. package/dist/core/servers/index.d.ts +12 -11
  31. package/dist/core/servers/index.d.ts.map +1 -1
  32. package/dist/core/servers/index.js +194 -81
  33. package/dist/core/servers/tunnels.d.ts +5 -3
  34. package/dist/core/servers/tunnels.d.ts.map +1 -1
  35. package/dist/core/servers/tunnels.js +45 -2
  36. package/dist/core/supervisor/service.d.ts +2 -4
  37. package/dist/core/supervisor/service.d.ts.map +1 -1
  38. package/dist/core/turn-rollback/content.d.ts +53 -0
  39. package/dist/core/turn-rollback/content.d.ts.map +1 -0
  40. package/dist/core/turn-rollback/content.js +396 -0
  41. package/dist/core/turn-rollback/index.d.ts +72 -0
  42. package/dist/core/turn-rollback/index.d.ts.map +1 -0
  43. package/dist/core/turn-rollback/index.js +443 -0
  44. package/dist/core/workflow/index.d.ts +4 -2
  45. package/dist/core/workflow/index.d.ts.map +1 -1
  46. package/dist/core/workflow/index.js +2 -1
  47. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  48. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  49. package/dist/core/workflow/recovery-audit.js +116 -0
  50. package/dist/core/workflow/run-state.d.ts +19 -2
  51. package/dist/core/workflow/run-state.d.ts.map +1 -1
  52. package/dist/core/workflow/run-state.js +113 -8
  53. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  54. package/dist/core/workflow/workspaces/local-git.js +70 -14
  55. package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
  56. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  57. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  58. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  59. package/dist/core/workflow/workspaces/recovery.js +24 -0
  60. package/dist/lib/tunnel/state.d.ts +13 -6
  61. package/dist/lib/tunnel/state.d.ts.map +1 -1
  62. package/dist/lib/tunnel/state.js +58 -12
  63. package/package.json +6 -1
  64. package/src/commander.ts +10 -0
  65. package/src/commands/files.ts +13 -5
  66. package/src/commands/servers.ts +12 -23
  67. package/src/commands/tunnel.ts +533 -166
  68. package/src/commands/turn-rollback.ts +111 -0
  69. package/src/commands/workflow.ts +59 -0
  70. package/src/core/agents/coord-client.ts +20 -0
  71. package/src/core/agents/state/proc-start.ts +14 -11
  72. package/src/core/agents/state/process-handle.ts +165 -0
  73. package/src/core/artifacts/delivery-card.ts +7 -1
  74. package/src/core/resources/service.ts +3 -88
  75. package/src/core/semantic/service.ts +2 -8
  76. package/src/core/servers/index.ts +234 -85
  77. package/src/core/servers/tunnels.ts +55 -3
  78. package/src/core/supervisor/service.ts +2 -3
  79. package/src/core/turn-rollback/content.ts +499 -0
  80. package/src/core/turn-rollback/index.ts +580 -0
  81. package/src/core/workflow/index.ts +12 -1
  82. package/src/core/workflow/recovery-audit.ts +181 -0
  83. package/src/core/workflow/run-state.ts +137 -8
  84. package/src/core/workflow/workspaces/local-git.ts +86 -15
  85. package/src/core/workflow/workspaces/reclaim.ts +4 -0
  86. package/src/core/workflow/workspaces/recovery.ts +23 -0
  87. package/src/lib/tunnel/state.ts +70 -15
@@ -0,0 +1,499 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ closeSync,
5
+ constants,
6
+ existsSync,
7
+ fchmodSync,
8
+ fstatSync,
9
+ fsyncSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ renameSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { dirname, join, relative, resolve } from "node:path";
20
+ import { stateDirMode, stateFileMode } from "../storage/modes.ts";
21
+ import { fsyncParentDirectory, stableDigest } from "../workflow/durable-record.ts";
22
+ import {
23
+ assertSafeRelativeSegment,
24
+ containsPath,
25
+ descriptorPath,
26
+ filesystemIdentity,
27
+ sameFilesystemIdentity,
28
+ } from "../workflow/workspaces/paths.ts";
29
+ import type { WorkspaceBinding } from "../workflow/workspaces/types.ts";
30
+
31
+ export const SNAPSHOT_LIMIT = 8 * 1024 * 1024;
32
+ export interface BlobEntry {
33
+ oid: string;
34
+ mode: "100644" | "100755";
35
+ }
36
+ export interface ContentEntry {
37
+ path: string;
38
+ index: BlobEntry | null;
39
+ worktree: BlobEntry | null;
40
+ untracked: boolean;
41
+ }
42
+ export interface ContentSnapshot {
43
+ head: string;
44
+ index_tree: string;
45
+ worktree_tree: string;
46
+ entries: ContentEntry[];
47
+ total_bytes: number;
48
+ sha256: string;
49
+ }
50
+ interface ObservedContent {
51
+ entries: ContentEntry[];
52
+ blobs: Map<string, Buffer>;
53
+ total: number;
54
+ head: string;
55
+ }
56
+
57
+ export function git(
58
+ root: string,
59
+ args: string[],
60
+ options: { input?: Buffer | string; index?: string; bare?: boolean } = {},
61
+ ): Buffer {
62
+ const env = { ...process.env };
63
+ for (const key of Object.keys(env)) if (key.startsWith("GIT_")) delete env[key];
64
+ Object.assign(env, {
65
+ GIT_OPTIONAL_LOCKS: "0",
66
+ GIT_CONFIG_NOSYSTEM: "1",
67
+ GIT_CONFIG_GLOBAL: "/dev/null",
68
+ GIT_LITERAL_PATHSPECS: "1",
69
+ });
70
+ if (args[0] === "check-ignore") delete env.GIT_LITERAL_PATHSPECS;
71
+ if (options.index) env.GIT_INDEX_FILE = options.index;
72
+ return execFileSync(
73
+ "git",
74
+ [options.bare ? `--git-dir=${root}` : "-C", ...(options.bare ? [] : [root]), ...args],
75
+ {
76
+ env,
77
+ input: options.input,
78
+ maxBuffer: SNAPSHOT_LIMIT + 1024 * 1024,
79
+ timeout: 10_000,
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ },
82
+ );
83
+ }
84
+ export function textGit(root: string, args: string[], options?: Parameters<typeof git>[2]): string {
85
+ return git(root, args, options).toString("utf8").trim();
86
+ }
87
+
88
+ export function safePaths(binding: WorkspaceBinding, paths: readonly string[]): string[] {
89
+ if (!Array.isArray(paths) || paths.length === 0 || paths.length > 100)
90
+ throw new Error("path count limit is 1 to 100");
91
+ const normalized = paths
92
+ .map((path) => {
93
+ if (typeof path !== "string" || path.length > 2000) throw new Error("invalid content path");
94
+ for (const part of path.split("/")) {
95
+ assertSafeRelativeSegment(part, "content path");
96
+ if ([".git", ".harnery"].includes(part.toLowerCase()))
97
+ throw new Error("administrative content path is forbidden");
98
+ }
99
+ if (
100
+ relative(binding.workspace_root, resolve(binding.workspace_root, path)).replaceAll(
101
+ "\\",
102
+ "/",
103
+ ) !== path
104
+ )
105
+ throw new Error("content path escapes workspace");
106
+ return path;
107
+ })
108
+ .sort();
109
+ if (new Set(normalized).size !== normalized.length) throw new Error("duplicate content paths");
110
+ for (const path of normalized) {
111
+ const opened = openParent(binding, path);
112
+ opened.close();
113
+ }
114
+ return normalized;
115
+ }
116
+
117
+ export function openParent(
118
+ binding: WorkspaceBinding,
119
+ path: string,
120
+ ): { path: string; close(): void } {
121
+ const fds: number[] = [];
122
+ try {
123
+ const root = binding.workspace_root;
124
+ let fd = openSync(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
125
+ fds.push(fd);
126
+ if (
127
+ !sameFilesystemIdentity(
128
+ filesystemIdentity(descriptorPath(fd)),
129
+ binding.workspace_root_identity,
130
+ )
131
+ )
132
+ throw new Error("workspace root identity changed");
133
+ for (const segment of path.split("/").slice(0, -1)) {
134
+ fd = openSync(
135
+ join(descriptorPath(fd), segment),
136
+ constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
137
+ );
138
+ fds.push(fd);
139
+ if (existsSync(join(descriptorPath(fd), ".git")))
140
+ throw new Error("nested repository boundary is forbidden");
141
+ }
142
+ const parent = descriptorPath(fd);
143
+ const real = realpathSync(parent);
144
+ if (!containsPath(root, real)) throw new Error("content parent escaped workspace");
145
+ for (const reserved of [
146
+ binding.repository?.gitdir.realpath,
147
+ binding.repository?.common_dir.realpath,
148
+ ]) {
149
+ if (reserved && containsPath(reserved, real))
150
+ throw new Error("Git administrative content is forbidden");
151
+ }
152
+ return {
153
+ path: join(parent, path.split("/").at(-1)!),
154
+ close: () => {
155
+ for (const handle of fds.reverse()) closeSync(handle);
156
+ },
157
+ };
158
+ } catch (error) {
159
+ for (const fd of fds.reverse()) closeSync(fd);
160
+ throw error;
161
+ }
162
+ }
163
+
164
+ function bytesAt(
165
+ binding: WorkspaceBinding,
166
+ path: string,
167
+ ): { bytes: Buffer; mode: BlobEntry["mode"] } | null {
168
+ const parent = openParent(binding, path);
169
+ try {
170
+ let stat: import("node:fs").Stats;
171
+ try {
172
+ stat = lstatSync(parent.path);
173
+ } catch (error) {
174
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
175
+ throw error;
176
+ }
177
+ if (!stat.isFile() || stat.nlink !== 1)
178
+ throw new Error("content must be an unlinked regular file");
179
+ if (stat.size > SNAPSHOT_LIMIT) throw new Error("snapshot byte limit exceeded");
180
+ const fd = openSync(parent.path, constants.O_RDONLY | constants.O_NOFOLLOW);
181
+ try {
182
+ const current = fstatSync(fd);
183
+ if (
184
+ !current.isFile() ||
185
+ current.nlink !== 1 ||
186
+ current.ino !== stat.ino ||
187
+ current.dev !== stat.dev
188
+ )
189
+ throw new Error("content identity changed");
190
+ const bytes = readFileSync(fd);
191
+ const after = fstatSync(fd);
192
+ if (
193
+ after.size !== current.size ||
194
+ after.mtimeMs !== current.mtimeMs ||
195
+ after.ctimeMs !== current.ctimeMs
196
+ )
197
+ throw new Error("content changed during capture");
198
+ return { bytes, mode: current.mode & 0o111 ? "100755" : "100644" };
199
+ } finally {
200
+ closeSync(fd);
201
+ }
202
+ } finally {
203
+ parent.close();
204
+ }
205
+ }
206
+
207
+ function indexEntries(
208
+ binding: WorkspaceBinding,
209
+ paths: string[],
210
+ ): Map<string, { mode: BlobEntry["mode"]; source: string }> {
211
+ const root = binding.workspace_root;
212
+ if (textGit(root, ["rev-parse", "--shared-index-path"]))
213
+ throw new Error("split index is unsupported");
214
+ try {
215
+ if (textGit(root, ["config", "--bool", "index.sparse"]) === "true")
216
+ throw new Error("sparse index is unsupported");
217
+ } catch (error) {
218
+ if (!(error && typeof error === "object" && "status" in error && error.status === 1))
219
+ throw error;
220
+ }
221
+ const flags = git(root, ["ls-files", "-v", "-z", "--", ...paths])
222
+ .toString("utf8")
223
+ .split("\0")
224
+ .filter(Boolean);
225
+ if (flags.some((flag) => !flag.startsWith("H ")))
226
+ throw new Error("selected index flags are unsupported");
227
+ const entries = new Map<string, { mode: BlobEntry["mode"]; source: string }>();
228
+ const debugFlags = git(root, ["ls-files", "--debug", "-z", "--", ...paths]).toString("utf8");
229
+ for (const match of debugFlags.matchAll(/flags: ([a-f0-9]+)\n/g)) {
230
+ if ((Number.parseInt(match[1]!, 16) & 0x20000000) !== 0)
231
+ throw new Error("intent-to-add index entries are unsupported");
232
+ }
233
+ for (const line of git(root, ["ls-files", "--stage", "-z", "--", ...paths])
234
+ .toString("utf8")
235
+ .split("\0")
236
+ .filter(Boolean)) {
237
+ const match = /^(100644|100755) ([a-f0-9]{40,64}) 0\t(.+)$/.exec(line);
238
+ if (!match || /^0+$/.test(match[2]!) || !paths.includes(match[3]!))
239
+ throw new Error("unsupported index entry");
240
+ entries.set(match[3]!, { mode: match[1] as BlobEntry["mode"], source: match[2]! });
241
+ }
242
+ return entries;
243
+ }
244
+
245
+ export function observe(
246
+ binding: WorkspaceBinding,
247
+ store: string,
248
+ paths: string[],
249
+ ): ObservedContent {
250
+ const staged = indexEntries(binding, paths);
251
+ const blobs = new Map<string, Buffer>();
252
+ let total = 0;
253
+ const blob = (bytes: Buffer, mode: BlobEntry["mode"]): BlobEntry => {
254
+ total += bytes.length;
255
+ if (total > SNAPSHOT_LIMIT) throw new Error("snapshot byte limit exceeded");
256
+ const oid = textGit(store, ["hash-object", "--stdin"], { bare: true, input: bytes });
257
+ blobs.set(oid, bytes);
258
+ return { oid, mode };
259
+ };
260
+ const entries = paths.map((path): ContentEntry => {
261
+ const index = staged.get(path);
262
+ const worktree = bytesAt(binding, path);
263
+ // check-ignore without --no-index only reports untracked ignored paths.
264
+ if (!index) {
265
+ try {
266
+ git(binding.workspace_root, ["check-ignore", "-q", "--", path]);
267
+ throw new Error("ignored content path is forbidden");
268
+ } catch (error) {
269
+ if (!(error && typeof error === "object" && "status" in error && error.status === 1))
270
+ throw error;
271
+ }
272
+ }
273
+ return {
274
+ path,
275
+ index: index
276
+ ? blob(git(binding.workspace_root, ["cat-file", "blob", index.source]), index.mode)
277
+ : null,
278
+ worktree: worktree ? blob(worktree.bytes, worktree.mode) : null,
279
+ untracked: !index && worktree !== null,
280
+ };
281
+ });
282
+ return { entries, blobs, total, head: textGit(binding.workspace_root, ["rev-parse", "HEAD"]) };
283
+ }
284
+
285
+ function tree(store: string, entries: ContentEntry[], side: "index" | "worktree"): string {
286
+ const temporary = join(dirname(store), `tree-${randomUUID()}.index`);
287
+ try {
288
+ git(store, ["read-tree", "--empty"], { bare: true, index: temporary });
289
+ for (const entry of entries) {
290
+ const blob = entry[side];
291
+ if (blob)
292
+ git(
293
+ store,
294
+ ["update-index", "--add", "--cacheinfo", `${blob.mode},${blob.oid},${entry.path}`],
295
+ { bare: true, index: temporary },
296
+ );
297
+ }
298
+ return textGit(store, ["write-tree"], { bare: true, index: temporary });
299
+ } finally {
300
+ if (existsSync(temporary)) unlinkSync(temporary);
301
+ }
302
+ }
303
+
304
+ export function persistSnapshot(
305
+ store: string,
306
+ observed: ObservedContent,
307
+ ref: string,
308
+ ): ContentSnapshot {
309
+ for (const bytes of observed.blobs.values())
310
+ git(store, ["hash-object", "-w", "--stdin"], { bare: true, input: bytes });
311
+ const basis = {
312
+ head: observed.head,
313
+ entries: observed.entries,
314
+ total_bytes: observed.total,
315
+ index_tree: tree(store, observed.entries, "index"),
316
+ worktree_tree: tree(store, observed.entries, "worktree"),
317
+ };
318
+ for (const side of ["index", "worktree"] as const)
319
+ git(store, ["update-ref", `refs/turns/${ref}/${side}`, basis[`${side}_tree`]], { bare: true });
320
+ return { ...basis, sha256: stableDigest(basis) };
321
+ }
322
+
323
+ export function verifySnapshot(store: string, snapshot: ContentSnapshot): void {
324
+ const { sha256, ...basis } = snapshot;
325
+ if (
326
+ sha256 !== stableDigest(basis) ||
327
+ !Number.isSafeInteger(snapshot.total_bytes) ||
328
+ snapshot.total_bytes < 0 ||
329
+ snapshot.total_bytes > SNAPSHOT_LIMIT
330
+ )
331
+ throw new Error("snapshot digest or byte limit changed");
332
+ let total = 0;
333
+ for (const side of ["index", "worktree"] as const) {
334
+ const actual = git(store, ["ls-tree", "-r", "-z", snapshot[`${side}_tree`]], { bare: true })
335
+ .toString("utf8")
336
+ .split("\0")
337
+ .filter(Boolean)
338
+ .sort();
339
+ const expected = snapshot.entries
340
+ .filter((entry) => entry[side])
341
+ .map((entry) => `${entry[side]!.mode} blob ${entry[side]!.oid}\t${entry.path}`)
342
+ .sort();
343
+ if (stableDigest(actual) !== stableDigest(expected)) throw new Error("snapshot tree changed");
344
+ for (const entry of snapshot.entries) {
345
+ const blob = entry[side];
346
+ if (blob) {
347
+ const bytes = git(store, ["cat-file", "blob", blob.oid], { bare: true });
348
+ if (textGit(store, ["hash-object", "--stdin"], { bare: true, input: bytes }) !== blob.oid)
349
+ throw new Error("snapshot blob digest changed");
350
+ total += bytes.length;
351
+ }
352
+ }
353
+ }
354
+ if (total !== snapshot.total_bytes) throw new Error("snapshot byte count changed");
355
+ }
356
+
357
+ export function currentMatches(
358
+ binding: WorkspaceBinding,
359
+ store: string,
360
+ snapshot: ContentSnapshot,
361
+ ): boolean {
362
+ const current = observe(
363
+ binding,
364
+ store,
365
+ snapshot.entries.map((entry) => entry.path),
366
+ );
367
+ return (
368
+ current.head === snapshot.head &&
369
+ stableDigest(current.entries) === stableDigest(snapshot.entries)
370
+ );
371
+ }
372
+
373
+ export interface IndexLock {
374
+ path: string;
375
+ privatePath: string;
376
+ publish(): void;
377
+ close(): void;
378
+ }
379
+ export function lockIndex(binding: WorkspaceBinding, state: string): IndexLock {
380
+ const path = join(binding.repository!.gitdir.realpath, "index");
381
+ const lock = `${path}.lock`;
382
+ const fd = openSync(lock, "wx", stateFileMode());
383
+ const identity = fstatSync(fd, { bigint: true });
384
+ const privatePath = join(state, `live-${randomUUID()}.index`);
385
+ let published = false;
386
+ const exact = () => {
387
+ const current = lstatSync(lock, { bigint: true });
388
+ if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino)
389
+ throw new Error("Git index lock changed");
390
+ };
391
+ try {
392
+ writeFileSync(privatePath, readFileSync(path), { flag: "wx", mode: stateFileMode() });
393
+ } catch (error) {
394
+ closeSync(fd);
395
+ exact();
396
+ unlinkSync(lock);
397
+ throw error;
398
+ }
399
+ return {
400
+ path,
401
+ privatePath,
402
+ publish() {
403
+ exact();
404
+ writeFileSync(fd, readFileSync(privatePath));
405
+ fsyncSync(fd);
406
+ exact();
407
+ renameSync(lock, path);
408
+ published = true;
409
+ const dir = openSync(dirname(path), "r");
410
+ try {
411
+ fsyncSync(dir);
412
+ } finally {
413
+ closeSync(dir);
414
+ }
415
+ },
416
+ close() {
417
+ closeSync(fd);
418
+ if (!published) {
419
+ exact();
420
+ unlinkSync(lock);
421
+ }
422
+ if (existsSync(privatePath)) unlinkSync(privatePath);
423
+ },
424
+ };
425
+ }
426
+
427
+ export function stageBefore(
428
+ binding: WorkspaceBinding,
429
+ store: string,
430
+ before: ContentSnapshot,
431
+ index: IndexLock,
432
+ ): void {
433
+ for (const entry of before.entries) {
434
+ if (entry.index) {
435
+ const bytes = git(store, ["cat-file", "blob", entry.index.oid], { bare: true });
436
+ const sourceOid = textGit(binding.workspace_root, ["hash-object", "-w", "--stdin"], {
437
+ input: bytes,
438
+ });
439
+ git(
440
+ binding.workspace_root,
441
+ ["update-index", "--add", "--cacheinfo", `${entry.index.mode},${sourceOid},${entry.path}`],
442
+ { index: index.privatePath },
443
+ );
444
+ } else
445
+ git(binding.workspace_root, ["update-index", "--force-remove", "--", entry.path], {
446
+ index: index.privatePath,
447
+ });
448
+ }
449
+ }
450
+
451
+ export function restoreFile(binding: WorkspaceBinding, store: string, entry: ContentEntry): void {
452
+ const parent = openParent(binding, entry.path);
453
+ const temporary = `${parent.path}.restore-${randomUUID()}`;
454
+ try {
455
+ if (!entry.worktree) {
456
+ if (existsSync(parent.path)) unlinkSync(parent.path);
457
+ } else {
458
+ const fd = openSync(temporary, "wx", entry.worktree.mode === "100755" ? 0o755 : 0o644);
459
+ try {
460
+ fchmodSync(fd, entry.worktree.mode === "100755" ? 0o755 : 0o644);
461
+ writeFileSync(fd, git(store, ["cat-file", "blob", entry.worktree.oid], { bare: true }));
462
+ fsyncSync(fd);
463
+ } finally {
464
+ closeSync(fd);
465
+ }
466
+ renameSync(temporary, parent.path);
467
+ }
468
+ const fd = openSync(dirname(parent.path), "r");
469
+ try {
470
+ fsyncSync(fd);
471
+ } finally {
472
+ closeSync(fd);
473
+ }
474
+ } finally {
475
+ if (existsSync(temporary)) unlinkSync(temporary);
476
+ parent.close();
477
+ }
478
+ }
479
+
480
+ export function ensureDirectory(path: string): void {
481
+ if (!existsSync(path)) {
482
+ ensureDirectory(dirname(path));
483
+ mkdirSync(path, { mode: stateDirMode() });
484
+ const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
485
+ try {
486
+ fchmodSync(fd, stateDirMode());
487
+ fsyncSync(fd);
488
+ } finally {
489
+ closeSync(fd);
490
+ }
491
+ fsyncParentDirectory(path);
492
+ }
493
+ if (
494
+ !lstatSync(path).isDirectory() ||
495
+ lstatSync(path).isSymbolicLink() ||
496
+ realpathSync(path) !== resolve(path)
497
+ )
498
+ throw new Error("rollback state path is unsafe");
499
+ }