@pmelab/gtd 24.3.0 → 25.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 +28 -22
- package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +780 -813
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +666 -161
- package/dist/workflow-kit.mjs +55 -96
- package/dist/workflows/feature.mjs +171 -76
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +353 -310
- package/package.json +1 -2
- package/src/cli/Program.test.ts +3 -8
- package/src/cli/Program.ts +1 -8
- package/src/edge/Edge.test.ts +5 -2
- package/src/edge/Edge.ts +3 -4
- package/src/emit/index.ts +1 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/replay/Replay.ts +2 -1
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +148 -51
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +140 -49
- package/src/testkit/InMemRepo.ts +35 -2
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflow-kit/scenarios.test.ts +116 -0
- package/src/workflow-kit/scenarios.ts +53 -113
- package/src/workflows/feature/feature.ts +2 -5
- package/src/workflows/feature/packages.test.ts +17 -34
- package/src/workflows/feature/packages.ts +13 -52
- package/src/workflows/feature/planning.ts +1 -1
- package/src/workflows/feature/review.test.ts +233 -54
- package/src/workflows/feature/review.ts +178 -41
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/index.ts +0 -2
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/health.test.ts +1 -21
- package/src/workflows/shared/health.ts +4 -39
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -1
- package/src/workflows/shared/steps.ts +18 -11
- package/src/workflows/shared/tail.test.ts +1 -41
- package/src/workflows/shared/tail.ts +17 -41
- package/src/workflows/shared/text.fixture.ts +1 -0
- package/src/workflows/shared/text.test.ts +129 -41
- package/src/workflows/shared/text.ts +111 -75
- package/src/workflows/shared/vars.ts +1 -1
- package/src/workflows/shared/scenarios.test.ts +0 -256
- package/src/workflows/shared/scenarios.ts +0 -35
package/src/testkit/GitTiers.ts
CHANGED
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
writeFileSync,
|
|
6
6
|
existsSync,
|
|
7
7
|
readFileSync,
|
|
8
|
+
readdirSync,
|
|
8
9
|
realpathSync,
|
|
10
|
+
statSync,
|
|
9
11
|
} from "node:fs"
|
|
10
|
-
import { join, dirname, basename } from "node:path"
|
|
12
|
+
import { join, dirname, basename, sep } from "node:path"
|
|
11
13
|
import { tmpdir } from "node:os"
|
|
12
|
-
import { execSync,
|
|
14
|
+
import { execFileSync, execSync, spawnSync } from "node:child_process"
|
|
13
15
|
// Imported directly (not just from *.test.ts) — a leak of this module into
|
|
14
16
|
// the shipped CLI bundle would try to inline `vitest` and fail the build
|
|
15
17
|
// loudly (`deps.alwaysBundle` in `tsdown.config.ts`), a guard alongside the
|
|
@@ -29,8 +31,9 @@ import {
|
|
|
29
31
|
fetchHistoryRefs,
|
|
30
32
|
deleteRef,
|
|
31
33
|
} from "../emit/index.js"
|
|
34
|
+
import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
32
35
|
import { InMemRepo } from "./InMemRepo.js"
|
|
33
|
-
import { gitTestLayer } from "./Layers.js"
|
|
36
|
+
import { gitTestLayer, type ScriptedCommand } from "./Layers.js"
|
|
34
37
|
|
|
35
38
|
export interface GitTierCapabilities {
|
|
36
39
|
/** Assert file BYTES on disk, not just index/HEAD state (`hardResetTo`) — only the Live tier has a real working-tree file to read back. */
|
|
@@ -41,6 +44,8 @@ export interface GitTierCapabilities {
|
|
|
41
44
|
readonly linkedWorktrees: boolean
|
|
42
45
|
/** A cwd→home directory chain for config discovery — both tiers walk it through `Workspace`/`Host` now, so this is `true` for either. */
|
|
43
46
|
readonly directoryChainConfig: boolean
|
|
47
|
+
/** Fetch from another repository — only the Live tier has a remote to reach. */
|
|
48
|
+
readonly remotes: boolean
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export interface GitTierSeed {
|
|
@@ -59,6 +64,11 @@ export interface GitTierObserve {
|
|
|
59
64
|
readonly refExists: (ref: string) => boolean
|
|
60
65
|
readonly readWorktreeFile: (path: string) => string
|
|
61
66
|
readonly existsPath: (path: string) => boolean
|
|
67
|
+
/** Everything git keeps on disk; equal snapshots mean nothing was written. Settles git's racy-index stat refresh first. */
|
|
68
|
+
readonly snapshot: () => Promise<string>
|
|
69
|
+
readonly headSubject: () => string
|
|
70
|
+
/** Paths HEAD's own commit touched, against its parent — not the whole tree. */
|
|
71
|
+
readonly headTouchedPaths: () => ReadonlyArray<string>
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
export interface GitTier {
|
|
@@ -74,6 +84,15 @@ export interface GitTier {
|
|
|
74
84
|
readonly emptyRepo: () => GitTier
|
|
75
85
|
readonly seed: GitTierSeed
|
|
76
86
|
readonly observe: GitTierObserve
|
|
87
|
+
/**
|
|
88
|
+
* Runs a script gtd hands a driver: real `sh` on Live, the in-memory twin on
|
|
89
|
+
* InMemory. `commands` declares what each program a check runs does — Live
|
|
90
|
+
* runs the real thing, so a contract case passes only when the two agree.
|
|
91
|
+
*/
|
|
92
|
+
readonly runScript: (
|
|
93
|
+
script: string,
|
|
94
|
+
commands?: ReadonlyMap<string, ScriptedCommand>,
|
|
95
|
+
) => { readonly ok: boolean; readonly status: number; readonly output: string }
|
|
77
96
|
readonly dispose: () => void
|
|
78
97
|
}
|
|
79
98
|
|
|
@@ -141,6 +160,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
141
160
|
commitHooks: true,
|
|
142
161
|
linkedWorktrees: true,
|
|
143
162
|
directoryChainConfig: true,
|
|
163
|
+
remotes: true,
|
|
144
164
|
},
|
|
145
165
|
provide,
|
|
146
166
|
provideExit,
|
|
@@ -187,11 +207,51 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
187
207
|
},
|
|
188
208
|
readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
|
|
189
209
|
existsPath: (path) => existsSync(join(root, path)),
|
|
210
|
+
snapshot: async () => {
|
|
211
|
+
// A tracked file's mtime in the same second as the index makes the next
|
|
212
|
+
// `git status` rewrite the index — git's own racy-git refresh, not a write
|
|
213
|
+
// of ours. Let the clock pass it before taking the baseline.
|
|
214
|
+
gitExec("status", "--porcelain")
|
|
215
|
+
await new Promise((r) => setTimeout(r, 1100))
|
|
216
|
+
gitExec("status", "--porcelain")
|
|
217
|
+
const gitDir = join(root, ".git")
|
|
218
|
+
const files = readdirSync(gitDir, { recursive: true, withFileTypes: true })
|
|
219
|
+
.filter((entry) => entry.isFile())
|
|
220
|
+
.map((entry) => join(entry.parentPath, entry.name))
|
|
221
|
+
.filter((path) => !path.includes(`${sep}gtd-cache${sep}`))
|
|
222
|
+
.sort()
|
|
223
|
+
.map((path) => `${path}:${statSync(path).size}:${statSync(path).mtimeMs}`)
|
|
224
|
+
return JSON.stringify({ files, status: gitExec("status", "--porcelain", "-uall") })
|
|
225
|
+
},
|
|
226
|
+
headSubject: () => gitExec("log", "-1", "--pretty=%s"),
|
|
227
|
+
headTouchedPaths: () =>
|
|
228
|
+
gitExec("show", "--name-only", "--pretty=format:", "HEAD")
|
|
229
|
+
.split("\n")
|
|
230
|
+
.filter((line) => line.length > 0),
|
|
231
|
+
},
|
|
232
|
+
runScript: (script) => {
|
|
233
|
+
// One stream, as a driver's terminal shows it.
|
|
234
|
+
const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
|
|
235
|
+
cwd: root,
|
|
236
|
+
encoding: "utf8",
|
|
237
|
+
env: { ...process.env, PATH: `${gtdShimDir()}:${process.env["PATH"] ?? ""}` },
|
|
238
|
+
})
|
|
239
|
+
return { ok: result.status === 0, status: result.status ?? 1, output: result.stdout }
|
|
190
240
|
},
|
|
191
241
|
dispose: () => rmSync(root, { recursive: true, force: true }),
|
|
192
242
|
}
|
|
193
243
|
}
|
|
194
244
|
|
|
245
|
+
/** A dir whose `gtd` runs this build's bundle, so a script's bare `gtd` is the code under test. */
|
|
246
|
+
let shimDir: string | undefined
|
|
247
|
+
const gtdShimDir = (): string => {
|
|
248
|
+
if (shimDir !== undefined) return shimDir
|
|
249
|
+
shimDir = mkdtempSync(join(tmpdir(), "gtd-tier-shim-"))
|
|
250
|
+
const bundle = join(import.meta.dirname, "../../dist/gtd.bundle.mjs")
|
|
251
|
+
writeFileSync(join(shimDir, "gtd"), `#!/bin/sh\nexec node "${bundle}" "$@"\n`, { mode: 0o755 })
|
|
252
|
+
return shimDir
|
|
253
|
+
}
|
|
254
|
+
|
|
195
255
|
const IN_MEM_ROOT = "/repo"
|
|
196
256
|
|
|
197
257
|
const makeInMemTier = (initialCommit = true): GitTier => {
|
|
@@ -218,6 +278,7 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
218
278
|
commitHooks: false,
|
|
219
279
|
linkedWorktrees: false,
|
|
220
280
|
directoryChainConfig: true,
|
|
281
|
+
remotes: false,
|
|
221
282
|
},
|
|
222
283
|
provide,
|
|
223
284
|
provideExit,
|
|
@@ -252,6 +313,19 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
252
313
|
return content
|
|
253
314
|
},
|
|
254
315
|
existsPath: (path) => repo.hasPath(path),
|
|
316
|
+
snapshot: async () => repo.snapshot(),
|
|
317
|
+
headSubject: () => repo.lastCommitSubject() ?? "",
|
|
318
|
+
headTouchedPaths: () => {
|
|
319
|
+
const paths = new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])
|
|
320
|
+
return [...paths]
|
|
321
|
+
.filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p))
|
|
322
|
+
.sort()
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
runScript: (script, commands = new Map()) => {
|
|
326
|
+
const { ok, status, output, error } = applyEmittedScript(repo, commands, script)
|
|
327
|
+
if (error?.startsWith("unrecognized script block:") === true) throw new Error(error)
|
|
328
|
+
return { ok, status, output }
|
|
255
329
|
},
|
|
256
330
|
dispose: () => {},
|
|
257
331
|
}
|
|
@@ -336,6 +410,36 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
336
410
|
t.dispose()
|
|
337
411
|
})
|
|
338
412
|
|
|
413
|
+
it("no reader writes to the repository", async () => {
|
|
414
|
+
t.seed.commit("feat: second", { "second.txt": "two" })
|
|
415
|
+
t.seed.writeFile("dirty.txt", "dirty")
|
|
416
|
+
const before = await t.observe.snapshot()
|
|
417
|
+
await runGit(t, (g) =>
|
|
418
|
+
Effect.all([
|
|
419
|
+
g.lastCommitSubject(),
|
|
420
|
+
g.lastCommitMessage(),
|
|
421
|
+
g.hasCommits(),
|
|
422
|
+
g.resolveRef("HEAD"),
|
|
423
|
+
g.readRefOption("refs/gtd/none"),
|
|
424
|
+
g.isAncestor("HEAD~1", "HEAD"),
|
|
425
|
+
g.mergeBase("HEAD~1", "HEAD"),
|
|
426
|
+
g.defaultBranch(),
|
|
427
|
+
g.topLevel(),
|
|
428
|
+
g.gitDir(),
|
|
429
|
+
g.gitCommonDir(),
|
|
430
|
+
g.commitHistory(),
|
|
431
|
+
g.subjectHistory(10, 0),
|
|
432
|
+
g.readFileAtRef("HEAD", "second.txt"),
|
|
433
|
+
g.changedPaths(),
|
|
434
|
+
g.changedPaths("HEAD~1"),
|
|
435
|
+
]),
|
|
436
|
+
)
|
|
437
|
+
expect(await t.observe.snapshot()).toBe(before)
|
|
438
|
+
// The negative control: a snapshot that saw nothing would pass the above vacuously.
|
|
439
|
+
await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")))
|
|
440
|
+
expect(await t.observe.snapshot()).not.toBe(before)
|
|
441
|
+
})
|
|
442
|
+
|
|
339
443
|
describe("resolveRef", () => {
|
|
340
444
|
it("resolves HEAD to a 40-char hash", async () => {
|
|
341
445
|
const hash = await runGit(t, (g) => g.resolveRef("HEAD"))
|
|
@@ -998,35 +1102,20 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
998
1102
|
}
|
|
999
1103
|
|
|
1000
1104
|
/**
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
* `&&`, a misquoted path) fails here exactly as it would in production.
|
|
1105
|
+
* The SAME writer postconditions `runGitServiceContract` asserts against
|
|
1106
|
+
* `GitOperations`, asserted here against `GitScript.ts`'s builders as a driver
|
|
1107
|
+
* runs them: real `sh` on Live, the in-memory twin on InMemory.
|
|
1005
1108
|
*/
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1109
|
+
const run = (t: GitTier, script: string): void => {
|
|
1110
|
+
const result = t.runScript(script)
|
|
1111
|
+
if (!result.ok) throw new Error(`script failed on ${t.name}: ${result.output}`)
|
|
1008
1112
|
}
|
|
1009
1113
|
|
|
1010
|
-
|
|
1011
|
-
const headTouchedPaths = (t: GitTier): ReadonlyArray<string> =>
|
|
1012
|
-
gitExecIn(t.root, "show", "--name-only", "--pretty=format:", "HEAD")
|
|
1013
|
-
.split("\n")
|
|
1014
|
-
.filter((line) => line.length > 0)
|
|
1015
|
-
|
|
1016
|
-
const headSubject = (t: GitTier): string => gitExecIn(t.root, "log", "-1", "--pretty=%s")
|
|
1017
|
-
|
|
1018
|
-
/**
|
|
1019
|
-
* The SAME 9 writer postconditions `runGitServiceContract` asserts against
|
|
1020
|
-
* `GitOperations`, asserted here against `GitScript.ts`'s bash builders
|
|
1021
|
-
* instead — driven through real `bash` against a real repo (`makeLiveTier`),
|
|
1022
|
-
* never the in-memory fake, since a fake root (`/repo`) has no shell to run
|
|
1023
|
-
* against.
|
|
1024
|
-
*/
|
|
1025
|
-
export const runGitScriptContract = (): void => {
|
|
1114
|
+
export const runGitScriptContract = (makeTier: () => GitTier): void => {
|
|
1026
1115
|
let t: GitTier
|
|
1027
1116
|
|
|
1028
1117
|
beforeEach(() => {
|
|
1029
|
-
t =
|
|
1118
|
+
t = makeTier()
|
|
1030
1119
|
})
|
|
1031
1120
|
|
|
1032
1121
|
afterEach(() => {
|
|
@@ -1037,18 +1126,18 @@ export const runGitScriptContract = (): void => {
|
|
|
1037
1126
|
it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
|
|
1038
1127
|
t.seed.writeFile("new.ts", "export const x = 1")
|
|
1039
1128
|
const headBefore = t.observe.resolveRef("HEAD")
|
|
1040
|
-
|
|
1129
|
+
run(t, commitAll("gtd: building"))
|
|
1041
1130
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
1042
|
-
expect(headSubject(
|
|
1043
|
-
expect(headTouchedPaths(
|
|
1131
|
+
expect(t.observe.headSubject()).toBe("gtd: building")
|
|
1132
|
+
expect(t.observe.headTouchedPaths()).toEqual(["new.ts"])
|
|
1044
1133
|
expect(t.observe.statusPorcelain().trim()).toBe("")
|
|
1045
1134
|
})
|
|
1046
1135
|
|
|
1047
1136
|
it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
|
|
1048
1137
|
const headBefore = t.observe.resolveRef("HEAD")
|
|
1049
|
-
|
|
1138
|
+
run(t, commitAll("gtd: grilled"))
|
|
1050
1139
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
1051
|
-
expect(headSubject(
|
|
1140
|
+
expect(t.observe.headSubject()).toBe("gtd: grilled")
|
|
1052
1141
|
})
|
|
1053
1142
|
})
|
|
1054
1143
|
|
|
@@ -1057,16 +1146,16 @@ export const runGitScriptContract = (): void => {
|
|
|
1057
1146
|
t.seed.writeFile("a.txt", "a")
|
|
1058
1147
|
t.seed.stageAll()
|
|
1059
1148
|
t.seed.writeFile("b.txt", "b")
|
|
1060
|
-
|
|
1061
|
-
expect(headSubject(
|
|
1062
|
-
expect(headTouchedPaths(
|
|
1149
|
+
run(t, commitAsIs("feat: squash"))
|
|
1150
|
+
expect(t.observe.headSubject()).toBe("feat: squash")
|
|
1151
|
+
expect(t.observe.headTouchedPaths()).toEqual(["a.txt"])
|
|
1063
1152
|
})
|
|
1064
1153
|
|
|
1065
1154
|
it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
|
|
1066
1155
|
const headBefore = t.observe.resolveRef("HEAD")
|
|
1067
|
-
|
|
1156
|
+
run(t, commitAsIs("gtd: empty squash"))
|
|
1068
1157
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
1069
|
-
expect(headSubject(
|
|
1158
|
+
expect(t.observe.headSubject()).toBe("gtd: empty squash")
|
|
1070
1159
|
})
|
|
1071
1160
|
})
|
|
1072
1161
|
|
|
@@ -1074,7 +1163,7 @@ export const runGitScriptContract = (): void => {
|
|
|
1074
1163
|
it("moves HEAD back but keeps worktree changes from the second commit", () => {
|
|
1075
1164
|
const firstHash = t.observe.resolveRef("HEAD")
|
|
1076
1165
|
t.seed.commit("feat: second", { "second.txt": "second content" })
|
|
1077
|
-
|
|
1166
|
+
run(t, softResetTo(firstHash))
|
|
1078
1167
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
1079
1168
|
expect(t.observe.statusPorcelain()).toContain("second.txt")
|
|
1080
1169
|
})
|
|
@@ -1084,7 +1173,7 @@ export const runGitScriptContract = (): void => {
|
|
|
1084
1173
|
it("moves HEAD, index, and working tree content back to the target ref", () => {
|
|
1085
1174
|
const firstHash = t.observe.resolveRef("HEAD")
|
|
1086
1175
|
t.seed.commit("feat: second", { "readme.txt": "modified content" })
|
|
1087
|
-
|
|
1176
|
+
run(t, hardResetTo(firstHash))
|
|
1088
1177
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
1089
1178
|
expect(t.observe.statusPorcelain()).toBe("")
|
|
1090
1179
|
expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello")
|
|
@@ -1095,7 +1184,7 @@ export const runGitScriptContract = (): void => {
|
|
|
1095
1184
|
it("moves HEAD and index, leaving the working tree untouched", () => {
|
|
1096
1185
|
const firstHash = t.observe.resolveRef("HEAD")
|
|
1097
1186
|
t.seed.commit("feat: second", { "second.txt": "second content" })
|
|
1098
|
-
|
|
1187
|
+
run(t, mixedResetTo(firstHash))
|
|
1099
1188
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
1100
1189
|
expect(t.observe.statusPorcelain()).toContain("second.txt")
|
|
1101
1190
|
})
|
|
@@ -1105,7 +1194,7 @@ export const runGitScriptContract = (): void => {
|
|
|
1105
1194
|
it("drops both tracked modifications and untracked files", () => {
|
|
1106
1195
|
t.seed.writeFile("readme.txt", "modified")
|
|
1107
1196
|
t.seed.writeFile("untracked.txt", "new")
|
|
1108
|
-
|
|
1197
|
+
run(t, discardPending())
|
|
1109
1198
|
expect(t.observe.statusPorcelain()).toBe("")
|
|
1110
1199
|
})
|
|
1111
1200
|
})
|
|
@@ -1113,33 +1202,34 @@ export const runGitScriptContract = (): void => {
|
|
|
1113
1202
|
describe("updateRef / deleteRef", () => {
|
|
1114
1203
|
it("updateRef points a ref at a commit", () => {
|
|
1115
1204
|
const head = t.observe.resolveRef("HEAD")
|
|
1116
|
-
|
|
1205
|
+
run(t, updateRef("refs/gtd/marker", head))
|
|
1117
1206
|
expect(t.observe.refExists("refs/gtd/marker")).toBe(true)
|
|
1118
1207
|
expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head)
|
|
1119
1208
|
})
|
|
1120
1209
|
|
|
1121
1210
|
it("updateRef with HEAD points the ref at the commit just made", () => {
|
|
1122
|
-
|
|
1123
|
-
|
|
1211
|
+
run(t, commitAll("feat: just made"))
|
|
1212
|
+
run(t, updateRef("refs/gtd/history/x", "HEAD"))
|
|
1124
1213
|
expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"))
|
|
1125
1214
|
})
|
|
1126
1215
|
|
|
1127
1216
|
it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
|
|
1128
1217
|
const head = t.observe.resolveRef("HEAD")
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1218
|
+
run(t, updateRef("refs/gtd/marker", head))
|
|
1219
|
+
run(t, deleteRef("refs/gtd/marker"))
|
|
1220
|
+
run(t, deleteRef("refs/gtd/marker"))
|
|
1132
1221
|
expect(t.observe.refExists("refs/gtd/marker")).toBe(false)
|
|
1133
1222
|
})
|
|
1134
1223
|
|
|
1135
1224
|
it("deleteRef tolerates a ref that was never created", () => {
|
|
1136
|
-
expect(() =>
|
|
1225
|
+
expect(() => run(t, deleteRef("refs/gtd/never-created"))).not.toThrow()
|
|
1137
1226
|
expect(t.observe.refExists("refs/gtd/never-created")).toBe(false)
|
|
1138
1227
|
})
|
|
1139
1228
|
})
|
|
1140
1229
|
|
|
1141
1230
|
describe("fetchHistoryRefs", () => {
|
|
1142
|
-
it("pulls every refs/gtd/history/* from a bare remote and prints the count", () => {
|
|
1231
|
+
it("pulls every refs/gtd/history/* from a bare remote and prints the count", (ctx) => {
|
|
1232
|
+
if (!t.capabilities.remotes) ctx.skip()
|
|
1143
1233
|
const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
|
|
1144
1234
|
try {
|
|
1145
1235
|
gitExecIn(bare, "init", "-q", "--bare")
|
|
@@ -1161,7 +1251,8 @@ export const runGitScriptContract = (): void => {
|
|
|
1161
1251
|
}
|
|
1162
1252
|
})
|
|
1163
1253
|
|
|
1164
|
-
it("fails loudly when a local history ref conflicts with the remote's", () => {
|
|
1254
|
+
it("fails loudly when a local history ref conflicts with the remote's", (ctx) => {
|
|
1255
|
+
if (!t.capabilities.remotes) ctx.skip()
|
|
1165
1256
|
const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
|
|
1166
1257
|
try {
|
|
1167
1258
|
gitExecIn(bare, "init", "-q", "--bare")
|
package/src/testkit/InMemRepo.ts
CHANGED
|
@@ -115,10 +115,11 @@ export class InMemRepo {
|
|
|
115
115
|
return this.commits.has(ref) ? ref : null
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+))?$/.exec(ref)
|
|
118
|
+
const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref)
|
|
119
119
|
if (tildeMatch) {
|
|
120
120
|
const base = tildeMatch[1]!
|
|
121
|
-
const steps =
|
|
121
|
+
const steps =
|
|
122
|
+
tildeMatch[3] !== undefined ? parseInt(tildeMatch[3], 10) : tildeMatch[2] === "^" ? 1 : 0
|
|
122
123
|
let cur: string | null = base === "HEAD" ? this.head : this.commits.has(base) ? base : null
|
|
123
124
|
for (let i = 0; i < steps; i++) {
|
|
124
125
|
if (cur === null) return null
|
|
@@ -297,6 +298,38 @@ export class InMemRepo {
|
|
|
297
298
|
.sort()
|
|
298
299
|
}
|
|
299
300
|
|
|
301
|
+
/** Everything git would keep on disk, serialized — two snapshots compare equal exactly when nothing was written. */
|
|
302
|
+
snapshot(): string {
|
|
303
|
+
const sorted = (m: Map<string, string>) =>
|
|
304
|
+
[...m.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
305
|
+
return JSON.stringify({
|
|
306
|
+
head: this.head,
|
|
307
|
+
branch: this.currentBranch,
|
|
308
|
+
commits: [...this.commits.keys()].sort(),
|
|
309
|
+
branches: sorted(this.branches),
|
|
310
|
+
refs: sorted(this.refs),
|
|
311
|
+
index: sorted(this.index),
|
|
312
|
+
worktree: sorted(this.worktree),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Every ref name under `prefix` (`refs/…`), sorted — `git for-each-ref <prefix>`. */
|
|
317
|
+
refNamesUnder(prefix: string): ReadonlyArray<string> {
|
|
318
|
+
return [...this.refs.keys()].filter((ref) => ref.startsWith(prefix)).sort()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Whether `path` is in the index — what `git diff <commit> -- <path>` compares against the worktree. */
|
|
322
|
+
isTracked(path: string): boolean {
|
|
323
|
+
return this.index.has(path)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** `git add <path>` — the index takes `path` as the worktree has it, or drops it. */
|
|
327
|
+
stage(path: string): void {
|
|
328
|
+
const content = this.worktree.get(path)
|
|
329
|
+
if (content === undefined) this.index.delete(path)
|
|
330
|
+
else this.index.set(path, content)
|
|
331
|
+
}
|
|
332
|
+
|
|
300
333
|
/** `git add -A` — the index becomes exactly the current worktree. */
|
|
301
334
|
stageAll(): void {
|
|
302
335
|
this.index = new Map(this.worktree)
|
package/src/testkit/Layers.ts
CHANGED
|
@@ -158,7 +158,7 @@ const makeInMemoryConfigDiscovery = (
|
|
|
158
158
|
for (const dir of walkUp(levelRoot, levelHome)) {
|
|
159
159
|
const filepath = join(dir, WORKFLOW_MODULE)
|
|
160
160
|
const source = workspace.atPath(filepath)
|
|
161
|
-
if (source !== undefined) return { filepath, source }
|
|
161
|
+
if (source !== undefined) return { filepath, source, read: workspace.atPath }
|
|
162
162
|
}
|
|
163
163
|
return undefined
|
|
164
164
|
},
|
|
@@ -170,7 +170,15 @@ const makeInMemoryConfigDiscovery = (
|
|
|
170
170
|
/** One scripted `bash` command's canned behavior, keyed by the RENDERED command string a scenario's `Given` step declares. */
|
|
171
171
|
export type ScriptedCommand =
|
|
172
172
|
| { readonly kind: "exit"; readonly status: number; readonly output: string }
|
|
173
|
-
| {
|
|
173
|
+
| {
|
|
174
|
+
readonly kind: "rewrite"
|
|
175
|
+
readonly file: string
|
|
176
|
+
readonly content: string
|
|
177
|
+
/** Adds `content` to the file's end instead of replacing it. */
|
|
178
|
+
readonly append?: boolean
|
|
179
|
+
}
|
|
180
|
+
/** Keyed by the binary's name: it is not on `$PATH`. */
|
|
181
|
+
| { readonly kind: "missing" }
|
|
174
182
|
|
|
175
183
|
export interface TestWorldOptions {
|
|
176
184
|
readonly env?: Readonly<Record<string, string | undefined>>
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { runCli } from "../cli/index.js"
|
|
|
4
4
|
import { EXIT_OK, type BeatDocument } from "../wire/index.js"
|
|
5
5
|
import { makeCapturingCliIo, type CapturedCliResult } from "./cliIo.js"
|
|
6
6
|
import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
7
|
+
import { applyFlowScript } from "./FlowScripts.js"
|
|
7
8
|
import { InMemRepo } from "./InMemRepo.js"
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -247,7 +248,9 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
247
248
|
commit: (message) => repo.commitAllWithPrefix(message),
|
|
248
249
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
249
250
|
runScript: (script) => {
|
|
250
|
-
|
|
251
|
+
const flow = applyFlowScript(repo, new Map(), script)
|
|
252
|
+
if (flow === undefined) for (const line of script.split("\n")) runLine(repo, line.trim())
|
|
253
|
+
else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
|
|
251
254
|
},
|
|
252
255
|
gtd,
|
|
253
256
|
start: (workflow, ...args) => begin(["--workflow", workflow, ...args]),
|
package/src/testkit/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { InMemRepo } from "./InMemRepo.js"
|
|
|
3
3
|
export { makeInMemoryWorkspaceOps, testLayers, type ScriptedCommand } from "./Layers.js"
|
|
4
4
|
export { makeCapturingCliIo } from "./cliIo.js"
|
|
5
5
|
export { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
6
|
-
export { gitTiers, runGitServiceContract, runGitScriptContract } from "./GitTiers.js"
|
|
6
|
+
export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } from "./GitTiers.js"
|
|
7
7
|
export { strictGitOperations } from "./StrictGitOperations.js"
|
|
8
8
|
export { fakeGitOperations } from "./FakeGitOperations.js"
|
|
9
9
|
export type { TestkitWorld } from "./steps.js"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { execSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { NodeContext } from "@effect/platform-node"
|
|
6
|
+
import { Effect, Layer } from "effect"
|
|
7
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
8
|
+
import { GitService, Host, Narrator, Workspace } from "../platform/index.js"
|
|
9
|
+
import { InMemRepo, testLayers } from "../testkit/index.js"
|
|
10
|
+
import { ConfigDiscovery, ConfigService } from "./index.js"
|
|
11
|
+
|
|
12
|
+
// A config and the local modules it imports load the same from disk, where
|
|
13
|
+
// jiti resolves them, and from an in-memory discovery, where the loader
|
|
14
|
+
// resolves them itself — the same workflows, the same fingerprint, the same
|
|
15
|
+
// failure for a module that is not there.
|
|
16
|
+
type Files = Readonly<Record<string, string>>
|
|
17
|
+
|
|
18
|
+
interface Tier {
|
|
19
|
+
readonly load: (files: Files) => Promise<{ names: readonly string[]; fingerprint: object }>
|
|
20
|
+
readonly dispose: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const config = (imports: string): string =>
|
|
24
|
+
[
|
|
25
|
+
'import { agent, human } from "@pmelab/gtd/flows"',
|
|
26
|
+
imports,
|
|
27
|
+
"export default async () => {",
|
|
28
|
+
' await human("idle", { message: "go" })',
|
|
29
|
+
' await agent("working", brief)',
|
|
30
|
+
"}",
|
|
31
|
+
"export const named = async () => {",
|
|
32
|
+
' await agent("working", brief)',
|
|
33
|
+
"}",
|
|
34
|
+
"",
|
|
35
|
+
].join("\n")
|
|
36
|
+
|
|
37
|
+
const loadWith = (
|
|
38
|
+
layer: Layer.Layer<ConfigService | Narrator | Workspace | Host | ConfigDiscovery>,
|
|
39
|
+
) =>
|
|
40
|
+
Effect.runPromise(
|
|
41
|
+
Effect.flatMap(ConfigService, (service) => service.load).pipe(
|
|
42
|
+
Effect.map((loaded) => ({ names: loaded.workflowNames, fingerprint: loaded.fingerprint })),
|
|
43
|
+
Effect.provide(layer),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const live = (): Tier => {
|
|
48
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-discovery-")))
|
|
49
|
+
execSync("git init -q && git commit -q --allow-empty -m init", {
|
|
50
|
+
cwd: root,
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
GIT_AUTHOR_NAME: "t",
|
|
54
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
55
|
+
GIT_COMMITTER_NAME: "t",
|
|
56
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
return {
|
|
60
|
+
load: (files) => {
|
|
61
|
+
for (const [path, content] of Object.entries(files)) {
|
|
62
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
63
|
+
writeFileSync(join(root, path), content)
|
|
64
|
+
}
|
|
65
|
+
const host = Host.layer({ root, home: root, env: {} })
|
|
66
|
+
const git = GitService.Live.pipe(Layer.provide(Layer.merge(host, NodeContext.layer)))
|
|
67
|
+
const workspace = Workspace.Live.pipe(Layer.provide(Layer.merge(host, git)))
|
|
68
|
+
return loadWith(
|
|
69
|
+
Layer.mergeAll(
|
|
70
|
+
ConfigService.Live,
|
|
71
|
+
ConfigDiscovery.Live,
|
|
72
|
+
host,
|
|
73
|
+
workspace,
|
|
74
|
+
Narrator.layer(() => {}, false),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
dispose: () => rmSync(root, { recursive: true, force: true }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const inMemory = (): Tier => ({
|
|
83
|
+
load: (files) => {
|
|
84
|
+
const repo = new InMemRepo()
|
|
85
|
+
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
86
|
+
repo.commitAllWithPrefix("chore: init")
|
|
87
|
+
return loadWith(testLayers(repo))
|
|
88
|
+
},
|
|
89
|
+
dispose: () => {},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
for (const make of [live, inMemory]) {
|
|
93
|
+
describe(`a config's local modules [${make.name}]`, () => {
|
|
94
|
+
let t: Tier
|
|
95
|
+
afterEach(() => t.dispose())
|
|
96
|
+
|
|
97
|
+
it("loads an import by its .js spelling, and one it imports in turn", async () => {
|
|
98
|
+
t = make()
|
|
99
|
+
const loaded = await t.load({
|
|
100
|
+
"gtd.config.ts": config('import { brief } from "./lib/brief.js"'),
|
|
101
|
+
"lib/brief.ts": 'import { word } from "./word"\nexport const brief = `do ${word}`\n',
|
|
102
|
+
"lib/word.ts": 'export const word = "it"\n',
|
|
103
|
+
})
|
|
104
|
+
expect(loaded.names).toEqual(["named"])
|
|
105
|
+
expect(Object.keys(loaded.fingerprint).sort()).toEqual([
|
|
106
|
+
"gtd.config.ts",
|
|
107
|
+
"lib/brief.ts",
|
|
108
|
+
"lib/word.ts",
|
|
109
|
+
])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("hashes each file's content the same way", async () => {
|
|
113
|
+
t = make()
|
|
114
|
+
const loaded = await t.load({
|
|
115
|
+
"gtd.config.ts": config('import { brief } from "./brief.js"'),
|
|
116
|
+
"brief.ts": 'export const brief = "do the work"\n',
|
|
117
|
+
})
|
|
118
|
+
expect(loaded.fingerprint).toEqual({
|
|
119
|
+
"brief.ts": "c59960854e8032b7",
|
|
120
|
+
"gtd.config.ts": expect.stringMatching(/^[0-9a-f]{16}$/),
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("leaves a specifier in a string alone, and loads a skipped require never", async () => {
|
|
125
|
+
t = make()
|
|
126
|
+
const loaded = await t.load({
|
|
127
|
+
"gtd.config.ts": config(
|
|
128
|
+
[
|
|
129
|
+
"const brief = \"look from './word.js'\"",
|
|
130
|
+
'if (brief === "") require("./optional.js")',
|
|
131
|
+
].join("\n"),
|
|
132
|
+
),
|
|
133
|
+
"word.ts": "export const word = 1\n",
|
|
134
|
+
"optional.ts": 'throw new Error("optional ran")\n',
|
|
135
|
+
})
|
|
136
|
+
expect(loaded.names).toEqual(["named"])
|
|
137
|
+
expect(Object.keys(loaded.fingerprint)).toEqual(["gtd.config.ts"])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("fails on an import that is not there, naming it", async () => {
|
|
141
|
+
t = make()
|
|
142
|
+
await expect(
|
|
143
|
+
t.load({ "gtd.config.ts": config('import { brief } from "./missing.js"') }),
|
|
144
|
+
).rejects.toThrow(/Cannot find module '\.\/missing\.js'/)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
}
|
|
@@ -6,6 +6,11 @@ import { Context, Effect, Layer } from "effect"
|
|
|
6
6
|
export interface WorkflowModule {
|
|
7
7
|
readonly filepath: string
|
|
8
8
|
readonly source: string
|
|
9
|
+
/**
|
|
10
|
+
* The modules the config imports, by absolute path, for a discovery with no
|
|
11
|
+
* disk behind it. Absent, they are read from disk like any module import.
|
|
12
|
+
*/
|
|
13
|
+
readonly read?: (path: string) => string | undefined
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
/** A leftover rc file, and whether the user running gtd owns it — and so can delete it. */
|