@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
@@ -0,0 +1,151 @@
1
+ import { realpathSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { isAbsolute } from "node:path"
4
+ import { PassThrough } from "node:stream"
5
+ import { Effect, Layer } from "effect"
6
+ import { describe, expect, it } from "vitest"
7
+ import { Host, nodeStdio, type HostOps, type NodeProcess, type Stdio } from "../platform/index.js"
8
+ import { InMemRepo, inMemoryHost, makeCapturingCliIo } from "./index.js"
9
+
10
+ type Env = Readonly<Record<string, string | undefined>>
11
+
12
+ const root = realpathSync(process.cwd())
13
+
14
+ const fakeProcess = (stdin: string | Buffer[], argv = ["node"]) => {
15
+ const input = new PassThrough()
16
+ for (const chunk of typeof stdin === "string" ? [stdin] : stdin) input.write(chunk)
17
+ input.end()
18
+ const out = { stdout: "", stderr: "" }
19
+ const sink = (name: keyof typeof out) => ({
20
+ write: (chunk: string | Uint8Array, done?: () => void) => {
21
+ out[name] += Buffer.from(chunk).toString("utf8")
22
+ done?.()
23
+ return true
24
+ },
25
+ })
26
+ const proc = {
27
+ argv,
28
+ execPath: "/usr/bin/node",
29
+ pid: 7,
30
+ getuid: () => 501,
31
+ stdin: input,
32
+ stdout: sink("stdout"),
33
+ stderr: sink("stderr"),
34
+ exitCode: undefined,
35
+ } as unknown as NodeProcess
36
+ return { proc, out }
37
+ }
38
+
39
+ // The core reaches the machine through `Host`: Node's process live, nothing real in memory.
40
+ describe.each([
41
+ [
42
+ "live",
43
+ (env: Env, stdin: string | Buffer[]) =>
44
+ Host.layer({ root, home: root, env }, fakeProcess(stdin).proc),
45
+ ],
46
+ [
47
+ "in memory",
48
+ (env: Env, stdin: string | Buffer[]) =>
49
+ Layer.succeed(
50
+ Host,
51
+ inMemoryHost(
52
+ root,
53
+ root,
54
+ env,
55
+ typeof stdin === "string" ? stdin : Buffer.concat(stdin).toString("utf8"),
56
+ ),
57
+ ),
58
+ ],
59
+ ])("Host (%s)", (_, layer) => {
60
+ const run = <A>(
61
+ f: (host: HostOps) => A | Effect.Effect<A, Error>,
62
+ env: Env = {},
63
+ stdin: string | Buffer[] = "",
64
+ ): Promise<A> =>
65
+ Effect.runPromise(
66
+ Effect.flatMap(Host, (host) => {
67
+ const result = f(host)
68
+ return Effect.isEffect(result) ? result : Effect.succeed(result)
69
+ }).pipe(Effect.provide(layer(env, stdin))),
70
+ )
71
+
72
+ it("puts scratch files under TMPDIR when it is set", async () => {
73
+ expect(await run((host) => host.scratchDir, { TMPDIR: "/fixture-scratch" })).toBe(
74
+ "/fixture-scratch",
75
+ )
76
+ })
77
+
78
+ it("falls back to an absolute scratch dir when TMPDIR is unset or empty", async () => {
79
+ expect(isAbsolute(await run((host) => host.scratchDir))).toBe(true)
80
+ expect(await run((host) => host.scratchDir, { TMPDIR: "" })).toBe(
81
+ await run((host) => host.scratchDir),
82
+ )
83
+ })
84
+
85
+ it("reads stdin to its end as UTF-8, a character split across chunks included", async () => {
86
+ const bytes = Buffer.from("verdict: ✓\n")
87
+ const split = [bytes.subarray(0, 10), bytes.subarray(10)]
88
+ expect(await run((host) => host.stdin, {}, split)).toBe("verdict: ✓\n")
89
+ expect(await run((host) => host.stdin)).toBe("")
90
+ })
91
+
92
+ it("resolves the repository root to itself", async () => {
93
+ expect(await run((host) => host.realPath(root))).toBe(root)
94
+ })
95
+
96
+ it("names gtd off PATH when no script runs it, and has a process id", async () => {
97
+ const [self, pid] = await run((host) => [host.self, host.pid] as const)
98
+ expect(self).toEqual(["gtd"])
99
+ expect(Number.isInteger(pid)).toBe(true)
100
+ })
101
+ })
102
+
103
+ describe("Host (live) alone", () => {
104
+ const host = (proc: NodeProcess, env: Env = {}): Promise<HostOps> =>
105
+ Effect.runPromise(
106
+ Effect.gen(function* () {
107
+ return yield* Host
108
+ }).pipe(Effect.provide(Host.layer({ root, home: root, env }, proc))),
109
+ )
110
+
111
+ it("falls back to node:os's tmpdir()", async () => {
112
+ expect((await host(fakeProcess("").proc)).scratchDir).toBe(tmpdir())
113
+ })
114
+
115
+ it("runs itself again as its script under the running runtime", async () => {
116
+ const { proc } = fakeProcess("", ["node", "/opt/gtd/gtd.js", "lsp"])
117
+ const live = await host(proc)
118
+ expect([live.self, live.pid, live.uid]).toEqual([["/usr/bin/node", "/opt/gtd/gtd.js"], 7, 501])
119
+ })
120
+ })
121
+
122
+ // `runCli` writes through `Stdio` before any layer exists: Node's process live, a capture in memory.
123
+ describe.each([
124
+ [
125
+ "live",
126
+ () => {
127
+ const { proc, out } = fakeProcess("")
128
+ return {
129
+ stdio: nodeStdio(proc),
130
+ seen: () => ({ ...out, exitCode: proc.exitCode }),
131
+ }
132
+ },
133
+ ],
134
+ [
135
+ "in memory",
136
+ () => {
137
+ const { io, result } = makeCapturingCliIo(new InMemRepo())
138
+ return { stdio: io as Stdio, seen: () => result() }
139
+ },
140
+ ],
141
+ ])("Stdio (%s)", (_, make) => {
142
+ it("writes stdout text and bytes, stderr, and exits with the code", () => {
143
+ const { stdio, seen } = make()
144
+ stdio.stdout("artifact")
145
+ stdio.stdout(new TextEncoder().encode(" bytes ✓"))
146
+ stdio.stderr("narration")
147
+ stdio.exit(3)
148
+ const { stdout, stderr, exitCode } = seen()
149
+ expect([stdout, stderr, exitCode]).toEqual(["artifact bytes ✓", "narration", 3])
150
+ })
151
+ })
@@ -2,7 +2,7 @@ import { Effect } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import { ConfigDiscovery, LEGACY_RC_FILES, walkUp } from "../workflow/index.js"
4
4
  import { InMemRepo } from "./index.js"
5
- import { ambientFlagEnv, testLayers } from "./Layers.js"
5
+ import { ambientFlagEnv, inMemPath, testLayers } from "./Layers.js"
6
6
  import { Host } from "../platform/index.js"
7
7
 
8
8
  // The in-memory `ConfigDiscovery` counterpart walks the SAME root→home chain
@@ -40,7 +40,7 @@ describe("the in-memory ConfigDiscovery counterpart", () => {
40
40
  seed(repo, ROOT, LEGACY_RC_FILES[1]!, "{}\n")
41
41
  seed(repo, ABOVE_HOME, LEGACY_RC_FILES[0]!, "never-walked: true\n")
42
42
  const found = (await runDiscovery(repo, (d) => d.legacyRcFiles(ROOT, HOME))).map((f) => f.path)
43
- expect(walkUp(ROOT, HOME)).toEqual([ROOT, MIDDLE, HOME])
43
+ expect(walkUp(inMemPath, ROOT, HOME)).toEqual([ROOT, MIDDLE, HOME])
44
44
  expect(found).toEqual([
45
45
  `${ROOT}/${LEGACY_RC_FILES[0]}`,
46
46
  `${ROOT}/${LEGACY_RC_FILES[1]}`,
@@ -8,12 +8,13 @@
8
8
  // simulates a driver running gtd's emitted scripts against the in-memory
9
9
  // worktree — a wholly separate mechanism from any gtd-internal layer.
10
10
 
11
- import { Effect, Layer } from "effect"
12
- import { isAbsolute, join } from "node:path"
11
+ import { Effect, Layer, Path } from "effect"
13
12
  import {
14
13
  Narrator,
15
14
  GitService,
16
15
  Host,
16
+ type HostOps,
17
+ scratchDirOf,
17
18
  Workspace,
18
19
  type WorkspaceOps,
19
20
  CommandRunner,
@@ -48,6 +49,10 @@ const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(Strin
48
49
  */
49
50
  const isGitDirKey = (key: string): boolean => key.startsWith(".git/")
50
51
 
52
+ // The in-memory tier's `Path`: Effect's own POSIX one, which needs no Node.
53
+ export const inMemPath = Effect.runSync(Effect.provide(Path.Path, Path.layer))
54
+ const paths = inMemPath
55
+
51
56
  /**
52
57
  * `Workspace` for the in-memory tier. `readSync`/`read`/`write`/`committed`
53
58
  * take a repo-relative key ONLY (mirroring the live adapter's
@@ -63,12 +68,12 @@ const isGitDirKey = (key: string): boolean => key.startsWith(".git/")
63
68
  */
64
69
  export const makeInMemoryWorkspaceOps = (repo: InMemRepo, root: string): WorkspaceOps => {
65
70
  const toKey = (path: string): string => {
66
- if (!isAbsolute(path)) return path
71
+ if (!paths.isAbsolute(path)) return path
67
72
  const prefix = `${root}/`
68
73
  return path === root ? "" : path.startsWith(prefix) ? path.slice(prefix.length) : path
69
74
  }
70
75
  const assertRepoRelative = (path: string): string => {
71
- if (isAbsolute(path)) {
76
+ if (paths.isAbsolute(path)) {
72
77
  throw new Error(
73
78
  `Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath" to read outside the repo)`,
74
79
  )
@@ -146,8 +151,8 @@ const makeInMemoryConfigDiscovery = (
146
151
  legacyRcFiles: (levelRoot, levelHome) =>
147
152
  Effect.try({
148
153
  try: () =>
149
- legacyRcDirs(levelRoot, levelHome).flatMap((dir) =>
150
- LEGACY_RC_FILES.map((name) => join(dir, name))
154
+ legacyRcDirs(paths, levelRoot, levelHome).flatMap((dir) =>
155
+ LEGACY_RC_FILES.map((name) => paths.join(dir, name))
151
156
  .filter((filepath) => workspace.atPath(filepath) !== undefined)
152
157
  .map((path) => ({ path, owned: true })),
153
158
  ),
@@ -156,8 +161,8 @@ const makeInMemoryConfigDiscovery = (
156
161
  workflowModule: (levelRoot, levelHome) =>
157
162
  Effect.try({
158
163
  try: () => {
159
- for (const dir of walkUp(levelRoot, levelHome)) {
160
- const filepath = join(dir, WORKFLOW_MODULE)
164
+ for (const dir of walkUp(paths, levelRoot, levelHome)) {
165
+ const filepath = paths.join(dir, WORKFLOW_MODULE)
161
166
  const source = workspace.atPath(filepath)
162
167
  if (source !== undefined) return { filepath, source, read: workspace.atPath }
163
168
  }
@@ -214,6 +219,8 @@ export interface TestWorldOptions {
214
219
  readonly verbose?: boolean
215
220
  /** Real or fake driver and tool commands — each one left out fails when called. */
216
221
  readonly tools?: Partial<ToolCommands["Service"]>
222
+ /** What gtd reads on stdin; empty by default, like a closed pipe. */
223
+ readonly stdin?: string
217
224
  }
218
225
 
219
226
  const missingTool = (name: string) => () =>
@@ -247,6 +254,33 @@ export const ambientFlagEnv = (
247
254
  ),
248
255
  )
249
256
 
257
+ /** Where the in-memory host's scratch files go when `TMPDIR` is unset. */
258
+ export const IN_MEMORY_SCRATCH = "/scratch"
259
+ export const IN_MEMORY_PID = 4242
260
+
261
+ /**
262
+ * The in-memory `Host`: nothing read off the real process or `node:os`.
263
+ * `realPath` is a no-op (there is no real filesystem to resolve symlinks
264
+ * against), so both sides of `assertRunningFromRepoRoot`'s comparison
265
+ * collapse to `root`.
266
+ */
267
+ export const inMemoryHost = (
268
+ root: string,
269
+ home: string,
270
+ env: Readonly<Record<string, string | undefined>>,
271
+ stdin = "",
272
+ ): HostOps => ({
273
+ root,
274
+ home,
275
+ env,
276
+ scratchDir: scratchDirOf(env, IN_MEMORY_SCRATCH),
277
+ realPath: () => Effect.succeed(root),
278
+ pid: IN_MEMORY_PID,
279
+ uid: undefined,
280
+ self: ["gtd"],
281
+ stdin: Effect.succeed(stdin),
282
+ })
283
+
250
284
  /** Every layer `makeProgram` needs — `CommandRequirements`'s return type here IS the guarantee: a new port added there fails this function's typecheck instead of silently under-providing. */
251
285
  export function testLayers(
252
286
  repo: InMemRepo,
@@ -258,20 +292,12 @@ export function testLayers(
258
292
  return Layer.mergeAll(
259
293
  gitTestLayer(repo, root),
260
294
  Layer.succeed(Workspace, makeInMemoryWorkspaceOps(repo, root)),
261
- // `realPath` is a no-op here (there is no real filesystem to resolve
262
- // symlinks against): both sides of `assertRunningFromRepoRoot`'s
263
- // comparison collapse to `root` when given anything, exactly like the
264
- // fake's `root` "resolving" to itself before this port existed.
265
- Host.layer({
266
- root,
267
- home,
268
- env: { ...ambientFlagEnv(), ...opts.env },
269
- realPath: () => Effect.succeed(root),
270
- }),
295
+ Layer.succeed(Host, inMemoryHost(root, home, { ...ambientFlagEnv(), ...opts.env }, opts.stdin)),
271
296
  ConfigService.Live,
272
297
  makeInMemoryConfigDiscovery(repo, root),
273
298
  Layer.succeed(EngineCore, { source: Effect.succeed(IN_MEMORY_ENGINE) }),
274
299
  Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true),
300
+ Path.layer,
275
301
  // Drivers and tools are unit-tested in their own folders; a call reaching
276
302
  // one of these is a test gap, not silence. A test opts into one via `tools`.
277
303
  Layer.succeed(ToolCommands, { ...missingToolCommands, ...opts.tools }),
@@ -0,0 +1,52 @@
1
+ import { Effect, Layer, Path } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { NodeServices } from "../platform/index.js"
4
+ import { InMemRepo, testLayers } from "./index.js"
5
+
6
+ // The core reaches paths through `Path`: Node's live, Effect's own POSIX one in memory.
7
+ describe.each([
8
+ ["live", NodeServices.layer as Layer.Layer<Path.Path>],
9
+ ["in memory", testLayers(new InMemRepo())],
10
+ ])("Path (%s)", (_, layer) => {
11
+ const run = <A>(f: (path: Path.Path) => A | Effect.Effect<A, unknown>): Promise<A> =>
12
+ Effect.runPromise(
13
+ Effect.flatMap(Path.Path, (path) => {
14
+ const result = f(path)
15
+ return Effect.isEffect(result) ? result : Effect.succeed(result)
16
+ }).pipe(Effect.provide(layer)),
17
+ )
18
+
19
+ it("joins, splits and relates paths", async () => {
20
+ expect(
21
+ await run((path) => [
22
+ path.sep,
23
+ path.join("/repo", ".gtd", "../gtd.config.ts"),
24
+ path.dirname("/repo/.gtd/TODO.md"),
25
+ path.dirname("/"),
26
+ path.resolve("/repo", "lib/flow.ts"),
27
+ path.resolve("/repo", "/elsewhere"),
28
+ path.relative("/repo", "/repo/lib/flow.ts"),
29
+ path.relative("/repo", "/home/.gtdrc"),
30
+ path.isAbsolute("/repo"),
31
+ path.isAbsolute("lib/flow.ts"),
32
+ ]),
33
+ ).toEqual([
34
+ "/",
35
+ "/repo/gtd.config.ts",
36
+ "/repo/.gtd",
37
+ "/",
38
+ "/repo/lib/flow.ts",
39
+ "/elsewhere",
40
+ "lib/flow.ts",
41
+ "../home/.gtdrc",
42
+ true,
43
+ false,
44
+ ])
45
+ })
46
+
47
+ it("converts a path to a file URL and back", async () => {
48
+ const url = await run((path) => path.toFileUrl("/repo/a b.ts"))
49
+ expect(url.href).toBe("file:///repo/a%20b.ts")
50
+ expect(await run((path) => path.fromFileUrl(url))).toBe("/repo/a b.ts")
51
+ })
52
+ })
@@ -0,0 +1,153 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { afterEach, describe, expect, it } from "vitest"
6
+ import { nodeSyncIo, type SyncIo } from "../platform/index.js"
7
+ import { InMemRepo, inMemorySyncIo } from "./index.js"
8
+
9
+ interface Tier {
10
+ readonly io: SyncIo
11
+ /** A git repository, empty until `commit`. */
12
+ readonly root: string
13
+ readonly uid: number | undefined
14
+ readonly symlink: (path: string, target: string) => void
15
+ /** Commits `files`, repository-relative, as the only change. */
16
+ readonly commit: (files: Record<string, string>) => void
17
+ }
18
+
19
+ const cleanups: (() => void)[] = []
20
+ afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
21
+
22
+ const liveTier = (): Tier => {
23
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-sync-io-")))
24
+ cleanups.push(() => rmSync(root, { recursive: true, force: true }))
25
+ const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "ignore" })
26
+ git("init", "-q")
27
+ return {
28
+ io: nodeSyncIo,
29
+ root,
30
+ uid: process.getuid?.(),
31
+ symlink: (path, target) => symlinkSync(target, path),
32
+ commit: (files) => {
33
+ for (const [path, content] of Object.entries(files))
34
+ nodeSyncIo.writeText(join(root, path), content)
35
+ git("add", "-A")
36
+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "c")
37
+ },
38
+ }
39
+ }
40
+
41
+ const inMemTier = (): Tier => {
42
+ const repo = new InMemRepo()
43
+ const root = "/repo"
44
+ const io = inMemorySyncIo({ repo, root, uid: 501 })
45
+ io.makeDirectory(root)
46
+ return {
47
+ io,
48
+ root,
49
+ uid: 501,
50
+ symlink: io.symlink,
51
+ commit: (files) => {
52
+ for (const [path, content] of Object.entries(files)) {
53
+ io.writeText(`${root}/${path}`, content)
54
+ repo.writeFile(path, content)
55
+ }
56
+ repo.seedCommit("c")
57
+ },
58
+ }
59
+ }
60
+
61
+ describe.each([
62
+ ["live", liveTier],
63
+ ["in memory", inMemTier],
64
+ ])("SyncIo (%s)", (_, makeTier) => {
65
+ it("reads absence as a value and a directory as a fault", () => {
66
+ const { io, root } = makeTier()
67
+ io.writeText(`${root}/file`, "x")
68
+
69
+ expect(io.readText(`${root}/missing`)).toBeUndefined()
70
+ expect(io.readFile(`${root}/file/below`)).toBeUndefined()
71
+ expect(() => io.readText(root)).toThrow()
72
+ })
73
+
74
+ it("writes text and reads it back as text and bytes", () => {
75
+ const { io, root } = makeTier()
76
+ io.makeDirectory(`${root}/a/b`)
77
+ io.makeDirectory(`${root}/a/b`)
78
+ io.writeText(`${root}/a/b/f`, "\uFEFFhéllo")
79
+
80
+ expect(io.readText(`${root}/a/b/f`)).toBe("\uFEFFhéllo")
81
+ expect(new Uint8Array(io.readFile(`${root}/a/b/f`)!)).toEqual(
82
+ new TextEncoder().encode("\uFEFFhéllo"),
83
+ )
84
+ expect(() => io.writeText(`${root}/none/f`, "x")).toThrow()
85
+ })
86
+
87
+ it("renames a file over another, and over a symlink rather than through it", () => {
88
+ const { io, root, symlink } = makeTier()
89
+ io.writeText(`${root}/from`, "new")
90
+ io.writeText(`${root}/to`, "old")
91
+ io.rename(`${root}/from`, `${root}/to`)
92
+ io.writeText(`${root}/other`, "moved")
93
+ symlink(`${root}/link`, "to")
94
+ io.rename(`${root}/other`, `${root}/link`)
95
+
96
+ expect(io.readText(`${root}/from`)).toBeUndefined()
97
+ expect(io.readText(`${root}/to`)).toBe("new")
98
+ expect(io.lstat(`${root}/link`)?.type).toBe("file")
99
+ expect(io.readText(`${root}/link`)).toBe("moved")
100
+ })
101
+
102
+ it("stats through a symlink, lstats the link itself, and resolves real paths", () => {
103
+ const { io, root, uid, symlink } = makeTier()
104
+ io.makeDirectory(`${root}/dir`)
105
+ io.writeText(`${root}/dir/f`, "x")
106
+ symlink(`${root}/link`, "dir")
107
+ symlink(`${root}/dangling`, "nowhere")
108
+
109
+ expect(io.stat(`${root}/dir/f`)).toEqual({ type: "file", uid })
110
+ expect(io.stat(`${root}/link`)).toEqual({ type: "directory", uid })
111
+ expect(io.lstat(`${root}/link`)).toEqual({ type: "symlink", uid })
112
+ expect(io.lstat(`${root}/missing`)).toBeUndefined()
113
+ expect(io.stat(`${root}/dangling`)).toBeUndefined()
114
+ expect(io.readLink(`${root}/link`)).toBe("dir")
115
+ expect(io.realPath(`${root}/link/f`)).toBe(`${root}/dir/f`)
116
+ expect(io.readText(`${root}/link/f`)).toBe("x")
117
+ expect(() => io.realPath(`${root}/dangling`)).toThrow()
118
+ })
119
+
120
+ it("reads committed content through git", () => {
121
+ const { io, root, commit } = makeTier()
122
+ commit({ "a.txt": "committed\n" })
123
+
124
+ expect(io.git(root, ["show", "HEAD:a.txt"])).toBe("committed\n")
125
+ expect(new Uint8Array(io.gitBytes(root, ["show", "HEAD:a.txt"]))).toEqual(
126
+ new TextEncoder().encode("committed\n"),
127
+ )
128
+ expect(io.git(root, ["rev-parse", "--show-object-format"])).toBe("sha1\n")
129
+ expect(io.git(root, ["rev-parse", "HEAD"])).toMatch(/^[0-9a-f]{40}\n$/)
130
+ expect(() => io.git(root, ["show", "HEAD:missing"])).toThrow()
131
+ expect(() => io.gitBytes(root, ["show", "HEAD:missing"])).toThrow()
132
+ })
133
+
134
+ it("hashes like git and the standard test vectors", () => {
135
+ const { io } = makeTier()
136
+ const million = "a".repeat(1_000_000)
137
+
138
+ expect(io.hash("sha1", "blob 5\0", new TextEncoder().encode("hello"))).toBe(
139
+ "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0",
140
+ )
141
+ expect(io.hash("sha1", "")).toBe("da39a3ee5e6b4b0d3255bfef95601890afd80709")
142
+ expect(io.hash("sha1", million)).toBe("34aa973cd4c4daa4f61eeb2bdbad27316534016f")
143
+ expect(io.hash("sha256", "abc")).toBe(
144
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
145
+ )
146
+ expect(io.hash("sha256", "a".repeat(55), "a".repeat(9))).toBe(
147
+ "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb",
148
+ )
149
+ expect(io.hash("sha256", million)).toBe(
150
+ "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0",
151
+ )
152
+ })
153
+ })
@@ -0,0 +1,159 @@
1
+ import { pureHash, type FileInfo, type SyncIo } from "../platform/index.js"
2
+ import type { InMemRepo } from "./InMemRepo.js"
3
+
4
+ const MAX_LINKS = 40
5
+
6
+ const fault = (code: string, op: string, path: string): Error =>
7
+ Object.assign(new Error(`${code}: ${op} '${path}'`), { code })
8
+
9
+ const parentOf = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/"
10
+
11
+ const joined = (dir: string, name: string): string => (dir === "/" ? `/${name}` : `${dir}/${name}`)
12
+
13
+ const encoder = new TextEncoder()
14
+ const decoder = new TextDecoder("utf-8", { ignoreBOM: true })
15
+
16
+ export interface InMemorySyncIo extends SyncIo {
17
+ readonly symlink: (path: string, target: string) => void
18
+ }
19
+
20
+ /**
21
+ * A POSIX file system in memory, absolute paths only. Its git answers the
22
+ * reads gtd makes from `repo`, rooted at `root`.
23
+ */
24
+ export const inMemorySyncIo = (
25
+ opts: { readonly repo?: InMemRepo; readonly root?: string; readonly uid?: number } = {},
26
+ ): InMemorySyncIo => {
27
+ const files = new Map<string, Uint8Array>()
28
+ const links = new Map<string, string>()
29
+ const dirs = new Set<string>(["/"])
30
+
31
+ /** `path` with every symlink resolved, the last component's only when `followLast`. */
32
+ const resolve = (path: string, followLast: boolean): string => {
33
+ let pending = path.split("/").filter((part) => part.length > 0)
34
+ let at = "/"
35
+ let hops = 0
36
+ while (pending.length > 0) {
37
+ const [name, ...rest] = pending as [string, ...string[]]
38
+ pending = rest
39
+ if (name === ".") continue
40
+ if (name === "..") {
41
+ at = parentOf(at)
42
+ continue
43
+ }
44
+ const next = joined(at, name)
45
+ const target = links.get(next)
46
+ if (target === undefined || (rest.length === 0 && !followLast)) {
47
+ at = next
48
+ continue
49
+ }
50
+ if (++hops > MAX_LINKS) throw fault("ELOOP", "resolve", path)
51
+ if (target.startsWith("/")) at = "/"
52
+ pending = [...target.split("/").filter((part) => part.length > 0), ...rest]
53
+ }
54
+ return at
55
+ }
56
+
57
+ const typeOf = (path: string): FileInfo["type"] | undefined =>
58
+ links.has(path)
59
+ ? "symlink"
60
+ : files.has(path)
61
+ ? "file"
62
+ : dirs.has(path)
63
+ ? "directory"
64
+ : undefined
65
+
66
+ const parentExists = (path: string): boolean =>
67
+ typeOf(resolve(parentOf(path), true)) === "directory"
68
+
69
+ const info = (path: string): FileInfo | undefined => {
70
+ const type = typeOf(path)
71
+ return type === undefined ? undefined : { type, uid: opts.uid }
72
+ }
73
+
74
+ const readFile = (path: string): Uint8Array | undefined => {
75
+ const real = resolve(path, true)
76
+ if (dirs.has(real)) throw fault("EISDIR", "read", path)
77
+ return files.get(real)
78
+ }
79
+
80
+ const writeFile = (path: string, content: Uint8Array): void => {
81
+ const real = resolve(path, true)
82
+ if (!parentExists(real)) throw fault("ENOENT", "open", path)
83
+ if (dirs.has(real)) throw fault("EISDIR", "open", path)
84
+ files.set(real, content)
85
+ }
86
+
87
+ const makeDirectory = (path: string): void => {
88
+ const real = resolve(path, true)
89
+ const type = typeOf(real)
90
+ if (type === "directory") return
91
+ if (type !== undefined) throw fault("EEXIST", "mkdir", path)
92
+ makeDirectory(parentOf(real))
93
+ dirs.add(real)
94
+ }
95
+
96
+ const show = (repo: InMemRepo, spec: string): string => {
97
+ const colon = spec.indexOf(":")
98
+ const content = repo.fileAtRef(spec.slice(0, colon), spec.slice(colon + 1))
99
+ if (content === null) throw new Error(`fatal: invalid object name '${spec}'`)
100
+ return content
101
+ }
102
+
103
+ const revParse = (repo: InMemRepo, arg: string): string => {
104
+ if (arg === "--show-object-format") return "sha1\n"
105
+ const hash = repo.resolveRef(arg)
106
+ if (hash === null) throw new Error(`fatal: ambiguous argument '${arg}'`)
107
+ return `${hash}\n`
108
+ }
109
+
110
+ const git = (cwd: string, args: readonly string[]): string => {
111
+ const { repo, root } = opts
112
+ if (repo === undefined || resolve(cwd, true) !== root) {
113
+ throw new Error(`fatal: not a git repository: ${cwd}`)
114
+ }
115
+ const [command, arg, ...rest] = args
116
+ if (arg === undefined || rest.length > 0 || (command !== "show" && command !== "rev-parse")) {
117
+ throw new Error(`in-memory git: unsupported \`git ${args.join(" ")}\``)
118
+ }
119
+ return command === "show" ? show(repo, arg) : revParse(repo, arg)
120
+ }
121
+
122
+ return {
123
+ readFile,
124
+ readText: (path) => {
125
+ const bytes = readFile(path)
126
+ return bytes === undefined ? undefined : decoder.decode(bytes)
127
+ },
128
+ writeText: (path, content) => writeFile(path, encoder.encode(content)),
129
+ makeDirectory,
130
+ rename: (from, to) => {
131
+ const source = resolve(from, false)
132
+ const target = resolve(to, false)
133
+ const bytes = files.get(source)
134
+ if (bytes === undefined) throw fault("ENOENT", "rename", from)
135
+ if (!parentExists(target)) throw fault("ENOENT", "rename", to)
136
+ links.delete(target)
137
+ files.set(target, bytes)
138
+ files.delete(source)
139
+ },
140
+ stat: (path) => info(resolve(path, true)),
141
+ lstat: (path) => info(resolve(path, false)),
142
+ readLink: (path) => {
143
+ const target = links.get(resolve(path, false))
144
+ if (target === undefined) throw fault("EINVAL", "readlink", path)
145
+ return target
146
+ },
147
+ realPath: (path) => {
148
+ const real = resolve(path, true)
149
+ if (typeOf(real) === undefined) throw fault("ENOENT", "realpath", path)
150
+ return real
151
+ },
152
+ git,
153
+ gitBytes: (cwd, args) => encoder.encode(git(cwd, args)),
154
+ hash: pureHash,
155
+ symlink: (path, target) => {
156
+ links.set(resolve(path, false), target)
157
+ },
158
+ }
159
+ }
@@ -14,6 +14,7 @@ export const makeCapturingCliIo = (
14
14
  repo: InMemRepo,
15
15
  env: Readonly<Record<string, string | undefined>> = {},
16
16
  tools: Partial<ToolCommands["Service"]> = {},
17
+ stdin = "",
17
18
  ): { readonly io: CliIo; readonly result: () => CapturedCliResult } => {
18
19
  let stdout: Buffer = Buffer.alloc(0)
19
20
  let stderr = ""
@@ -32,7 +33,13 @@ export const makeCapturingCliIo = (
32
33
  // errors into — narration lands in the exact same buffer, exactly like a
33
34
  // real invocation's narration and remediation share one fd.
34
35
  layers: (verbose) =>
35
- testLayers(repo, { env, narrate: (line) => (stderr += line), verbose, tools }),
36
+ testLayers(repo, {
37
+ env,
38
+ narrate: (line) => (stderr += line),
39
+ verbose,
40
+ tools,
41
+ stdin,
42
+ }),
36
43
  }
37
44
  return {
38
45
  io,