@pmelab/gtd 63.0.0 → 64.0.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -3
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
  6. package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-zGjNeQNL.mjs} +1011 -313
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +3890 -1294
  9. package/dist/gtd.core.mjs +119 -118
  10. package/dist/steering.mjs +1 -1
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +9 -27
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +11 -30
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +30 -10
  23. package/src/emit/SteeringMode.test.ts +4 -2
  24. package/src/emit/SteeringMode.ts +10 -9
  25. package/src/gherkin/messages.test.ts +1 -1
  26. package/src/gherkin/module-config.feature +1 -1
  27. package/src/gherkin/testkit.feature +2 -2
  28. package/src/install/Version.ts +14 -13
  29. package/src/platform/Host.ts +75 -22
  30. package/src/platform/NodePinFile.ts +44 -0
  31. package/src/platform/NodeRuntime.ts +19 -0
  32. package/src/platform/NodeSync.ts +64 -0
  33. package/src/platform/PinFile.ts +0 -50
  34. package/src/platform/Sha.ts +121 -0
  35. package/src/platform/SyncIo.ts +35 -0
  36. package/src/platform/Workspace.test.ts +8 -3
  37. package/src/platform/Workspace.ts +82 -123
  38. package/src/platform/WorktreeState.test.ts +8 -4
  39. package/src/platform/WorktreeState.ts +21 -19
  40. package/src/platform/index.ts +18 -5
  41. package/src/replay/Format.test.ts +16 -13
  42. package/src/replay/Format.ts +4 -2
  43. package/src/replay/Replay.test.ts +25 -5
  44. package/src/replay/Replay.ts +10 -6
  45. package/src/replay/Trailers.test.ts +10 -10
  46. package/src/replay/Trailers.ts +23 -1
  47. package/src/replay/index.ts +1 -0
  48. package/src/step/planStart.test.ts +2 -2
  49. package/src/step/planStep.test.ts +4 -4
  50. package/src/testkit/TestRepo.test.ts +1 -1
  51. package/src/testkit/TestRepo.ts +4 -18
  52. package/src/twins/Host.test.ts +151 -0
  53. package/src/twins/Layers.test.ts +2 -2
  54. package/src/twins/Layers.ts +44 -18
  55. package/src/twins/Path.test.ts +52 -0
  56. package/src/twins/SyncIo.test.ts +153 -0
  57. package/src/twins/SyncIo.ts +159 -0
  58. package/src/twins/cliIo.ts +8 -1
  59. package/src/twins/index.ts +5 -0
  60. package/src/workflow/discovery.test.ts +6 -5
  61. package/src/workflow/discovery.ts +43 -32
  62. package/src/workflow/fingerprint.test.ts +23 -3
  63. package/src/workflow/fingerprint.ts +9 -11
  64. package/src/workflow/load.test.ts +16 -6
  65. package/src/workflow/load.ts +107 -69
  66. package/src/workflow/pin.test.ts +3 -2
  67. package/src/workflow/pin.ts +17 -11
@@ -1,9 +1,3 @@
1
- import { execFileSync } from "node:child_process"
2
- import { existsSync, readFileSync } from "node:fs"
3
- import { registerHooks } from "node:module"
4
- import { join } from "node:path"
5
- import { pathToFileURL } from "node:url"
6
-
7
1
  /** A process's pin as the launcher found it: the file's bytes and where its engine runs from. */
