@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.
Files changed (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -3
  3. package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
  4. package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
  5. package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
  6. package/dist/flows.mjs +15 -1
  7. package/dist/gtd.bundle.mjs +219 -34
  8. package/dist/testkit.mjs +447 -72
  9. package/dist/wire.mjs +2 -2
  10. package/dist/workflows/feature.mjs +1 -1
  11. package/dist/workflows/fix.mjs +1 -1
  12. package/dist/workflows/review.mjs +1 -1
  13. package/package.json +2 -2
  14. package/src/cli/Cli.test.ts +56 -4
  15. package/src/cli/Cli.ts +48 -6
  16. package/src/cli/History.test.ts +2 -2
  17. package/src/cli/Output.ts +3 -1
  18. package/src/cli/Program.test.ts +11 -12
  19. package/src/cli/Program.ts +28 -7
  20. package/src/cli/Strays.test.ts +88 -0
  21. package/src/cli/Strays.ts +56 -0
  22. package/src/edge/Edge.test.ts +113 -16
  23. package/src/edge/Edge.ts +22 -6
  24. package/src/emit/GitScript.test.ts +65 -2
  25. package/src/emit/GitScript.ts +34 -1
  26. package/src/emit/index.ts +1 -0
  27. package/src/flags/index.ts +0 -1
  28. package/src/flows/runtime.test.ts +33 -0
  29. package/src/flows/runtime.ts +31 -0
  30. package/src/platform/Git.test.ts +96 -1
  31. package/src/platform/Git.ts +24 -0
  32. package/src/platform/RetainedHistory.test.ts +9 -0
  33. package/src/platform/RetainedHistory.ts +3 -0
  34. package/src/platform/Workspace.test.ts +1 -1
  35. package/src/platform/index.ts +1 -0
  36. package/src/replay/RefName.test.ts +40 -0
  37. package/src/replay/RefName.ts +19 -0
  38. package/src/replay/Replay.test.ts +193 -2
  39. package/src/replay/Replay.ts +51 -6
  40. package/src/replay/Trailers.test.ts +26 -0
  41. package/src/replay/Trailers.ts +8 -0
  42. package/src/replay/index.ts +1 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/RepoSnapshot.ts +2 -0
  45. package/src/step/index.ts +1 -0
  46. package/src/step/planStart.test.ts +5 -5
  47. package/src/step/planStep.test.ts +53 -0
  48. package/src/step/planStep.ts +7 -1
  49. package/src/step/strayPaths.test.ts +17 -0
  50. package/src/step/strayPaths.ts +8 -0
  51. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  52. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  53. package/src/testkit/EmittedScripts.test.ts +182 -0
  54. package/src/testkit/FakeGitOperations.ts +10 -2
  55. package/src/testkit/GitTiers.ts +170 -25
  56. package/src/testkit/InMemRepo.ts +64 -12
  57. package/src/testkit/Layers.test.ts +34 -1
  58. package/src/testkit/Layers.ts +21 -1
  59. package/src/testkit/TestRepo.ts +3 -3
  60. package/src/wire/ExitCodes.test.ts +11 -3
  61. package/src/wire/ExitCodes.ts +3 -0
  62. package/src/wire/Glob.test.ts +35 -0
  63. package/src/wire/Glob.ts +1 -1
  64. package/src/workflow/Workflow.ts +1 -0
  65. package/src/workflow/discovery.test.ts +1 -1
  66. package/src/workflows/shared/text.fixture.ts +1 -0
@@ -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.commitAllWithPrefix("init: first commit")
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.commitAllWithPrefix(message)
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.commitAllWithPrefix(message)
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 all 21 `GitOperations` methods identically against `makeTier()` —
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" })
@@ -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
- /** Commit whatever is currently staged (the index) verbatim, with no implicit staging first — mirrors `git commit --allow-empty -m <message>` after a soft reset. */
339
- commitAsIs(message: string): void {
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
- /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
350
- discardPending(): void {
351
- this.index = new Map(this.worktree)
352
- this.resetHard()
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
- commitAllWithPrefix(prefix: string): void {
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
- const commit: Commit = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
364
- this.commits.set(hash, commit)
365
- this.head = hash
366
- this.branches.set(this.currentBranch, hash)
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
+ })
@@ -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({ root, home, env: opts.env ?? {}, realPath: () => Effect.succeed(root) }),
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),
@@ -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.commitAllWithPrefix("chore: initial commit")
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.commitAllWithPrefix(message),
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 [EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR, EXIT_SIGINT, EXIT_SIGTERM]) {
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
  })
@@ -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
@@ -23,7 +23,7 @@ const compile = (glob: string): RegExp => {
23
23
  i += 2
24
24
  }
25
25
  }
26
- return new RegExp(`${pattern}$`)
26
+ return new RegExp(`${pattern}$`, "s")
27
27
  }
28
28
 
29
29
  export const globMatches = (path: string, glob: string): boolean => compile(glob).test(path)
@@ -39,6 +39,7 @@ export interface StepDef {
39
39
  readonly answerGate?: boolean
40
40
  readonly requireRevert?: boolean
41
41
  readonly allowEmpty?: boolean
42
+ readonly keep?: boolean
42
43
  readonly acceptClean?: boolean
43
44
  readonly skills?: readonly string[]
44
45
  readonly access?: StepAccess
@@ -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.commitAllWithPrefix("chore: init")
86
+ repo.seedCommit("chore: init")
87
87
  return loadWith(testLayers(repo))
88
88
  },
89
89
  dispose: () => {},
@@ -77,6 +77,7 @@ export const fixtureContext = (
77
77
  glob: () => [],
78
78
  changes: () => [],
79
79
  changesSince: unavailable,
80
+ kept: () => undefined,
80
81
  matches: () => false,
81
82
  sections: () => [],
82
83
  sectionBodies: () => [],