@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
- package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
- package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
- package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-zGjNeQNL.mjs} +1011 -313
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +3890 -1294
- package/dist/gtd.core.mjs +119 -118
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +5 -8
- package/src/cli/Cli.ts +9 -27
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +11 -30
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +30 -10
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +10 -9
- package/src/gherkin/messages.test.ts +1 -1
- package/src/gherkin/module-config.feature +1 -1
- package/src/gherkin/testkit.feature +2 -2
- package/src/install/Version.ts +14 -13
- package/src/platform/Host.ts +75 -22
- package/src/platform/NodePinFile.ts +44 -0
- package/src/platform/NodeRuntime.ts +19 -0
- package/src/platform/NodeSync.ts +64 -0
- package/src/platform/PinFile.ts +0 -50
- package/src/platform/Sha.ts +121 -0
- package/src/platform/SyncIo.ts +35 -0
- package/src/platform/Workspace.test.ts +8 -3
- package/src/platform/Workspace.ts +82 -123
- package/src/platform/WorktreeState.test.ts +8 -4
- package/src/platform/WorktreeState.ts +21 -19
- package/src/platform/index.ts +18 -5
- package/src/replay/Format.test.ts +16 -13
- package/src/replay/Format.ts +4 -2
- package/src/replay/Replay.test.ts +25 -5
- package/src/replay/Replay.ts +10 -6
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +23 -1
- package/src/replay/index.ts +1 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/testkit/TestRepo.test.ts +1 -1
- package/src/testkit/TestRepo.ts +4 -18
- package/src/twins/Host.test.ts +151 -0
- package/src/twins/Layers.test.ts +2 -2
- package/src/twins/Layers.ts +44 -18
- package/src/twins/Path.test.ts +52 -0
- package/src/twins/SyncIo.test.ts +153 -0
- package/src/twins/SyncIo.ts +159 -0
- package/src/twins/cliIo.ts +8 -1
- package/src/twins/index.ts +5 -0
- package/src/workflow/discovery.test.ts +6 -5
- package/src/workflow/discovery.ts +43 -32
- package/src/workflow/fingerprint.test.ts +23 -3
- package/src/workflow/fingerprint.ts +9 -11
- package/src/workflow/load.test.ts +16 -6
- package/src/workflow/load.ts +107 -69
- package/src/workflow/pin.test.ts +3 -2
- 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
|
+
})
|
package/src/twins/Layers.test.ts
CHANGED
|
@@ -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]}`,
|
package/src/twins/Layers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/twins/cliIo.ts
CHANGED
|
@@ -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, {
|
|
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,
|