8
2
  export interface FoundPin {
9
3
  readonly path: string
@@ -15,47 +9,3 @@ export interface FoundPin {
15
9
  * the landing after an unpin beat deleted the file still runs on the pin.
16
10
  */
17
11
  export type PinFile = () => FoundPin | undefined
18
-
19
- // `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
20
- // a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
21
- // read counts as no pin, so the launcher refuses it rather than crashing.
22
- export const livePinFile =
23
- (cwd: string, path: string): PinFile =>
24
- () => {
25
- const file = join(cwd, path)
26
- if (existsSync(file)) {
27
- try {
28
- return { path: file, bytes: readFileSync(file) }
29
- } catch {
30
- return { path: file, bytes: new Uint8Array() }
31
- }
32
- }
33
- try {
34
- const bytes = execFileSync("git", ["show", `HEAD:${path}`], {
35
- cwd,
36
- stdio: ["ignore", "pipe", "ignore"],
37
- })
38
- return { path: file, bytes }
39
- } catch {
40
- return undefined
41
- }
42
- }
43
-
44
- /**
45
- * Runs a pin's module as this process's main program. Node refuses an unknown
46
- * extension, so a load hook serves the unpacked source under the pin's own
47
- * URL — `import.meta.url` stays a file in the repository.
48
- */
49
- export const runPinModule = async (path: string, source: string): Promise<void> => {
50
- const url = pathToFileURL(path).href
51
- // A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
52
- registerHooks({
53
- resolve: (specifier, context, nextResolve) =>
54
- specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
55
- load: (requested, context, nextLoad) =>
56
- requested === url
57
- ? { format: "module", source, shortCircuit: true }
58
- : nextLoad(requested, context),
59
- })
60
- await import(url)
61
- }
@@ -0,0 +1,121 @@
1
+ import type { HashAlgorithm } from "./SyncIo.js"
2
+
3
+ // The digest for a runtime without Node's crypto, which the in-memory tier runs on.
4
+
5
+ const K256 = Uint32Array.from([
6
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
7
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
8
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
9
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
10
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
11
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
12
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
13
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
14
+ ])
15
+
16
+ const rotl = (x: number, n: number): number => (x << n) | (x >>> (32 - n))
17
+ const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n))
18
+
19
+ /** `data` padded to whole 64-byte blocks, its bit length big-endian at the end. */
20
+ const padded = (data: Uint8Array): DataView => {
21
+ const length = Math.ceil((data.length + 9) / 64) * 64
22
+ const bytes = new Uint8Array(length)
23
+ bytes.set(data)
24
+ bytes[data.length] = 0x80
25
+ const view = new DataView(bytes.buffer)
26
+ view.setUint32(length - 8, Math.floor(data.length / 0x20000000))
27
+ view.setUint32(length - 4, (data.length * 8) >>> 0)
28
+ return view
29
+ }
30
+
31
+ /** Round `i`'s boolean function of `b`, `c`, `d`, plus its constant. */
32
+ const sha1Mix = (i: number, b: number, c: number, d: number): number =>
33
+ i < 20
34
+ ? ((b & c) | (~b & d)) + 0x5a827999
35
+ : i < 40
36
+ ? (b ^ c ^ d) + 0x6ed9eba1
37
+ : i < 60
38
+ ? ((b & c) | (b & d) | (c & d)) + 0x8f1bbcdc
39
+ : (b ^ c ^ d) + 0xca62c1d6
40
+
41
+ const sha1 = (view: DataView): Uint32Array => {
42
+ const h = Uint32Array.from([0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0])
43
+ const w = new Uint32Array(80)
44
+ for (let block = 0; block < view.byteLength; block += 64) {
45
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(block + i * 4)
46
+ for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3]! ^ w[i - 8]! ^ w[i - 14]! ^ w[i - 16]!, 1)
47
+ let [a, b, c, d, e] = [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!]
48
+ for (let i = 0; i < 80; i++) {
49
+ const t = (rotl(a, 5) + sha1Mix(i, b, c, d) + e + w[i]!) | 0
50
+ e = d
51
+ d = c
52
+ c = rotl(b, 30)
53
+ b = a
54
+ a = t
55
+ }
56
+ h[0]! += a
57
+ h[1]! += b
58
+ h[2]! += c
59
+ h[3]! += d
60
+ h[4]! += e
61
+ }
62
+ return h
63
+ }
64
+
65
+ const sha256 = (view: DataView): Uint32Array => {
66
+ const h = Uint32Array.from([
67
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
68
+ ])
69
+ const w = new Uint32Array(64)
70
+ for (let block = 0; block < view.byteLength; block += 64) {
71
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(block + i * 4)
72
+ for (let i = 16; i < 64; i++) {
73
+ const x = w[i - 15]!
74
+ const y = w[i - 2]!
75
+ const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3)
76
+ const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10)
77
+ w[i] = w[i - 16]! + s0 + w[i - 7]! + s1
78
+ }
79
+ let [a, b, c, d, e, f, g, hh] = [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!, h[5]!, h[6]!, h[7]!]
80
+ for (let i = 0; i < 64; i++) {
81
+ const t1 =
82
+ (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K256[i]! + w[i]!) |
83
+ 0
84
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0
85
+ hh = g
86
+ g = f
87
+ f = e
88
+ e = (d + t1) | 0
89
+ d = c
90
+ c = b
91
+ b = a
92
+ a = (t1 + t2) | 0
93
+ }
94
+ h[0]! += a
95
+ h[1]! += b
96
+ h[2]! += c
97
+ h[3]! += d
98
+ h[4]! += e
99
+ h[5]! += f
100
+ h[6]! += g
101
+ h[7]! += hh
102
+ }
103
+ return h
104
+ }
105
+
106
+ const encoder = new TextEncoder()
107
+
108
+ export const pureHash = (
109
+ algorithm: HashAlgorithm,
110
+ ...parts: readonly (string | Uint8Array)[]
111
+ ): string => {
112
+ const chunks = parts.map((part) => (typeof part === "string" ? encoder.encode(part) : part))
113
+ const data = new Uint8Array(chunks.reduce((n, chunk) => n + chunk.length, 0))
114
+ let at = 0
115
+ for (const chunk of chunks) {
116
+ data.set(chunk, at)
117
+ at += chunk.length
118
+ }
119
+ const words = (algorithm === "sha1" ? sha1 : sha256)(padded(data))
120
+ return [...words].map((word) => word.toString(16).padStart(8, "0")).join("")
121
+ }
@@ -0,0 +1,35 @@
1
+ export type HashAlgorithm = "sha1" | "sha256"
2
+
3
+ export interface FileInfo {
4
+ readonly type: "file" | "directory" | "symlink" | "other"
5
+ /** `undefined` where the platform has no POSIX user ids. */
6
+ readonly uid: number | undefined
7
+ }
8
+
9
+ /**
10
+ * The machine reads that must stay synchronous: workflows read the tree
11
+ * between two awaits during replay, so neither the reads nor the hashing
12
+ * behind them can wait on IO. Absence is a value (`undefined`); any other
13
+ * fault throws.
14
+ */
15
+ export interface SyncIo {
16
+ readonly readFile: (path: string) => Uint8Array | undefined
17
+ readonly readText: (path: string) => string | undefined
18
+ readonly writeText: (path: string, content: string) => void
19
+ /** Creates every missing parent too. */
20
+ readonly makeDirectory: (path: string) => void
21
+ readonly rename: (from: string, to: string) => void
22
+ /** Follows a symlink. */
23
+ readonly stat: (path: string) => FileInfo | undefined
24
+ /** Reports a symlink as one. */
25
+ readonly lstat: (path: string) => FileInfo | undefined
26
+ readonly readLink: (path: string) => string
27
+ /** Throws when `path` does not exist. */
28
+ readonly realPath: (path: string) => string
29
+ /** git's stdout; throws on a non-zero exit, with git's stderr passed through. */
30
+ readonly git: (cwd: string, args: readonly string[]) => string
31
+ /** `git`'s raw stdout, with its stderr discarded. */
32
+ readonly gitBytes: (cwd: string, args: readonly string[]) => Uint8Array
33
+ /** Lowercase hex digest of the parts, concatenated; a string part is UTF-8. */
34
+ readonly hash: (algorithm: HashAlgorithm, ...parts: readonly (string | Uint8Array)[]) => string
35
+ }
@@ -54,7 +54,9 @@ const makeLiveTier = (): WorkspaceTier => {
54
54
 
55
55
  const hostLayer = Host.layer({ root, home: root, env: {} })
56
56
  const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
57
- const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
57
+ const workspaceLayer = Workspace.Live.pipe(
58
+ Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
59
+ )
58
60
 
59
61
  return {
60
62
  root,
@@ -270,8 +272,9 @@ describe("Workspace [Live] worktreeSync", () => {
270
272
  Effect.provide(
271
273
  Workspace.Live.pipe(
272
274
  Layer.provide(
273
- Layer.merge(
275
+ Layer.mergeAll(
274
276
  Host.layer({ root, home: root, env: {} }),
277
+ NodeServices.layer,
275
278
  GitService.Live.pipe(
276
279
  Layer.provide(
277
280
  Layer.merge(Host.layer({ root, home: root, env: {} }), NodeServices.layer),
@@ -361,7 +364,9 @@ const makeLiveEpisodeTier = () => {
361
364
 
362
365
  const hostLayer = Host.layer({ root, home: root, env: {} })
363
366
  const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
364
- const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
367
+ const workspaceLayer = Workspace.Live.pipe(
368
+ Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
369
+ )
365
370
  const ops = (): Promise<WorkspaceOps> =>
366
371
  Effect.runPromise(
367
372
  Effect.gen(function* () {
@@ -1,38 +1,22 @@
1
- import { execFileSync } from "node:child_process"
2
- import { createHash } from "node:crypto"
3
- import {
4
- lstatSync,
5
- mkdirSync,
6
- readFileSync,
7
- readlinkSync,
8
- renameSync,
9
- writeFileSync,
10
- } from "node:fs"
11
- import { dirname, isAbsolute, join } from "node:path"
12
- import { Context, Effect, Layer } from "effect"
1
+ import { Context, Effect, Layer, Path } from "effect"
13
2
  import { GitService, type GitOperations } from "./Git.js"
14
- import { Host } from "./Host.js"
3
+ import { Host, type HostOps } from "./Host.js"
4
+ import { nodeSyncIo } from "./NodeSync.js"
5
+ import type { SyncIo } from "./SyncIo.js"
15
6
 
16
7
  const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
17
8
 
18
- // A tree listing, or one committed file, can overflow execFileSync's 1 MB default.
19
- const MAX_BUFFER = 256 * 1024 * 1024
20
9
  const HASH_CHUNK = 500
10
+ const encoder = new TextEncoder()
21
11
 
22
12
  /** A full sha1 or sha256 object id — the only refs an immutable, disk-cached read is safe to key on. */
23
13
  const isObjectHash = (ref: string): boolean => /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(ref)
24
14
 
25
15
  const GTD_CACHE_SUBDIR = "gtd-cache"
26
16
 
27
- const treeCachePath = (commonDir: string, hash: string): string =>
28
- join(commonDir, GTD_CACHE_SUBDIR, "trees", hash)
29
-
30
- const blobCachePath = (commonDir: string, id: string): string =>
31
- join(commonDir, GTD_CACHE_SUBDIR, "blobs", id)
32
-
33
- const readTextCache = (path: string): string | undefined => {
17
+ const readTextCache = (io: SyncIo, path: string): string | undefined => {
34
18
  try {
35
- return readFileSync(path, "utf8")
19
+ return io.readText(path)
36
20
  } catch {
37
21
  return undefined
38
22
  }
@@ -46,20 +30,26 @@ const readTextCache = (path: string): string | undefined => {
46
30
  * cache is a best-effort speedup, never a correctness dependency, and the
47
31
  * alternative — failing a gtd command over a cache write — is worse.
48
32
  */
49
- const writeTextCacheAtomic = (path: string, content: string): void => {
33
+ const writeTextCacheAtomic = (
34
+ io: SyncIo,
35
+ paths: Path.Path,
36
+ pid: number,
37
+ path: string,
38
+ content: string,
39
+ ): void => {
50
40
  try {
51
- mkdirSync(dirname(path), { recursive: true })
52
- const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
53
- writeFileSync(tmp, content, "utf8")
54
- renameSync(tmp, path)
41
+ io.makeDirectory(paths.dirname(path))
42
+ const tmp = `${path}.tmp-${pid}-${Math.random().toString(36).slice(2)}`
43
+ io.writeText(tmp, content)
44
+ io.rename(tmp, path)
55
45
  } catch {
56
46
  // A full disk or read-only git dir silently disables the cache.
57
47
  }
58
48
  }
59
49
 
60
50
  /** A commit's `path → blob id` map, serialized as JSON. `undefined` on ANY read failure — missing, truncated, unparseable — so the caller falls back to asking git. */
61
- const readTreeCache = (commonDir: string, hash: string): Map<string, string> | undefined => {
62
- const raw = readTextCache(treeCachePath(commonDir, hash))
51
+ const readTreeCache = (io: SyncIo, file: string): Map<string, string> | undefined => {
52
+ const raw = readTextCache(io, file)
63
53
  if (raw === undefined) return undefined
64
54
  try {
65
55
  const parsed: unknown = JSON.parse(raw)
@@ -75,8 +65,13 @@ const readTreeCache = (commonDir: string, hash: string): Map<string, string> | u
75
65
  }
76
66
  }
77
67
 
78
- const writeTreeCache = (commonDir: string, hash: string, tree: ReadonlyMap<string, string>): void =>
79
- writeTextCacheAtomic(treeCachePath(commonDir, hash), JSON.stringify(Object.fromEntries(tree)))
68
+ const writeTreeCache = (
69
+ io: SyncIo,
70
+ paths: Path.Path,
71
+ pid: number,
72
+ file: string,
73
+ tree: ReadonlyMap<string, string>,
74
+ ): void => writeTextCacheAtomic(io, paths, pid, file, JSON.stringify(Object.fromEntries(tree)))
80
75
 
81
76
  /**
82
77
  * One raw `git log --raw` diff entry, already resolved to what it does to a
@@ -177,19 +172,14 @@ const parseLsTree = (out: string): Map<string, string> => {
177
172
  * check `ok` before doing so.
178
173
  */
179
174
  const fetchTree = (
175
+ io: SyncIo,
180
176
  root: string,
181
177
  ref: string,
182
178
  ): { readonly ok: boolean; readonly entries: Map<string, string> } => {
183
179
  try {
184
180
  return {
185
181
  ok: true,
186
- entries: parseLsTree(
187
- execFileSync("git", ["ls-tree", "-r", "-z", "--full-tree", ref], {
188
- cwd: root,
189
- encoding: "utf8",
190
- maxBuffer: MAX_BUFFER,
191
- }),
192
- ),
182
+ entries: parseLsTree(io.git(root, ["ls-tree", "-r", "-z", "--full-tree", ref])),
193
183
  }
194
184
  } catch {
195
185
  return { ok: false, entries: new Map() }
@@ -263,8 +253,8 @@ export interface WorkspaceOps {
263
253
  readonly writeAtPath: (path: string, content: string) => Effect.Effect<void, Error>
264
254
  }
265
255
 
266
- const assertRepoRelative = (path: string): string => {
267
- if (isAbsolute(path)) {
256
+ const assertRepoRelative = (paths: Path.Path, path: string): string => {
257
+ if (paths.isAbsolute(path)) {
268
258
  throw new Error(
269
259
  `Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath"/"writeAtPath" to read/write outside the repo)`,
270
260
  )
@@ -272,24 +262,6 @@ const assertRepoRelative = (path: string): string => {
272
262
  return path
273
263
  }
274
264
 
275
- const readFileOrAbsent = (path: string): string | undefined => {
276
- try {
277
- return readFileSync(path, "utf8")
278
- } catch (e) {
279
- if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
280
- throw e
281
- }
282
- }
283
-
284
- const lstatOrAbsent = (path: string): ReturnType<typeof lstatSync> | undefined => {
285
- try {
286
- return lstatSync(path)
287
- } catch (e) {
288
- if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
289
- throw e
290
- }
291
- }
292
-
293
265
  /** One replayed commit: its hash and the (already-`\n`-stripped) `--raw -z` body `parseRawDiff` consumes. */
294
266
  interface RawLogEntry {
295
267
  readonly hash: string
@@ -304,27 +276,24 @@ interface RawLogEntry {
304
276
  * bounds no longer match git's own history.
305
277
  */
306
278
  const runRawLogReplay = (
279
+ io: SyncIo,
307
280
  root: string,
308
281
  base: string | undefined,
309
282
  head: string,
310
283
  commits: readonly string[],
311
284
  ): readonly RawLogEntry[] => {
312
285
  const range = base !== undefined ? `${base}..${head}` : head
313
- const out = execFileSync(
314
- "git",
315
- [
316
- "log",
317
- "--first-parent",
318
- "--reverse",
319
- "-z",
320
- "--raw",
321
- "-r",
322
- "--no-abbrev",
323
- "--format=%x01%H",
324
- range,
325
- ],
326
- { cwd: root, encoding: "utf8", maxBuffer: MAX_BUFFER },
327
- )
286
+ const out = io.git(root, [
287
+ "log",
288
+ "--first-parent",
289
+ "--reverse",
290
+ "-z",
291
+ "--raw",
292
+ "-r",
293
+ "--no-abbrev",
294
+ "--format=%x01%H",
295
+ range,
296
+ ])
328
297
  const chunks = out.split("\x01").filter((c) => c.length > 0)
329
298
  if (chunks.length !== commits.length) {
330
299
  throw new Error(
@@ -363,11 +332,19 @@ const foldRawDiffs = (
363
332
  }
364
333
 
365
334
  const makeWorkspaceOps = (
366
- root: string,
335
+ { root, pid }: HostOps,
367
336
  git: GitOperations,
368
337
  commonDir: string | undefined,
338
+ paths: Path.Path,
339
+ io: SyncIo,
369
340
  ): WorkspaceOps => {
370
- const resolveAny = (path: string): string => (isAbsolute(path) ? path : join(root, path))
341
+ const resolveAny = (path: string): string =>
342
+ paths.isAbsolute(path) ? path : paths.join(root, path)
343
+ const repoPath = (path: string): string => paths.join(root, assertRepoRelative(paths, path))
344
+ const treeCachePath = (dir: string, hash: string): string =>
345
+ paths.join(dir, GTD_CACHE_SUBDIR, "trees", hash)
346
+ const blobCachePath = (dir: string, id: string): string =>
347
+ paths.join(dir, GTD_CACHE_SUBDIR, "blobs", id)
371
348
 
372
349
  // Per-PROCESS memo, on the workspace instance rather than any one replay's
373
350
  // `TreeView` — an immutable key (a commit hash, a blob id) can never serve
@@ -381,18 +358,18 @@ const makeWorkspaceOps = (
381
358
  const memoHit = treeMemo.get(hash)
382
359
  if (memoHit !== undefined) return memoHit
383
360
  if (commonDir === undefined) return undefined
384
- const cached = readTreeCache(commonDir, hash)
361
+ const cached = readTreeCache(io, treeCachePath(commonDir, hash))
385
362
  if (cached !== undefined) treeMemo.set(hash, cached)
386
363
  return cached
387
364
  }
388
365
 
389
366
  const rememberTree = (hash: string, tree: ReadonlyMap<string, string>): void => {
390
367
  treeMemo.set(hash, tree)
391
- if (commonDir !== undefined) writeTreeCache(commonDir, hash, tree)
368
+ if (commonDir !== undefined)
369
+ writeTreeCache(io, paths, pid, treeCachePath(commonDir, hash), tree)
392
370
  }
393
371
 
394
- const readSync = (path: string): string | undefined =>
395
- readFileOrAbsent(join(root, assertRepoRelative(path)))
372
+ const readSync = (path: string): string | undefined => io.readText(repoPath(path))
396
373
 
397
374
  /**
398
375
  * A blob's content, keyed on its OWN id rather than a (path, ref) pair —
@@ -405,7 +382,7 @@ const makeWorkspaceOps = (
405
382
  const memoHit = blobMemo.get(id)
406
383
  if (memoHit !== undefined) return memoHit
407
384
  if (commonDir !== undefined) {
408
- const cached = readTextCache(blobCachePath(commonDir, id))
385
+ const cached = readTextCache(io, blobCachePath(commonDir, id))
409
386
  if (cached !== undefined) {
410
387
  blobMemo.set(id, cached)
411
388
  return cached
@@ -413,23 +390,20 @@ const makeWorkspaceOps = (
413
390
  }
414
391
  let content: string
415
392
  try {
416
- content = execFileSync("git", ["cat-file", "-p", id], {
417
- cwd: root,
418
- encoding: "utf8",
419
- maxBuffer: MAX_BUFFER,
420
- })
393
+ content = io.git(root, ["cat-file", "-p", id])
421
394
  } catch {
422
395
  return undefined
423
396
  }
424
397
  blobMemo.set(id, content)
425
- if (commonDir !== undefined) writeTextCacheAtomic(blobCachePath(commonDir, id), content)
398
+ if (commonDir !== undefined)
399
+ writeTextCacheAtomic(io, paths, pid, blobCachePath(commonDir, id), content)
426
400
  return content
427
401
  }
428
402
 
429
403
  // A raw, synchronous `git show <ref>:<path>` — the sync counterpart to
430
404
  // `committed`, for replay, which can't `yield*` mid-flow. Bypasses the Effect-based `GitOperations`
431
405
  // entirely, the same escape `readSync` already takes around the Effect
432
- // `FileSystem` — both are raw Node calls, not a second git abstraction.
406
+ // `FileSystem` — both go through the sync port, not a second git abstraction.
433
407
  // ANY failure (missing ref, missing path at that ref, git not on PATH)
434
408
  // folds to `undefined`, matching `committed`'s own catch-all-as-absence.
435
409
  //
@@ -439,17 +413,13 @@ const makeWorkspaceOps = (
439
413
  // is never cached here, since it can move mid-process (a landing commit),
440
414
  // and goes straight to `git show` as before.
441
415
  const readCommittedSync = (path: string, ref = "HEAD"): string | undefined => {
442
- const relPath = assertRepoRelative(path)
416
+ const relPath = assertRepoRelative(paths, path)
443
417
  if (isObjectHash(ref)) {
444
418
  const id = treeSync(ref).get(relPath)
445
419
  return id === undefined ? undefined : readBlobSync(id)
446
420
  }
447
421
  try {
448
- return execFileSync("git", ["show", `${ref}:${relPath}`], {
449
- cwd: root,
450
- encoding: "utf8",
451
- maxBuffer: MAX_BUFFER,
452
- })
422
+ return io.git(root, ["show", `${ref}:${relPath}`])
453
423
  } catch {
454
424
  return undefined
455
425
  }
@@ -464,7 +434,7 @@ const makeWorkspaceOps = (
464
434
  const cached = peekTree(ref)
465
435
  if (cached !== undefined) return cached
466
436
  }
467
- const { ok, entries } = fetchTree(root, ref)
437
+ const { ok, entries } = fetchTree(io, root, ref)
468
438
  if (!ok) {
469
439
  if (onFailure === "throw") {
470
440
  throw new Error(`gtd: failed to read the tree at "${ref}" — git ls-tree did not succeed`)
@@ -510,7 +480,7 @@ const makeWorkspaceOps = (
510
480
  }
511
481
 
512
482
  const head = commits[commits.length - 1]!
513
- const replay = runRawLogReplay(root, base, head, commits)
483
+ const replay = runRawLogReplay(io, root, base, head, commits)
514
484
  for (const [hash, tree] of foldRawDiffs(baseTree, replay, rememberTree))
515
485
  result.set(hash, tree)
516
486
  return result
@@ -518,13 +488,7 @@ const makeWorkspaceOps = (
518
488
  catch: toError,
519
489
  })
520
490
 
521
- const gitSync = (args: readonly string[], input?: string): string =>
522
- execFileSync("git", args, {
523
- cwd: root,
524
- encoding: "utf8",
525
- maxBuffer: MAX_BUFFER,
526
- ...(input !== undefined ? { input } : {}),
527
- })
491
+ const gitSync = (args: readonly string[]): string => io.git(root, args)
528
492
 
529
493
  // A path git status reports is re-hashed the way `git add` would store it;
530
494
  // every other path keeps HEAD's blob id, so an untouched file is never
@@ -544,13 +508,13 @@ const makeWorkspaceOps = (
544
508
  for (const record of status.split("\0")) {
545
509
  if (record.length < 4) continue
546
510
  const path = record.slice(3).replace(/\/$/, "")
547
- const stat = lstatOrAbsent(join(root, path))
511
+ const stat = io.lstat(paths.join(root, path))
548
512
  if (stat === undefined) entries.delete(path)
549
- else if (stat.isSymbolicLink()) {
550
- entries.set(path, blobId(readlinkSync(join(root, path), "utf8")))
551
- } else if (stat.isDirectory()) {
513
+ else if (stat.type === "symlink") {
514
+ entries.set(path, blobId(io.readLink(paths.join(root, path))))
515
+ } else if (stat.type === "directory") {
552
516
  // Only a directory holding its own repository is committed, as a gitlink.
553
- if (lstatOrAbsent(join(root, path, ".git")) === undefined) entries.delete(path)
517
+ if (io.lstat(paths.join(root, path, ".git")) === undefined) entries.delete(path)
554
518
  else entries.set(path, gitlinkOf(path))
555
519
  } else files.push(path)
556
520
  }
@@ -567,10 +531,7 @@ const makeWorkspaceOps = (
567
531
  /** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
568
532
  const gitlinkOf = (path: string): string | undefined => {
569
533
  try {
570
- return execFileSync("git", ["rev-parse", "HEAD"], {
571
- cwd: join(root, path),
572
- encoding: "utf8",
573
- }).trim()
534
+ return io.git(paths.join(root, path), ["rev-parse", "HEAD"]).trim()
574
535
  } catch {
575
536
  return undefined
576
537
  }
@@ -579,11 +540,8 @@ const makeWorkspaceOps = (
579
540
  let objectFormat: string | undefined
580
541
  const blobId = (content: string): string => {
581
542
  objectFormat ??= gitSync(["rev-parse", "--show-object-format"]).trim()
582
- const bytes = Buffer.from(content, "utf8")
583
- return createHash(objectFormat === "sha256" ? "sha256" : "sha1")
584
- .update(`blob ${bytes.length}\0`)
585
- .update(bytes)
586
- .digest("hex")
543
+ const bytes = encoder.encode(content)
544
+ return io.hash(objectFormat === "sha256" ? "sha256" : "sha1", `blob ${bytes.length}\0`, bytes)
587
545
  }
588
546
 
589
547
  return {
@@ -594,19 +552,19 @@ const makeWorkspaceOps = (
594
552
  read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
595
553
  write: (path, content) =>
596
554
  Effect.try({
597
- try: () => writeFileSync(join(root, assertRepoRelative(path)), content, "utf8"),
555
+ try: () => io.writeText(repoPath(path), content),
598
556
  catch: toError,
599
557
  }),
600
558
  committed: (path, ref = "HEAD") =>
601
- Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
559
+ Effect.try({ try: () => assertRepoRelative(paths, path), catch: toError }).pipe(
602
560
  Effect.flatMap((relPath) =>
603
561
  git.readFileAtRef(ref, relPath).pipe(Effect.catch(() => Effect.succeed(undefined))),
604
562
  ),
605
563
  ),
606
564
  readCommittedSync,
607
- atPath: (path) => readFileOrAbsent(resolveAny(path)),
565
+ atPath: (path) => io.readText(resolveAny(path)),
608
566
  writeAtPath: (path, content) =>
609
- Effect.try({ try: () => writeFileSync(resolveAny(path), content, "utf8"), catch: toError }),
567
+ Effect.try({ try: () => io.writeText(resolveAny(path), content), catch: toError }),
610
568
  }
611
569
  }
612
570
 
@@ -614,8 +572,9 @@ export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Works
614
572
  static Live = Layer.effect(
615
573
  Workspace,
616
574
  Effect.gen(function* () {
617
- const { root } = yield* Host
575
+ const host = yield* Host
618
576
  const git = yield* GitService
577
+ const paths = yield* Path.Path
619
578
  // Resolved once per process, not lazily inside a sync method: the
620
579
  // common dir is git's own concern to answer, and every cache read/
621
580
  // write below is synchronous. A repo whose common dir can't be
@@ -625,7 +584,7 @@ export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Works
625
584
  const commonDir = yield* git
626
585
  .gitCommonDir()
627
586
  .pipe(Effect.catch(() => Effect.succeed(undefined)))
628
- return makeWorkspaceOps(root, git, commonDir)
587
+ return makeWorkspaceOps(host, git, commonDir, paths, nodeSyncIo)
629
588
  }),
630
589
  )
631
590
  }