@pmelab/gtd 26.0.0 → 26.1.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -0
  3. package/dist/flows.mjs +15 -1
  4. package/dist/gtd.bundle.mjs +134 -18
  5. package/dist/testkit.mjs +284 -55
  6. package/package.json +1 -1
  7. package/src/cli/Cli.test.ts +3 -3
  8. package/src/cli/History.test.ts +2 -2
  9. package/src/cli/Program.test.ts +10 -12
  10. package/src/edge/Edge.test.ts +113 -16
  11. package/src/edge/Edge.ts +22 -6
  12. package/src/emit/GitScript.test.ts +65 -2
  13. package/src/emit/GitScript.ts +34 -1
  14. package/src/emit/index.ts +1 -0
  15. package/src/flags/index.ts +0 -1
  16. package/src/flows/runtime.test.ts +33 -0
  17. package/src/flows/runtime.ts +31 -0
  18. package/src/platform/RetainedHistory.test.ts +9 -0
  19. package/src/platform/RetainedHistory.ts +3 -0
  20. package/src/platform/Workspace.test.ts +1 -1
  21. package/src/platform/index.ts +1 -0
  22. package/src/replay/RefName.test.ts +40 -0
  23. package/src/replay/RefName.ts +19 -0
  24. package/src/replay/Replay.test.ts +193 -2
  25. package/src/replay/Replay.ts +51 -6
  26. package/src/replay/Trailers.test.ts +26 -0
  27. package/src/replay/Trailers.ts +8 -0
  28. package/src/replay/index.ts +1 -0
  29. package/src/step/LandStep.ts +1 -0
  30. package/src/step/RepoSnapshot.ts +2 -0
  31. package/src/step/planStart.test.ts +5 -5
  32. package/src/step/planStep.test.ts +53 -0
  33. package/src/step/planStep.ts +7 -1
  34. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  35. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  36. package/src/testkit/EmittedScripts.test.ts +182 -0
  37. package/src/testkit/FakeGitOperations.ts +8 -2
  38. package/src/testkit/GitTiers.ts +103 -24
  39. package/src/testkit/InMemRepo.ts +57 -12
  40. package/src/testkit/TestRepo.ts +2 -2
  41. package/src/workflow/Workflow.ts +1 -0
  42. package/src/workflow/discovery.test.ts +1 -1
  43. 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) => {
@@ -727,23 +775,6 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
727
775
  const history = await runGit(t, (g) => g.commitHistory())
728
776
  expect(history[history.length - 1]?.message).toBe("gtd: grilled")
729
777
  })
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
778
  })
748
779
 
749
780
  describe("commitAsIs", () => {
@@ -768,6 +799,35 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
768
799
  })
769
800
  })
770
801
 
