@pmelab/gtd 26.0.0 → 27.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 +21 -3
- package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
- package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
- package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +219 -34
- package/dist/testkit.mjs +447 -72
- package/dist/wire.mjs +2 -2
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/package.json +2 -2
- package/src/cli/Cli.test.ts +56 -4
- package/src/cli/Cli.ts +48 -6
- package/src/cli/History.test.ts +2 -2
- package/src/cli/Output.ts +3 -1
- package/src/cli/Program.test.ts +11 -12
- package/src/cli/Program.ts +28 -7
- package/src/cli/Strays.test.ts +88 -0
- package/src/cli/Strays.ts +56 -0
- package/src/edge/Edge.test.ts +113 -16
- package/src/edge/Edge.ts +22 -6
- package/src/emit/GitScript.test.ts +65 -2
- package/src/emit/GitScript.ts +34 -1
- package/src/emit/index.ts +1 -0
- package/src/flags/index.ts +0 -1
- package/src/flows/runtime.test.ts +33 -0
- package/src/flows/runtime.ts +31 -0
- package/src/platform/Git.test.ts +96 -1
- package/src/platform/Git.ts +24 -0
- package/src/platform/RetainedHistory.test.ts +9 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/Workspace.test.ts +1 -1
- package/src/platform/index.ts +1 -0
- package/src/replay/RefName.test.ts +40 -0
- package/src/replay/RefName.ts +19 -0
- package/src/replay/Replay.test.ts +193 -2
- package/src/replay/Replay.ts +51 -6
- package/src/replay/Trailers.test.ts +26 -0
- package/src/replay/Trailers.ts +8 -0
- package/src/replay/index.ts +1 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +5 -5
- package/src/step/planStep.test.ts +53 -0
- package/src/step/planStep.ts +7 -1
- package/src/step/strayPaths.test.ts +17 -0
- package/src/step/strayPaths.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
- package/src/testkit/EmittedScriptRecognizer.ts +26 -4
- package/src/testkit/EmittedScripts.test.ts +182 -0
- package/src/testkit/FakeGitOperations.ts +10 -2
- package/src/testkit/GitTiers.ts +170 -25
- package/src/testkit/InMemRepo.ts +64 -12
- package/src/testkit/Layers.test.ts +34 -1
- package/src/testkit/Layers.ts +21 -1
- package/src/testkit/TestRepo.ts +3 -3
- package/src/wire/ExitCodes.test.ts +11 -3
- package/src/wire/ExitCodes.ts +3 -0
- package/src/wire/Glob.test.ts +35 -0
- package/src/wire/Glob.ts +1 -1
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
package/src/testkit/GitTiers.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
updateRef,
|
|
31
31
|
fetchHistoryRefs,
|
|
32
32
|
deleteRef,
|
|
33
|
+
shellQuote,
|
|
33
34
|
} from "../emit/index.js"
|
|
34
35
|
import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
35
36
|
import { InMemRepo } from "./InMemRepo.js"
|
|
@@ -38,8 +39,6 @@ import { gitTestLayer, type ScriptedCommand } from "./Layers.js"
|
|
|
38
39
|
export interface GitTierCapabilities {
|
|
39
40
|
/** Assert file BYTES on disk, not just index/HEAD state (`hardResetTo`) — only the Live tier has a real working-tree file to read back. */
|
|
40
41
|
readonly onDiskContent: boolean
|
|
41
|
-
/** Install a real `.git/hooks/pre-commit` and prove the `--no-verify` retry — only the Live tier has a hooks directory. */
|
|
42
|
-
readonly commitHooks: boolean
|
|
43
42
|
/** `git worktree add` — sibling linked worktrees sharing one `.git`; the fake models a single worktree only. */
|
|
44
43
|
readonly linkedWorktrees: boolean
|
|
45
44
|
/** A cwd→home directory chain for config discovery — both tiers walk it through `Workspace`/`Host` now, so this is `true` for either. */
|
|
@@ -48,6 +47,26 @@ export interface GitTierCapabilities {
|
|
|
48
47
|
readonly remotes: boolean
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
// Quoted heredoc: `$`, `$(…)` and backticks in `output` reach stderr verbatim.
|
|
51
|
+
const writeRejectingHook = (hooksDir: string, output: string): void => {
|
|
52
|
+
mkdirSync(hooksDir, { recursive: true })
|
|
53
|
+
writeFileSync(
|
|
54
|
+
join(hooksDir, "pre-commit"),
|
|
55
|
+
`#!/bin/sh\ncat >&2 <<'GTD_HOOK_EOF'\n${output}\nGTD_HOOK_EOF\nexit 1\n`,
|
|
56
|
+
{ mode: 0o755 },
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// `printf '%s'` keeps `content` byte-exact.
|
|
61
|
+
const writeStagingHook = (hooksDir: string, path: string, content: string): void => {
|
|
62
|
+
mkdirSync(hooksDir, { recursive: true })
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(hooksDir, "pre-commit"),
|
|
65
|
+
`#!/bin/sh\nprintf '%s' ${shellQuote(content)} > ${shellQuote(path)} && git add ${shellQuote(path)}\nexit 0\n`,
|
|
66
|
+
{ mode: 0o755 },
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export interface GitTierSeed {
|
|
52
71
|
readonly commit: (message: string, files?: Record<string, string>) => void
|
|
53
72
|
readonly writeFile: (path: string, content: string) => void
|
|
@@ -56,6 +75,12 @@ export interface GitTierSeed {
|
|
|
56
75
|
readonly stageAll: () => void
|
|
57
76
|
readonly updateRef: (ref: string, hash: string) => void
|
|
58
77
|
readonly mixedReset: (ref: string) => void
|
|
78
|
+
/** Every hooked commit fails printing `output`; `--no-verify` skips it. */
|
|
79
|
+
readonly rejectingHook: (output: string) => void
|
|
80
|
+
/** Every hooked commit first writes `content` to `path` and stages it; `--no-verify` and `commit-tree` skip it. */
|
|
81
|
+
readonly stagingHook: (path: string, content: string) => void
|
|
82
|
+
/** Every commit `git commit` signs, or `git commit-tree -S` writes, fails; plain `git commit-tree` is unaffected. */
|
|
83
|
+
readonly failingSigner: () => void
|
|
59
84
|
}
|
|
60
85
|
|
|
61
86
|
export interface GitTierObserve {
|
|
@@ -157,7 +182,6 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
157
182
|
root,
|
|
158
183
|
capabilities: {
|
|
159
184
|
onDiskContent: true,
|
|
160
|
-
commitHooks: true,
|
|
161
185
|
linkedWorktrees: true,
|
|
162
186
|
directoryChainConfig: true,
|
|
163
187
|
remotes: true,
|
|
@@ -193,6 +217,22 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
193
217
|
stageAll: () => gitExec("add", "-A"),
|
|
194
218
|
updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
|
|
195
219
|
mixedReset: (ref) => gitExec("reset", "--mixed", ref),
|
|
220
|
+
rejectingHook: (output) => {
|
|
221
|
+
const hooks = join(root, ".git/hooks")
|
|
222
|
+
gitExec("config", "core.hooksPath", hooks)
|
|
223
|
+
writeRejectingHook(hooks, output)
|
|
224
|
+
},
|
|
225
|
+
stagingHook: (path, content) => {
|
|
226
|
+
const hooks = join(root, ".git/hooks")
|
|
227
|
+
gitExec("config", "core.hooksPath", hooks)
|
|
228
|
+
writeStagingHook(hooks, path, content)
|
|
229
|
+
},
|
|
230
|
+
failingSigner: () => {
|
|
231
|
+
gitExec("config", "commit.gpgsign", "true")
|
|
232
|
+
// A global `gpg.format ssh` would otherwise sign and the case pass vacuously.
|
|
233
|
+
gitExec("config", "gpg.format", "openpgp")
|
|
234
|
+
gitExec("config", "gpg.program", "false")
|
|
235
|
+
},
|
|
196
236
|
},
|
|
197
237
|
observe: {
|
|
198
238
|
resolveRef: (ref) => gitExec("rev-parse", ref),
|
|
@@ -258,7 +298,7 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
258
298
|
const repo = new InMemRepo()
|
|
259
299
|
if (initialCommit) {
|
|
260
300
|
repo.writeFile("readme.txt", "hello")
|
|
261
|
-
repo.
|
|
301
|
+
repo.seedCommit("init: first commit")
|
|
262
302
|
}
|
|
263
303
|
const gitLayer = gitTestLayer(repo, IN_MEM_ROOT)
|
|
264
304
|
|
|
@@ -275,7 +315,6 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
275
315
|
root: IN_MEM_ROOT,
|
|
276
316
|
capabilities: {
|
|
277
317
|
onDiskContent: false,
|
|
278
|
-
commitHooks: false,
|
|
279
318
|
linkedWorktrees: false,
|
|
280
319
|
directoryChainConfig: true,
|
|
281
320
|
remotes: false,
|
|
@@ -286,17 +325,26 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
286
325
|
seed: {
|
|
287
326
|
commit: (message, files = { "file.txt": message }) => {
|
|
288
327
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
289
|
-
repo.
|
|
328
|
+
repo.seedCommit(message)
|
|
290
329
|
},
|
|
291
330
|
writeFile: (path, content) => repo.writeFile(path, content),
|
|
292
331
|
deleteFile: (path) => repo.deleteFile(path),
|
|
293
332
|
commitDeletion: (path, message) => {
|
|
294
333
|
repo.deleteFile(path)
|
|
295
|
-
repo.
|
|
334
|
+
repo.seedCommit(message)
|
|
296
335
|
},
|
|
297
336
|
stageAll: () => repo.stageAll(),
|
|
298
337
|
updateRef: (ref, hash) => repo.updateRef(ref, hash),
|
|
299
338
|
mixedReset: (ref) => repo.mixedResetTo(ref),
|
|
339
|
+
rejectingHook: (output) => {
|
|
340
|
+
repo.hookRejection = output
|
|
341
|
+
},
|
|
342
|
+
stagingHook: (path, content) => {
|
|
343
|
+
repo.hookStages = { path, content }
|
|
344
|
+
},
|
|
345
|
+
failingSigner: () => {
|
|
346
|
+
repo.signingFails = true
|
|
347
|
+
},
|
|
300
348
|
},
|
|
301
349
|
observe: {
|
|
302
350
|
resolveRef: (ref) => {
|
|
@@ -361,6 +409,7 @@ export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new
|
|
|
361
409
|
"subjectHistory",
|
|
362
410
|
"readFileAtRef",
|
|
363
411
|
"changedPaths",
|
|
412
|
+
"diffPaths",
|
|
364
413
|
"commitAllWithPrefix",
|
|
365
414
|
"softResetTo",
|
|
366
415
|
"commitAsIs",
|
|
@@ -386,7 +435,7 @@ const PATHOLOGICAL_PATHS: ReadonlyArray<{ label: string; path: string }> = [
|
|
|
386
435
|
]
|
|
387
436
|
|
|
388
437
|
/**
|
|
389
|
-
* Exercise
|
|
438
|
+
* Exercise every `GitOperations` methods identically against `makeTier()` —
|
|
390
439
|
* called once per tier by `src/platform/Git.test.ts`. A capability-gated group
|
|
391
440
|
* (`t.capabilities.X`) is skipped, not faked, on a tier that can't support it.
|
|
392
441
|
*/
|
|
@@ -432,6 +481,7 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
432
481
|
g.readFileAtRef("HEAD", "second.txt"),
|
|
433
482
|
g.changedPaths(),
|
|
434
483
|
g.changedPaths("HEAD~1"),
|
|
484
|
+
g.diffPaths("HEAD~1", "HEAD"),
|
|
435
485
|
]),
|
|
436
486
|
)
|
|
437
487
|
expect(await t.observe.snapshot()).toBe(before)
|
|
@@ -727,23 +777,6 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
727
777
|
const history = await runGit(t, (g) => g.commitHistory())
|
|
728
778
|
expect(history[history.length - 1]?.message).toBe("gtd: grilled")
|
|
729
779
|
})
|
|
730
|
-
|
|
731
|
-
it.skipIf(!capabilities.commitHooks)(
|
|
732
|
-
"retries with --no-verify when a hook blocks the empty commit",
|
|
733
|
-
async () => {
|
|
734
|
-
const hookPath = join(t.root, ".git/hooks/pre-commit")
|
|
735
|
-
writeFileSync(
|
|
736
|
-
hookPath,
|
|
737
|
-
`#!/bin/sh\necho "lint-staged prevented an empty git commit." >&2\nexit 1\n`,
|
|
738
|
-
)
|
|
739
|
-
execSync(`chmod +x "${hookPath}"`)
|
|
740
|
-
const headBefore = t.observe.resolveRef("HEAD")
|
|
741
|
-
await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"))
|
|
742
|
-
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
743
|
-
const history = await runGit(t, (g) => g.commitHistory())
|
|
744
|
-
expect(history[history.length - 1]?.message).toBe("gtd: grilled")
|
|
745
|
-
},
|
|
746
|
-
)
|
|
747
780
|
})
|
|
748
781
|
|
|
749
782
|
describe("commitAsIs", () => {
|
|
@@ -768,6 +801,35 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
768
801
|
})
|
|
769
802
|
})
|
|
770
803
|
|
|
804
|
+
describe.each([
|
|
805
|
+
["commitAllWithPrefix", (g: GitOperations) => g.commitAllWithPrefix("gtd: building")],
|
|
806
|
+
["commitAsIs", (g: GitOperations) => g.commitAsIs("gtd: building")],
|
|
807
|
+
] as const)("%s seeded commit failures", (_name, commit) => {
|
|
808
|
+
it("retries with --no-verify when a hook blocks the empty commit", async () => {
|
|
809
|
+
t.seed.rejectingHook("lint-staged prevented an empty git commit.")
|
|
810
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
811
|
+
await runGit(t, commit)
|
|
812
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
813
|
+
const history = await runGit(t, (g) => g.commitHistory())
|
|
814
|
+
expect(history[history.length - 1]?.message).toBe("gtd: building")
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it("fails on a hook rejection that is not the empty-commit one, leaving HEAD unchanged", async () => {
|
|
818
|
+
t.seed.rejectingHook("lint error")
|
|
819
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
820
|
+
expect(Exit.isFailure(await runGitExit(t, commit))).toBe(true)
|
|
821
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it("fails both attempts when the signer cannot write, leaving HEAD unchanged", async () => {
|
|
825
|
+
t.seed.rejectingHook("rejected: empty git commit")
|
|
826
|
+
t.seed.failingSigner()
|
|
827
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
828
|
+
expect(Exit.isFailure(await runGitExit(t, commit))).toBe(true)
|
|
829
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
|
|
830
|
+
})
|
|
831
|
+
})
|
|
832
|
+
|
|
771
833
|
describe("softResetTo", () => {
|
|
772
834
|
it("moves HEAD back but keeps worktree changes from second commit", async () => {
|
|
773
835
|
const firstHash = t.observe.resolveRef("HEAD")
|
|
@@ -824,6 +886,25 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
824
886
|
expect(resolved._tag).toBe("None")
|
|
825
887
|
})
|
|
826
888
|
|
|
889
|
+
it("readRefOption of <hash>^{commit} is Some for an existing commit", async () => {
|
|
890
|
+
const head = t.observe.resolveRef("HEAD")
|
|
891
|
+
const resolved = await runGit(t, (g) => g.readRefOption(`${head}^{commit}`))
|
|
892
|
+
expect(resolved._tag).toBe("Some")
|
|
893
|
+
if (resolved._tag === "Some") expect(resolved.value).toBe(head)
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
it("readRefOption of <hash>^{commit} is None for a 40-hex hash with no object", async () => {
|
|
897
|
+
const missing = "0123456789abcdef0123456789abcdef01234567"
|
|
898
|
+
const resolved = await runGit(t, (g) => g.readRefOption(`${missing}^{commit}`))
|
|
899
|
+
expect(resolved._tag).toBe("None")
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
it("readRefOption of a bare 40-hex hash with no object is Some", async () => {
|
|
903
|
+
const missing = "0123456789abcdef0123456789abcdef01234567"
|
|
904
|
+
const resolved = await runGit(t, (g) => g.readRefOption(missing))
|
|
905
|
+
expect(resolved._tag).toBe("Some")
|
|
906
|
+
})
|
|
907
|
+
|
|
827
908
|
it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
|
|
828
909
|
const head = t.observe.resolveRef("HEAD")
|
|
829
910
|
await runGit(t, (g) => g.updateRef("refs/gtd/marker", head))
|
|
@@ -909,6 +990,70 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
909
990
|
})
|
|
910
991
|
})
|
|
911
992
|
|
|
993
|
+
describe("diffPaths", () => {
|
|
994
|
+
const sorted = (paths: ReadonlyArray<string>) => [...paths].sort()
|
|
995
|
+
|
|
996
|
+
it("reports added, modified and deleted paths between two commits", async () => {
|
|
997
|
+
t.seed.commit("chore: seed", { "mod.txt": "one", "del.txt": "bye" })
|
|
998
|
+
const base = t.observe.resolveRef("HEAD")
|
|
999
|
+
t.seed.writeFile("mod.txt", "two")
|
|
1000
|
+
t.seed.stageAll()
|
|
1001
|
+
t.seed.commitDeletion("del.txt", "chore: delete")
|
|
1002
|
+
t.seed.commit("feat: add", { "add.txt": "new", "mod.txt": "two" })
|
|
1003
|
+
const tip = t.observe.resolveRef("HEAD")
|
|
1004
|
+
const paths = await runGit(t, (g) => g.diffPaths(base, tip))
|
|
1005
|
+
expect(sorted(paths)).toEqual(["add.txt", "del.txt", "mod.txt"])
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it("reports both paths of a rename", async () => {
|
|
1009
|
+
t.seed.commit("chore: seed", { "old.txt": "same content unique-marker\n" })
|
|
1010
|
+
const base = t.observe.resolveRef("HEAD")
|
|
1011
|
+
t.seed.deleteFile("old.txt")
|
|
1012
|
+
t.seed.commit("refactor: rename", { "new.txt": "same content unique-marker\n" })
|
|
1013
|
+
const tip = t.observe.resolveRef("HEAD")
|
|
1014
|
+
expect(sorted(await runGit(t, (g) => g.diffPaths(base, tip)))).toEqual(["new.txt", "old.txt"])
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
it("omits a file changed then restored between the endpoints", async () => {
|
|
1018
|
+
t.seed.commit("chore: seed", { "flip.txt": "one" })
|
|
1019
|
+
const base = t.observe.resolveRef("HEAD")
|
|
1020
|
+
t.seed.commit("chore: change", { "flip.txt": "two" })
|
|
1021
|
+
t.seed.commit("chore: restore", { "flip.txt": "one" })
|
|
1022
|
+
const tip = t.observe.resolveRef("HEAD")
|
|
1023
|
+
expect(await runGit(t, (g) => g.diffPaths(base, tip))).toEqual([])
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
it("never reports staged, unstaged or untracked changes", async () => {
|
|
1027
|
+
t.seed.commit("chore: seed", { "a.txt": "a", "b.txt": "b" })
|
|
1028
|
+
const base = t.observe.resolveRef("HEAD")
|
|
1029
|
+
t.seed.writeFile("a.txt", "staged")
|
|
1030
|
+
t.seed.stageAll()
|
|
1031
|
+
t.seed.writeFile("b.txt", "unstaged")
|
|
1032
|
+
t.seed.writeFile("untracked.txt", "u")
|
|
1033
|
+
expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([])
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
it("is [] for diffPaths(x, x)", async () => {
|
|
1037
|
+
const head = t.observe.resolveRef("HEAD")
|
|
1038
|
+
expect(await runGit(t, (g) => g.diffPaths(head, head))).toEqual([])
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
for (const { label, path } of PATHOLOGICAL_PATHS) {
|
|
1042
|
+
it(`reports a path containing ${label}, verbatim`, async () => {
|
|
1043
|
+
const base = t.observe.resolveRef("HEAD")
|
|
1044
|
+
t.seed.commit("feat: pathological", { [path]: "content" })
|
|
1045
|
+
expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([path])
|
|
1046
|
+
})
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
it("fails when either side does not resolve", async () => {
|
|
1050
|
+
const bad = await runGitExit(t, (g) => g.diffPaths("totally-invalid-ref-xyz", "HEAD"))
|
|
1051
|
+
expect(Exit.isFailure(bad)).toBe(true)
|
|
1052
|
+
const badTip = await runGitExit(t, (g) => g.diffPaths("HEAD", "totally-invalid-ref-xyz"))
|
|
1053
|
+
expect(Exit.isFailure(badTip)).toBe(true)
|
|
1054
|
+
})
|
|
1055
|
+
})
|
|
1056
|
+
|
|
912
1057
|
describe("changedPaths", () => {
|
|
913
1058
|
it("unions a tracked modification, a tracked deletion, and an untracked addition, deduplicated by path", async () => {
|
|
914
1059
|
t.seed.commit("chore: seed", { "modme.txt": "orig", "delme.txt": "bye" })
|
package/src/testkit/InMemRepo.ts
CHANGED
|
@@ -111,6 +111,7 @@ export class InMemRepo {
|
|
|
111
111
|
|
|
112
112
|
// fallow-ignore-next-line complexity
|
|
113
113
|
resolveRef(ref: string): string | null {
|
|
114
|
+
if (ref.endsWith("^{commit}")) return this.resolveRef(ref.slice(0, -"^{commit}".length))
|
|
114
115
|
if (/^[0-9a-f]{40}$/.test(ref)) {
|
|
115
116
|
return this.commits.has(ref) ? ref : null
|
|
116
117
|
}
|
|
@@ -269,6 +270,13 @@ export class InMemRepo {
|
|
|
269
270
|
return diffTrees(baseTree, worktreeTree)
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
diffPaths(base: string, tip: string): string[] {
|
|
274
|
+
for (const ref of [base, tip]) {
|
|
275
|
+
if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`)
|
|
276
|
+
}
|
|
277
|
+
return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path)
|
|
278
|
+
}
|
|
279
|
+
|
|
272
280
|
/** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
|
|
273
281
|
readFile(path: string): string | undefined {
|
|
274
282
|
return this.worktree.get(path)
|
|
@@ -335,8 +343,13 @@ export class InMemRepo {
|
|
|
335
343
|
this.index = new Map(this.worktree)
|
|
336
344
|
}
|
|
337
345
|
|
|
338
|
-
/**
|
|
339
|
-
|
|
346
|
+
/** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
|
|
347
|
+
hookRejection: string | undefined = undefined
|
|
348
|
+
signingFails = false
|
|
349
|
+
/** Seeded: a pre-commit hook writing and staging this file. */
|
|
350
|
+
hookStages: { readonly path: string; readonly content: string } | undefined = undefined
|
|
351
|
+
|
|
352
|
+
private writeCommit(message: string): void {
|
|
340
353
|
const tree = new Map(this.index)
|
|
341
354
|
const parent = this.head
|
|
342
355
|
const hash = makeHash(message, parent, tree)
|
|
@@ -346,24 +359,63 @@ export class InMemRepo {
|
|
|
346
359
|
this.branches.set(this.currentBranch, hash)
|
|
347
360
|
}
|
|
348
361
|
|
|
349
|
-
/**
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
362
|
+
/**
|
|
363
|
+
* Twin of the emitted `git commit --allow-empty` if/case retry: a hook
|
|
364
|
+
* rejection printing "empty git commit" retries without hooks, any other
|
|
365
|
+
* rejection fails; a failing signer fails either attempt. Commits whatever is
|
|
366
|
+
* staged verbatim, like `git commit` after a soft reset.
|
|
367
|
+
*/
|
|
368
|
+
commitAsIs(message: string): string | undefined {
|
|
369
|
+
if (this.hookRejection !== undefined && !this.hookRejection.includes("empty git commit")) {
|
|
370
|
+
return this.hookRejection
|
|
371
|
+
}
|
|
372
|
+
if (this.signingFails) return "error: gpg failed to sign the data"
|
|
373
|
+
if (this.hookStages !== undefined) {
|
|
374
|
+
this.worktree.set(this.hookStages.path, this.hookStages.content)
|
|
375
|
+
this.index.set(this.hookStages.path, this.hookStages.content)
|
|
376
|
+
}
|
|
377
|
+
this.writeCommit(message)
|
|
378
|
+
return undefined
|
|
353
379
|
}
|
|
354
380
|
|
|
355
|
-
|
|
381
|
+
/** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
|
|
382
|
+
seedCommit(message: string): void {
|
|
383
|
+
if (this.hookRejection !== undefined || this.hookStages !== undefined || this.signingFails) {
|
|
384
|
+
throw new Error("seedCommit after a seeded commit failure")
|
|
385
|
+
}
|
|
356
386
|
this.index = new Map(this.worktree)
|
|
387
|
+
this.writeCommit(message)
|
|
388
|
+
}
|
|
357
389
|
|
|
390
|
+
/** Twin of `GitScript.keepTurn`: no hook runs; `signingFails` fails the marker. */
|
|
391
|
+
keepTurn(refPrefix: string, message: string): string | undefined {
|
|
392
|
+
this.stageAll()
|
|
358
393
|
const tree = new Map(this.index)
|
|
359
|
-
const message = prefix
|
|
360
394
|
const parent = this.head
|
|
361
395
|
const hash = makeHash(message, parent, tree)
|
|
396
|
+
this.commits.set(hash, { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ })
|
|
397
|
+
const ref = `${refPrefix}/${hash}`
|
|
398
|
+
this.refs.set(ref, hash)
|
|
399
|
+
this.index = new Map(this.headTree())
|
|
400
|
+
if (this.signingFails) {
|
|
401
|
+
this.refs.delete(ref)
|
|
402
|
+
return "error: gpg failed to sign the data"
|
|
403
|
+
}
|
|
404
|
+
this.writeCommit(`${message}\nGtd-Kept: ${hash}`)
|
|
405
|
+
this.index = new Map(tree)
|
|
406
|
+
this.resetHard()
|
|
407
|
+
return undefined
|
|
408
|
+
}
|
|
362
409
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
this.
|
|
366
|
-
this.
|
|
410
|
+
/** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
|
|
411
|
+
discardPending(): void {
|
|
412
|
+
this.index = new Map(this.worktree)
|
|
413
|
+
this.resetHard()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
commitAllWithPrefix(prefix: string): string | undefined {
|
|
417
|
+
this.index = new Map(this.worktree)
|
|
418
|
+
return this.commitAsIs(prefix)
|
|
367
419
|
}
|
|
368
420
|
|
|
369
421
|
// Git's empty-tree object SHA: used as squash base when there's no parent commit.
|
|
@@ -2,7 +2,8 @@ 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 { testLayers } from "./Layers.js"
|
|
5
|
+
import { ambientFlagEnv, testLayers } from "./Layers.js"
|
|
6
|
+
import { Host } from "../platform/index.js"
|
|
6
7
|
|
|
7
8
|
// The in-memory `ConfigDiscovery` counterpart walks the SAME root→home chain
|
|
8
9
|
// and looks for the SAME `LEGACY_RC_FILES` as `ConfigDiscovery.Live` — both
|
|
@@ -56,3 +57,35 @@ describe("the in-memory ConfigDiscovery counterpart", () => {
|
|
|
56
57
|
expect(found).toEqual([`${HOME}/${LEGACY_RC_FILES[1]}`])
|
|
57
58
|
})
|
|
58
59
|
})
|
|
60
|
+
|
|
61
|
+
describe("ambient GTD_FLAG_* reaches the in-memory Host", () => {
|
|
62
|
+
const hostEnv = (ambient: Record<string, string>, env?: Record<string, string>) => {
|
|
63
|
+
const before = { ...process.env }
|
|
64
|
+
Object.assign(process.env, ambient)
|
|
65
|
+
return Effect.runPromise(
|
|
66
|
+
Effect.map(Host, (h) => h.env).pipe(
|
|
67
|
+
Effect.provide(testLayers(new InMemRepo(), env ? { env } : {})),
|
|
68
|
+
Effect.ensuring(
|
|
69
|
+
Effect.sync(() => {
|
|
70
|
+
for (const k of Object.keys(ambient)) {
|
|
71
|
+
if (before[k] === undefined) delete process.env[k]
|
|
72
|
+
else process.env[k] = before[k]
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("carries the ambient flag, and explicit env wins", async () => {
|
|
81
|
+
expect((await hostEnv({ GTD_FLAG_ALPHA: "1" }))["GTD_FLAG_ALPHA"]).toBe("1")
|
|
82
|
+
const env = await hostEnv({ GTD_FLAG_ALPHA: "1" }, { GTD_FLAG_ALPHA: "0" })
|
|
83
|
+
expect(env["GTD_FLAG_ALPHA"]).toBe("0")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("picks only GTD_FLAG_* keys", () => {
|
|
87
|
+
expect(ambientFlagEnv({ GTD_FLAG_X: "1", PATH: "/bin", GTD_FLAG_U: undefined })).toEqual({
|
|
88
|
+
GTD_FLAG_X: "1",
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|
package/src/testkit/Layers.ts
CHANGED
|
@@ -216,6 +216,21 @@ const missingToolCommands: ToolCommands["Type"] = {
|
|
|
216
216
|
export const gitTestLayer = (repo: InMemRepo, root = "/repo"): Layer.Layer<GitService> =>
|
|
217
217
|
Layer.succeed(GitService, fakeGitOperations(repo, root))
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The one sanctioned leak of the real `process.env` into the in-memory `Host`:
|
|
221
|
+
* the runner's `GTD_FLAG_*` state, so a flag-on `npm test` state exercises the
|
|
222
|
+
* flag in memory as it does live. A test asserting one flag side sets the flag
|
|
223
|
+
* itself; explicit env wins.
|
|
224
|
+
*/
|
|
225
|
+
export const ambientFlagEnv = (
|
|
226
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
227
|
+
): Record<string, string> =>
|
|
228
|
+
Object.fromEntries(
|
|
229
|
+
Object.entries(env).filter(
|
|
230
|
+
(e): e is [string, string] => e[0].startsWith("GTD_FLAG_") && e[1] !== undefined,
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
|
|
219
234
|
/** 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. */
|
|
220
235
|
export function testLayers(
|
|
221
236
|
repo: InMemRepo,
|
|
@@ -231,7 +246,12 @@ export function testLayers(
|
|
|
231
246
|
// symlinks against): both sides of `assertRunningFromRepoRoot`'s
|
|
232
247
|
// comparison collapse to `root` when given anything, exactly like the
|
|
233
248
|
// fake's `root` "resolving" to itself before this port existed.
|
|
234
|
-
Host.layer({
|
|
249
|
+
Host.layer({
|
|
250
|
+
root,
|
|
251
|
+
home,
|
|
252
|
+
env: { ...ambientFlagEnv(), ...opts.env },
|
|
253
|
+
realPath: () => Effect.succeed(root),
|
|
254
|
+
}),
|
|
235
255
|
ConfigService.Live,
|
|
236
256
|
makeInMemoryConfigDiscovery(repo, root),
|
|
237
257
|
Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true),
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface TestRepoOptions {
|
|
|
19
19
|
readonly workflow: WorkflowModule
|
|
20
20
|
/** Committed alongside the config, before any process starts. */
|
|
21
21
|
readonly files?: Readonly<Record<string, string>>
|
|
22
|
-
/** The environment gtd sees; the real `process.env` is never read. */
|
|
22
|
+
/** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
|
|
23
23
|
readonly env?: Readonly<Record<string, string>>
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -169,7 +169,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
169
169
|
typeof workflow === "string" ? workflow : moduleSource(id, workflow),
|
|
170
170
|
)
|
|
171
171
|
for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
|
|
172
|
-
repo.
|
|
172
|
+
repo.seedCommit("chore: initial commit")
|
|
173
173
|
|
|
174
174
|
// The module is registered only while gtd runs, so nothing outlives the
|
|
175
175
|
// call that loads it and no test can reach another's module.
|
|
@@ -245,7 +245,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
245
245
|
read: (path) => repo.readFile(path),
|
|
246
246
|
write: (path, content) => repo.writeFile(path, content),
|
|
247
247
|
remove: (path) => repo.deleteFile(path),
|
|
248
|
-
commit: (message) => repo.
|
|
248
|
+
commit: (message) => repo.seedCommit(message),
|
|
249
249
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
250
250
|
runScript: (script) => {
|
|
251
251
|
const flow = applyFlowScript(repo, new Map(), script)
|
|
@@ -5,16 +5,24 @@ import {
|
|
|
5
5
|
EXIT_RUNTIME_ERROR,
|
|
6
6
|
EXIT_SIGINT,
|
|
7
7
|
EXIT_SIGTERM,
|
|
8
|
+
EXIT_STRAYS_FOUND,
|
|
8
9
|
EXIT_USAGE_ERROR,
|
|
9
10
|
} from "./index.js"
|
|
10
11
|
|
|
11
12
|
describe("EXIT_CODES", () => {
|
|
12
|
-
it("is frozen at exactly {0, 1, 2, 130, 143} — a new code breaks every driver", () => {
|
|
13
|
-
expect(EXIT_CODES).toEqual(new Set([0, 1, 2, 130, 143]))
|
|
13
|
+
it("is frozen at exactly {0, 1, 2, 10, 130, 143} — a new code breaks every driver", () => {
|
|
14
|
+
expect(EXIT_CODES).toEqual(new Set([0, 1, 2, 10, 130, 143]))
|
|
14
15
|
})
|
|
15
16
|
|
|
16
17
|
it("each named constant is a member of the set", () => {
|
|
17
|
-
for (const code of [
|
|
18
|
+
for (const code of [
|
|
19
|
+
EXIT_OK,
|
|
20
|
+
EXIT_RUNTIME_ERROR,
|
|
21
|
+
EXIT_USAGE_ERROR,
|
|
22
|
+
EXIT_STRAYS_FOUND,
|
|
23
|
+
EXIT_SIGINT,
|
|
24
|
+
EXIT_SIGTERM,
|
|
25
|
+
]) {
|
|
18
26
|
expect(EXIT_CODES.has(code)).toBe(true)
|
|
19
27
|
}
|
|
20
28
|
})
|
package/src/wire/ExitCodes.ts
CHANGED
|
@@ -3,6 +3,8 @@ export const EXIT_OK = 0
|
|
|
3
3
|
export const EXIT_RUNTIME_ERROR = 1
|
|
4
4
|
/** gtd was invoked wrong — an unknown option/command, bad arity, a scope violation, a decode failure. */
|
|
5
5
|
export const EXIT_USAGE_ERROR = 2
|
|
6
|
+
/** `gtd strays` found touched paths outside the allowlist — that command's only, and it carries no other meaning. */
|
|
7
|
+
export const EXIT_STRAYS_FOUND = 10
|
|
6
8
|
export const EXIT_SIGINT = 130
|
|
7
9
|
export const EXIT_SIGTERM = 143
|
|
8
10
|
|
|
@@ -11,6 +13,7 @@ export const EXIT_CODES: ReadonlySet<number> = new Set([
|
|
|
11
13
|
EXIT_OK,
|
|
12
14
|
EXIT_RUNTIME_ERROR,
|
|
13
15
|
EXIT_USAGE_ERROR,
|
|
16
|
+
EXIT_STRAYS_FOUND,
|
|
14
17
|
EXIT_SIGINT,
|
|
15
18
|
EXIT_SIGTERM,
|
|
16
19
|
])
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { globMatches } from "./Glob.js"
|
|
3
|
+
|
|
4
|
+
describe("globMatches", () => {
|
|
5
|
+
it("`*` stays within one segment; `**` crosses segments, zero included", () => {
|
|
6
|
+
expect(globMatches("a.md", "*.md")).toBe(true)
|
|
7
|
+
expect(globMatches("docs/a.md", "*.md")).toBe(false)
|
|
8
|
+
expect(globMatches("src/x/y/a.ts", "src/**")).toBe(true)
|
|
9
|
+
expect(globMatches("a.ts", "**/a.ts")).toBe(true)
|
|
10
|
+
expect(globMatches("x/a.ts", "**/a.ts")).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("dotfiles match `*` and `**` like any other path", () => {
|
|
14
|
+
expect(globMatches(".env", "*")).toBe(true)
|
|
15
|
+
expect(globMatches("dir/.hidden", "dir/*")).toBe(true)
|
|
16
|
+
expect(globMatches(".github/ci.yml", "**")).toBe(true)
|
|
17
|
+
expect(globMatches(".github/ci.yml", "*")).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("matches case-sensitively", () => {
|
|
21
|
+
expect(globMatches("README.md", "readme.md")).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("`?` and `[` are literal", () => {
|
|
25
|
+
expect(globMatches("a?.txt", "a?.txt")).toBe(true)
|
|
26
|
+
expect(globMatches("ab.txt", "a?.txt")).toBe(false)
|
|
27
|
+
expect(globMatches("[a].txt", "[a].txt")).toBe(true)
|
|
28
|
+
expect(globMatches("a.txt", "[a].txt")).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("`**` crosses a path containing a line break", () => {
|
|
32
|
+
expect(globMatches("weird\nname.txt", "**")).toBe(true)
|
|
33
|
+
expect(globMatches("d/a\nb", "d/**")).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
})
|
package/src/wire/Glob.ts
CHANGED
package/src/workflow/Workflow.ts
CHANGED
|
@@ -83,7 +83,7 @@ const inMemory = (): Tier => ({
|
|
|
83
83
|
load: (files) => {
|
|
84
84
|
const repo = new InMemRepo()
|
|
85
85
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
86
|
-
repo.
|
|
86
|
+
repo.seedCommit("chore: init")
|
|
87
87
|
return loadWith(testLayers(repo))
|
|
88
88
|
},
|
|
89
89
|
dispose: () => {},
|