802
+ describe.each([
803
+ ["commitAllWithPrefix", (g: GitOperations) => g.commitAllWithPrefix("gtd: building")],
804
+ ["commitAsIs", (g: GitOperations) => g.commitAsIs("gtd: building")],
805
+ ] as const)("%s seeded commit failures", (_name, commit) => {
806
+ it("retries with --no-verify when a hook blocks the empty commit", async () => {
807
+ t.seed.rejectingHook("lint-staged prevented an empty git commit.")
808
+ const headBefore = t.observe.resolveRef("HEAD")
809
+ await runGit(t, commit)
810
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
811
+ const history = await runGit(t, (g) => g.commitHistory())
812
+ expect(history[history.length - 1]?.message).toBe("gtd: building")
813
+ })
814
+
815
+ it("fails on a hook rejection that is not the empty-commit one, leaving HEAD unchanged", async () => {
816
+ t.seed.rejectingHook("lint error")
817
+ const headBefore = t.observe.resolveRef("HEAD")
818
+ expect(Exit.isFailure(await runGitExit(t, commit))).toBe(true)
819
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
820
+ })
821
+
822
+ it("fails both attempts when the signer cannot write, leaving HEAD unchanged", async () => {
823
+ t.seed.rejectingHook("rejected: empty git commit")
824
+ t.seed.failingSigner()
825
+ const headBefore = t.observe.resolveRef("HEAD")
826
+ expect(Exit.isFailure(await runGitExit(t, commit))).toBe(true)
827
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
828
+ })
829
+ })
830
+
771
831
  describe("softResetTo", () => {
772
832
  it("moves HEAD back but keeps worktree changes from second commit", async () => {
773
833
  const firstHash = t.observe.resolveRef("HEAD")
@@ -824,6 +884,25 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
824
884
  expect(resolved._tag).toBe("None")
825
885
  })
826
886
 
887
+ it("readRefOption of <hash>^{commit} is Some for an existing commit", async () => {
888
+ const head = t.observe.resolveRef("HEAD")
889
+ const resolved = await runGit(t, (g) => g.readRefOption(`${head}^{commit}`))
890
+ expect(resolved._tag).toBe("Some")
891
+ if (resolved._tag === "Some") expect(resolved.value).toBe(head)
892
+ })
893
+
894
+ it("readRefOption of <hash>^{commit} is None for a 40-hex hash with no object", async () => {
895
+ const missing = "0123456789abcdef0123456789abcdef01234567"
896
+ const resolved = await runGit(t, (g) => g.readRefOption(`${missing}^{commit}`))
897
+ expect(resolved._tag).toBe("None")
898
+ })
899
+
900
+ it("readRefOption of a bare 40-hex hash with no object is Some", async () => {
901
+ const missing = "0123456789abcdef0123456789abcdef01234567"
902
+ const resolved = await runGit(t, (g) => g.readRefOption(missing))
903
+ expect(resolved._tag).toBe("Some")
904
+ })
905
+
827
906
  it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
828
907
  const head = t.observe.resolveRef("HEAD")
829
908
  await runGit(t, (g) => g.updateRef("refs/gtd/marker", head))
@@ -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
  }
@@ -335,8 +336,13 @@ export class InMemRepo {
335
336
  this.index = new Map(this.worktree)
336
337
  }
337
338
 
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 {
339
+ /** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
340
+ hookRejection: string | undefined = undefined
341
+ signingFails = false
342
+ /** Seeded: a pre-commit hook writing and staging this file. */
343
+ hookStages: { readonly path: string; readonly content: string } | undefined = undefined
344
+
345
+ private writeCommit(message: string): void {
340
346
  const tree = new Map(this.index)
341
347
  const parent = this.head
342
348
  const hash = makeHash(message, parent, tree)
@@ -346,24 +352,63 @@ export class InMemRepo {
346
352
  this.branches.set(this.currentBranch, hash)
347
353
  }
348
354
 
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()
355
+ /**
356
+ * Twin of the emitted `git commit --allow-empty` if/case retry: a hook
357
+ * rejection printing "empty git commit" retries without hooks, any other
358
+ * rejection fails; a failing signer fails either attempt. Commits whatever is
359
+ * staged verbatim, like `git commit` after a soft reset.
360
+ */
361
+ commitAsIs(message: string): string | undefined {
362
+ if (this.hookRejection !== undefined && !this.hookRejection.includes("empty git commit")) {
363
+ return this.hookRejection
364
+ }
365
+ if (this.signingFails) return "error: gpg failed to sign the data"
366
+ if (this.hookStages !== undefined) {
367
+ this.worktree.set(this.hookStages.path, this.hookStages.content)
368
+ this.index.set(this.hookStages.path, this.hookStages.content)
369
+ }
370
+ this.writeCommit(message)
371
+ return undefined
353
372
  }
354
373
 
355
- commitAllWithPrefix(prefix: string): void {
374
+ /** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
375
+ seedCommit(message: string): void {
376
+ if (this.hookRejection !== undefined || this.hookStages !== undefined || this.signingFails) {
377
+ throw new Error("seedCommit after a seeded commit failure")
378
+ }
356
379
  this.index = new Map(this.worktree)
380
+ this.writeCommit(message)
381
+ }
357
382
 
383
+ /** Twin of `GitScript.keepTurn`: no hook runs; `signingFails` fails the marker. */
384
+ keepTurn(refPrefix: string, message: string): string | undefined {
385
+ this.stageAll()
358
386
  const tree = new Map(this.index)
359
- const message = prefix
360
387
  const parent = this.head
361
388
  const hash = makeHash(message, parent, tree)
389
+ this.commits.set(hash, { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ })
390
+ const ref = `${refPrefix}/${hash}`
391
+ this.refs.set(ref, hash)
392
+ this.index = new Map(this.headTree())
393
+ if (this.signingFails) {
394
+ this.refs.delete(ref)
395
+ return "error: gpg failed to sign the data"
396
+ }
397
+ this.writeCommit(`${message}\nGtd-Kept: ${hash}`)
398
+ this.index = new Map(tree)
399
+ this.resetHard()
400
+ return undefined
401
+ }
362
402
 
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)
403
+ /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
404
+ discardPending(): void {
405
+ this.index = new Map(this.worktree)
406
+ this.resetHard()
407
+ }
408
+
409
+ commitAllWithPrefix(prefix: string): string | undefined {
410
+ this.index = new Map(this.worktree)
411
+ return this.commitAsIs(prefix)
367
412
  }
368
413
 
369
414
  // Git's empty-tree object SHA: used as squash base when there's no parent commit.
@@ -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)
@@ -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: () => [],