@pmelab/gtd 24.2.0 → 24.4.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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -6
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
  5. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  6. package/dist/flows.mjs +9 -1
  7. package/dist/gtd.bundle.mjs +1114 -865
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1135 -218
  10. package/dist/workflow-kit.mjs +9 -7
  11. package/dist/workflows/feature.mjs +152 -35
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +1 -1
  14. package/dist/workflows.mjs +223 -54
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +57 -0
  17. package/src/cli/Cli.ts +89 -9
  18. package/src/cli/History.test.ts +225 -0
  19. package/src/cli/History.ts +143 -0
  20. package/src/cli/Output.ts +68 -0
  21. package/src/cli/Program.test.ts +10 -9
  22. package/src/cli/Program.ts +24 -72
  23. package/src/edge/Edge.test.ts +261 -3
  24. package/src/edge/Edge.ts +218 -41
  25. package/src/edge/index.ts +1 -0
  26. package/src/emit/GitScript.test.ts +27 -0
  27. package/src/emit/GitScript.ts +18 -2
  28. package/src/emit/index.ts +2 -0
  29. package/src/flows/scripts.ts +6 -0
  30. package/src/platform/Git.test.ts +6 -9
  31. package/src/platform/Git.ts +48 -10
  32. package/src/platform/RetainedHistory.test.ts +14 -0
  33. package/src/platform/RetainedHistory.ts +5 -0
  34. package/src/platform/index.ts +7 -1
  35. package/src/replay/Trailers.test.ts +32 -0
  36. package/src/replay/Trailers.ts +17 -0
  37. package/src/steering/FORMATS.md +1 -1
  38. package/src/steering/SteeringFormat.test.ts +9 -1
  39. package/src/steering/SteeringFormat.ts +7 -0
  40. package/src/steering/contract.test.ts +3 -0
  41. package/src/steering/index.ts +1 -0
  42. package/src/steering/review.test.ts +30 -0
  43. package/src/steering/review.ts +8 -2
  44. package/src/step/LandStep.ts +3 -1
  45. package/src/step/RepoSnapshot.ts +7 -1
  46. package/src/step/planStart.test.ts +6 -3
  47. package/src/step/planStep.test.ts +29 -1
  48. package/src/step/planStep.ts +8 -0
  49. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  50. package/src/testkit/EmittedScriptRecognizer.ts +149 -52
  51. package/src/testkit/EmittedScripts.test.ts +166 -0
  52. package/src/testkit/FakeGitOperations.ts +6 -2
  53. package/src/testkit/FlowScripts.test.ts +242 -0
  54. package/src/testkit/FlowScripts.ts +326 -0
  55. package/src/testkit/GitTiers.ts +269 -47
  56. package/src/testkit/InMemRepo.ts +61 -8
  57. package/src/testkit/Layers.ts +10 -2
  58. package/src/testkit/TestRepo.ts +4 -1
  59. package/src/testkit/index.ts +1 -1
  60. package/src/workflow/discovery.test.ts +147 -0
  61. package/src/workflow/discovery.ts +5 -0
  62. package/src/workflow/fingerprint.test.ts +20 -4
  63. package/src/workflow/fingerprint.ts +8 -9
  64. package/src/workflow/index.ts +1 -6
  65. package/src/workflow/load.test.ts +5 -19
  66. package/src/workflow/load.ts +94 -10
  67. package/src/workflow-kit/review.test.ts +84 -0
  68. package/src/workflow-kit/review.ts +9 -6
  69. package/src/workflows/feature/review.test.ts +238 -12
  70. package/src/workflows/feature/review.ts +172 -31
  71. package/src/workflows/feature/steps.test.ts +12 -0
  72. package/src/workflows/feature/steps.ts +8 -1
  73. package/src/workflows/shared/access.test.ts +4 -2
  74. package/src/workflows/shared/access.ts +1 -0
  75. package/src/workflows/shared/options.test.ts +8 -0
  76. package/src/workflows/shared/options.ts +1 -0
  77. package/src/workflows/shared/steps.ts +2 -0
  78. package/src/workflows/shared/tail.ts +3 -11
  79. package/src/workflows/shared/text.test.ts +106 -0
  80. package/src/workflows/shared/text.ts +57 -1
  81. package/src/workflows/shared/vars.ts +1 -0
@@ -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, execFileSync } from "node:child_process"
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
@@ -26,10 +28,12 @@ import {
26
28
  hardResetTo,
27
29
  discardPending,
28
30
  updateRef,
31
+ fetchHistoryRefs,
29
32
  deleteRef,
30
33
  } from "../emit/index.js"
34
+ import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
31
35
  import { InMemRepo } from "./InMemRepo.js"
32
- import { gitTestLayer } from "./Layers.js"
36
+ import { gitTestLayer, type ScriptedCommand } from "./Layers.js"
33
37
 
34
38
  export interface GitTierCapabilities {
35
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. */
@@ -40,6 +44,8 @@ export interface GitTierCapabilities {
40
44
  readonly linkedWorktrees: boolean
41
45
  /** A cwd→home directory chain for config discovery — both tiers walk it through `Workspace`/`Host` now, so this is `true` for either. */
42
46
  readonly directoryChainConfig: boolean
47
+ /** Fetch from another repository — only the Live tier has a remote to reach. */
48
+ readonly remotes: boolean
43
49
  }
44
50
 
45
51
  export interface GitTierSeed {
@@ -58,6 +64,11 @@ export interface GitTierObserve {
58
64
  readonly refExists: (ref: string) => boolean
59
65
  readonly readWorktreeFile: (path: string) => string
60
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>
61
72
  }
62
73
 
63
74
  export interface GitTier {
@@ -73,6 +84,15 @@ export interface GitTier {
73
84
  readonly emptyRepo: () => GitTier
74
85
  readonly seed: GitTierSeed
75
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 }
76
96
  readonly dispose: () => void
77
97
  }
78
98
 
@@ -88,6 +108,18 @@ const makeLiveTier = (initialCommit = true): GitTier => {
88
108
  // symlink to `/private/tmp` — `git` reports the resolved path).
89
109
  const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-")))
90
110
  const gitExec = (...args: string[]) => gitExecIn(root, ...args)
111
+ // Distinct, increasing committer dates, so ref ordering by tip date is deterministic.
112
+ let tick = 0
113
+ const commitEnv = () => ({
114
+ ...process.env,
115
+ GIT_COMMITTER_DATE: new Date(Date.UTC(2024, 0, 1, 0, 0, ++tick)).toISOString(),
116
+ })
117
+ const gitCommit = (message: string, allowEmpty = false) =>
118
+ execFileSync("git", ["commit", ...(allowEmpty ? ["--allow-empty"] : []), "-m", message], {
119
+ cwd: root,
120
+ env: commitEnv(),
121
+ stdio: "pipe",
122
+ })
91
123
  gitExec("init")
92
124
  gitExec(`config user.email "test@test.com"`)
93
125
  gitExec(`config user.name "Test"`)
@@ -95,7 +127,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
95
127
  if (initialCommit) {
96
128
  writeFileSync(join(root, "readme.txt"), "hello")
97
129
  gitExec("add", "-A")
98
- gitExec(`commit -m "init: first commit"`)
130
+ gitCommit("init: first commit")
99
131
  }
100
132
 
101
133
  const provide = <A>(eff: Effect.Effect<A, Error, GitService | Narrator>): Promise<A> =>
@@ -128,6 +160,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
128
160
  commitHooks: true,
129
161
  linkedWorktrees: true,
130
162
  directoryChainConfig: true,
163
+ remotes: true,
131
164
  },
132
165
  provide,
133
166
  provideExit,
@@ -142,7 +175,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
142
175
  // --allow-empty: a caller passing `files: {}` (no prior staged
143
176
  // change either) means a deliberate empty commit — the state-entry
144
177
  // shape many test seeders rely on.
145
- gitExec(`commit --allow-empty -m "${message}"`)
178
+ gitCommit(message, true)
146
179
  },
147
180
  writeFile: (path, content) => {
148
181
  mkdirSync(dirname(join(root, path)), { recursive: true })
@@ -174,11 +207,51 @@ const makeLiveTier = (initialCommit = true): GitTier => {
174
207
  },
175
208
  readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
176
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 }
177
240
  },
178
241
  dispose: () => rmSync(root, { recursive: true, force: true }),
179
242
  }
180
243
  }
181
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
+
182
255
  const IN_MEM_ROOT = "/repo"
183
256
 
184
257
  const makeInMemTier = (initialCommit = true): GitTier => {
@@ -205,6 +278,7 @@ const makeInMemTier = (initialCommit = true): GitTier => {
205
278
  commitHooks: false,
206
279
  linkedWorktrees: false,
207
280
  directoryChainConfig: true,
281
+ remotes: false,
208
282
  },
209
283
  provide,
210
284
  provideExit,
@@ -239,6 +313,19 @@ const makeInMemTier = (initialCommit = true): GitTier => {
239
313
  return content
240
314
  },
241
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 }
242
329
  },
243
330
  dispose: () => {},
244
331
  }
@@ -259,6 +346,8 @@ const runGitExit = <A>(
259
346
  export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new Set([
260
347
  "lastCommitSubject",
261
348
  "lastCommitMessage",
349
+ "currentBranch",
350
+ "listRefs",
262
351
  "hasCommits",
263
352
  "resolveRef",
264
353
  "readRefOption",
@@ -321,6 +410,36 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
321
410
  t.dispose()
322
411
  })
323
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
+
324
443
  describe("resolveRef", () => {
325
444
  it("resolves HEAD to a 40-char hash", async () => {
326
445
  const hash = await runGit(t, (g) => g.resolveRef("HEAD"))
@@ -367,6 +486,73 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
367
486
  })
368
487
  })
369
488
 
489
+ describe("lastCommitMessage with a ref", () => {
490
+ it("reads the given ref's full message, not HEAD's", async () => {
491
+ t.seed.commit("feat: first\n\nGtd-Branch: one")
492
+ t.seed.commit("feat: second", { "extra.txt": "extra" })
493
+ const message = await runGit(t, (g) => g.lastCommitMessage("HEAD~1"))
494
+ expect(message).toBe("feat: first\n\nGtd-Branch: one")
495
+ })
496
+ })
497
+
498
+ describe("currentBranch", () => {
499
+ it("is the checked-out branch's name", async () => {
500
+ const branch = await runGit(t, (g) => g.currentBranch())
501
+ expect(Option.isSome(branch)).toBe(true)
502
+ })
503
+
504
+ it.skipIf(!capabilities.onDiskContent)(
505
+ "is none on a detached HEAD, never a failure",
506
+ async () => {
507
+ gitExecIn(t.root, "checkout", "--detach", "HEAD")
508
+ const branch = await runGit(t, (g) => g.currentBranch())
509
+ expect(Option.isNone(branch)).toBe(true)
510
+ },
511
+ )
512
+ })
513
+
514
+ describe("listRefs", () => {
515
+ it("is [] when no ref lives under the prefix", async () => {
516
+ const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
517
+ expect(refs).toEqual([])
518
+ })
519
+
520
+ it("lists {ref, hash, date} for refs under the prefix only, newest tip first", async () => {
521
+ const first = t.observe.resolveRef("HEAD")
522
+ t.seed.commit("feat: second", { "second.txt": "2" })
523
+ const second = t.observe.resolveRef("HEAD")
524
+ t.seed.updateRef("refs/gtd/history/old", first)
525
+ t.seed.updateRef("refs/gtd/history/new", second)
526
+ t.seed.updateRef("refs/gtd/other/elsewhere", second)
527
+ const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
528
+ expect(refs.map((r) => [r.ref, r.hash])).toEqual([
529
+ ["refs/gtd/history/new", second],
530
+ ["refs/gtd/history/old", first],
531
+ ])
532
+ for (const r of refs) expect(r.date.length).toBeGreaterThan(0)
533
+ })
534
+
535
+ it("reports the date as ISO 8601 strict", async () => {
536
+ t.seed.updateRef("refs/gtd/history/x", t.observe.resolveRef("HEAD"))
537
+ const [entry] = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
538
+ expect(entry?.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/)
539
+ })
540
+ })
541
+
542
+ describe("history reachable only from a retained ref", () => {
543
+ it("commitHistory and readFileAtRef read it after HEAD moved off those commits", async () => {
544
+ t.seed.commit("feat: second", { "kept.txt": "kept" })
545
+ const tip = t.observe.resolveRef("HEAD")
546
+ t.seed.updateRef("refs/gtd/history/tip", tip)
547
+ t.seed.mixedReset("HEAD~1")
548
+ expect(t.observe.resolveRef("HEAD")).not.toBe(tip)
549
+ const history = await runGit(t, (g) => g.commitHistory(undefined, "refs/gtd/history/tip"))
550
+ expect(history.map((c) => c.message)).toEqual(["init: first commit", "feat: second"])
551
+ const content = await runGit(t, (g) => g.readFileAtRef("refs/gtd/history/tip", "kept.txt"))
552
+ expect(content).toBe("kept")
553
+ })
554
+ })
555
+
370
556
  describe("hasCommits", () => {
371
557
  it("returns false in an empty repo", async () => {
372
558
  const empty = t.emptyRepo()
@@ -916,35 +1102,20 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
916
1102
  }
917
1103
 
918
1104
  /**
919
- * Runs a `GitScript.ts` builder's output through a REAL shell against `t.root`
920
- * — the same execution model the eventual driver uses (`bash -c <script>`),
921
- * so a builder that's syntactically fine but semantically wrong (a missing
922
- * `&&`, 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.
923
1108
  */
924
- const execScript = (t: GitTier, script: string): void => {
925
- execFileSync("bash", ["-c", script], { cwd: t.root, stdio: "pipe" })
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}`)
926
1112
  }
927
1113
 
928
- /** Names touched by HEAD's own commit — `git show`'s diff against its parent, not the whole tree. */
929
- const headTouchedPaths = (t: GitTier): ReadonlyArray<string> =>
930
- gitExecIn(t.root, "show", "--name-only", "--pretty=format:", "HEAD")
931
- .split("\n")
932
- .filter((line) => line.length > 0)
933
-
934
- const headSubject = (t: GitTier): string => gitExecIn(t.root, "log", "-1", "--pretty=%s")
935
-
936
- /**
937
- * The SAME 9 writer postconditions `runGitServiceContract` asserts against
938
- * `GitOperations`, asserted here against `GitScript.ts`'s bash builders
939
- * instead — driven through real `bash` against a real repo (`makeLiveTier`),
940
- * never the in-memory fake, since a fake root (`/repo`) has no shell to run
941
- * against.
942
- */
943
- export const runGitScriptContract = (): void => {
1114
+ export const runGitScriptContract = (makeTier: () => GitTier): void => {
944
1115
  let t: GitTier
945
1116
 
946
1117
  beforeEach(() => {
947
- t = makeLiveTier()
1118
+ t = makeTier()
948
1119
  })
949
1120
 
950
1121
  afterEach(() => {
@@ -955,18 +1126,18 @@ export const runGitScriptContract = (): void => {
955
1126
  it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
956
1127
  t.seed.writeFile("new.ts", "export const x = 1")
957
1128
  const headBefore = t.observe.resolveRef("HEAD")
958
- execScript(t, commitAll("gtd: building"))
1129
+ run(t, commitAll("gtd: building"))
959
1130
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
960
- expect(headSubject(t)).toBe("gtd: building")
961
- expect(headTouchedPaths(t)).toEqual(["new.ts"])
1131
+ expect(t.observe.headSubject()).toBe("gtd: building")
1132
+ expect(t.observe.headTouchedPaths()).toEqual(["new.ts"])
962
1133
  expect(t.observe.statusPorcelain().trim()).toBe("")
963
1134
  })
964
1135
 
965
1136
  it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
966
1137
  const headBefore = t.observe.resolveRef("HEAD")
967
- execScript(t, commitAll("gtd: grilled"))
1138
+ run(t, commitAll("gtd: grilled"))
968
1139
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
969
- expect(headSubject(t)).toBe("gtd: grilled")
1140
+ expect(t.observe.headSubject()).toBe("gtd: grilled")
970
1141
  })
971
1142
  })
972
1143
 
@@ -975,16 +1146,16 @@ export const runGitScriptContract = (): void => {
975
1146
  t.seed.writeFile("a.txt", "a")
976
1147
  t.seed.stageAll()
977
1148
  t.seed.writeFile("b.txt", "b")
978
- execScript(t, commitAsIs("feat: squash"))
979
- expect(headSubject(t)).toBe("feat: squash")
980
- expect(headTouchedPaths(t)).toEqual(["a.txt"])
1149
+ run(t, commitAsIs("feat: squash"))
1150
+ expect(t.observe.headSubject()).toBe("feat: squash")
1151
+ expect(t.observe.headTouchedPaths()).toEqual(["a.txt"])
981
1152
  })
982
1153
 
983
1154
  it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
984
1155
  const headBefore = t.observe.resolveRef("HEAD")
985
- execScript(t, commitAsIs("gtd: empty squash"))
1156
+ run(t, commitAsIs("gtd: empty squash"))
986
1157
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
987
- expect(headSubject(t)).toBe("gtd: empty squash")
1158
+ expect(t.observe.headSubject()).toBe("gtd: empty squash")
988
1159
  })
989
1160
  })
990
1161
 
@@ -992,7 +1163,7 @@ export const runGitScriptContract = (): void => {
992
1163
  it("moves HEAD back but keeps worktree changes from the second commit", () => {
993
1164
  const firstHash = t.observe.resolveRef("HEAD")
994
1165
  t.seed.commit("feat: second", { "second.txt": "second content" })
995
- execScript(t, softResetTo(firstHash))
1166
+ run(t, softResetTo(firstHash))
996
1167
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
997
1168
  expect(t.observe.statusPorcelain()).toContain("second.txt")
998
1169
  })
@@ -1002,7 +1173,7 @@ export const runGitScriptContract = (): void => {
1002
1173
  it("moves HEAD, index, and working tree content back to the target ref", () => {
1003
1174
  const firstHash = t.observe.resolveRef("HEAD")
1004
1175
  t.seed.commit("feat: second", { "readme.txt": "modified content" })
1005
- execScript(t, hardResetTo(firstHash))
1176
+ run(t, hardResetTo(firstHash))
1006
1177
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
1007
1178
  expect(t.observe.statusPorcelain()).toBe("")
1008
1179
  expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello")
@@ -1013,7 +1184,7 @@ export const runGitScriptContract = (): void => {
1013
1184
  it("moves HEAD and index, leaving the working tree untouched", () => {
1014
1185
  const firstHash = t.observe.resolveRef("HEAD")
1015
1186
  t.seed.commit("feat: second", { "second.txt": "second content" })
1016
- execScript(t, mixedResetTo(firstHash))
1187
+ run(t, mixedResetTo(firstHash))
1017
1188
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
1018
1189
  expect(t.observe.statusPorcelain()).toContain("second.txt")
1019
1190
  })
@@ -1023,7 +1194,7 @@ export const runGitScriptContract = (): void => {
1023
1194
  it("drops both tracked modifications and untracked files", () => {
1024
1195
  t.seed.writeFile("readme.txt", "modified")
1025
1196
  t.seed.writeFile("untracked.txt", "new")
1026
- execScript(t, discardPending())
1197
+ run(t, discardPending())
1027
1198
  expect(t.observe.statusPorcelain()).toBe("")
1028
1199
  })
1029
1200
  })
@@ -1031,22 +1202,73 @@ export const runGitScriptContract = (): void => {
1031
1202
  describe("updateRef / deleteRef", () => {
1032
1203
  it("updateRef points a ref at a commit", () => {
1033
1204
  const head = t.observe.resolveRef("HEAD")
1034
- execScript(t, updateRef("refs/gtd/marker", head))
1205
+ run(t, updateRef("refs/gtd/marker", head))
1035
1206
  expect(t.observe.refExists("refs/gtd/marker")).toBe(true)
1036
1207
  expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head)
1037
1208
  })
1038
1209
 
1210
+ it("updateRef with HEAD points the ref at the commit just made", () => {
1211
+ run(t, commitAll("feat: just made"))
1212
+ run(t, updateRef("refs/gtd/history/x", "HEAD"))
1213
+ expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"))
1214
+ })
1215
+
1039
1216
  it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
1040
1217
  const head = t.observe.resolveRef("HEAD")
1041
- execScript(t, updateRef("refs/gtd/marker", head))
1042
- execScript(t, deleteRef("refs/gtd/marker"))
1043
- execScript(t, deleteRef("refs/gtd/marker"))
1218
+ run(t, updateRef("refs/gtd/marker", head))
1219
+ run(t, deleteRef("refs/gtd/marker"))
1220
+ run(t, deleteRef("refs/gtd/marker"))
1044
1221
  expect(t.observe.refExists("refs/gtd/marker")).toBe(false)
1045
1222
  })
1046
1223
 
1047
1224
  it("deleteRef tolerates a ref that was never created", () => {
1048
- expect(() => execScript(t, deleteRef("refs/gtd/never-created"))).not.toThrow()
1225
+ expect(() => run(t, deleteRef("refs/gtd/never-created"))).not.toThrow()
1049
1226
  expect(t.observe.refExists("refs/gtd/never-created")).toBe(false)
1050
1227
  })
1051
1228
  })
1229
+
1230
+ describe("fetchHistoryRefs", () => {
1231
+ it("pulls every refs/gtd/history/* from a bare remote and prints the count", (ctx) => {
1232
+ if (!t.capabilities.remotes) ctx.skip()
1233
+ const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
1234
+ try {
1235
+ gitExecIn(bare, "init", "-q", "--bare")
1236
+ gitExecIn(t.root, "remote", "add", "origin", bare)
1237
+ const head = t.observe.resolveRef("HEAD")
1238
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/one`)
1239
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/two`)
1240
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/other/skip`)
1241
+ const out = execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
1242
+ cwd: t.root,
1243
+ encoding: "utf8",
1244
+ })
1245
+ expect(t.observe.refExists("refs/gtd/history/one")).toBe(true)
1246
+ expect(t.observe.refExists("refs/gtd/history/two")).toBe(true)
1247
+ expect(t.observe.refExists("refs/gtd/other/skip")).toBe(false)
1248
+ expect(out.trim()).toBe("2 history refs arrived")
1249
+ } finally {
1250
+ rmSync(bare, { recursive: true, force: true })
1251
+ }
1252
+ })
1253
+
1254
+ it("fails loudly when a local history ref conflicts with the remote's", (ctx) => {
1255
+ if (!t.capabilities.remotes) ctx.skip()
1256
+ const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
1257
+ try {
1258
+ gitExecIn(bare, "init", "-q", "--bare")
1259
+ gitExecIn(t.root, "remote", "add", "origin", bare)
1260
+ const first = t.observe.resolveRef("HEAD")
1261
+ t.seed.commit("feat: second", { "second.txt": "x" })
1262
+ const second = t.observe.resolveRef("HEAD")
1263
+ gitExecIn(t.root, "push", "-q", "origin", `${first}:refs/gtd/history/one`)
1264
+ t.seed.updateRef("refs/gtd/history/one", second)
1265
+ expect(() =>
1266
+ execFileSync("bash", ["-c", fetchHistoryRefs("origin")], { cwd: t.root, stdio: "pipe" }),
1267
+ ).toThrow()
1268
+ expect(t.observe.resolveRef("refs/gtd/history/one")).toBe(second)
1269
+ } finally {
1270
+ rmSync(bare, { recursive: true, force: true })
1271
+ }
1272
+ })
1273
+ })
1052
1274
  }
@@ -9,6 +9,8 @@ interface Commit {
9
9
  message: string
10
10
  files: Map<string, string> // full tree snapshot (path → UTF-8 content)
11
11
  parent: string | null
12
+ /** Creation order — the twin's stand-in for a committer date. */
13
+ seq: number
12
14
  }
13
15
 
14
16
  function sha1(input: string): string {
@@ -35,6 +37,7 @@ export class InMemRepo {
35
37
  private currentBranch: string = "main"
36
38
  private worktree: Map<string, string> = new Map()
37
39
  private index: Map<string, string> = new Map()
40
+ private nextSeq = 1
38
41
 
39
42
  private getCommit(hash: string): Commit | null {
40
43
  return this.commits.get(hash) ?? null
@@ -112,10 +115,11 @@ export class InMemRepo {
112
115
  return this.commits.has(ref) ? ref : null
113
116
  }
114
117
 
115
- const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+))?$/.exec(ref)
118
+ const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref)
116
119
  if (tildeMatch) {
117
120
  const base = tildeMatch[1]!
118
- const steps = tildeMatch[3] !== undefined ? parseInt(tildeMatch[3], 10) : 0
121
+ const steps =
122
+ tildeMatch[3] !== undefined ? parseInt(tildeMatch[3], 10) : tildeMatch[2] === "^" ? 1 : 0
119
123
  let cur: string | null = base === "HEAD" ? this.head : this.commits.has(base) ? base : null
120
124
  for (let i = 0; i < steps; i++) {
121
125
  if (cur === null) return null
@@ -137,10 +141,27 @@ export class InMemRepo {
137
141
  return c.message.split("\n")[0] ?? null
138
142
  }
139
143
 
140
- lastCommitMessage(): string | null {
141
- const c = this.headCommit()
142
- if (!c) return null
143
- return c.message
144
+ lastCommitMessage(ref?: string): string | null {
145
+ const hash = ref === undefined ? this.head : this.resolveRef(ref)
146
+ const c = hash === null ? null : this.getCommit(hash)
147
+ return c === null ? null : c.message
148
+ }
149
+
150
+ branchName(): string {
151
+ return this.currentBranch
152
+ }
153
+
154
+ /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
155
+ listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
156
+ return [...this.refs.entries()]
157
+ .filter(([ref]) => ref.startsWith(prefix))
158
+ .map(([ref, hash]) => ({ ref, hash, seq: this.getCommit(hash)?.seq ?? 0 }))
159
+ .sort((a, b) => b.seq - a.seq)
160
+ .map(({ ref, hash, seq }) => ({
161
+ ref,
162
+ hash,
163
+ date: new Date(seq * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
164
+ }))
144
165
  }
145
166
 
146
167
  /**
@@ -277,6 +298,38 @@ export class InMemRepo {
277
298
  .sort()
278
299
  }
279
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
+
280
333
  /** `git add -A` — the index becomes exactly the current worktree. */
281
334
  stageAll(): void {
282
335
  this.index = new Map(this.worktree)
@@ -287,7 +340,7 @@ export class InMemRepo {
287
340
  const tree = new Map(this.index)
288
341
  const parent = this.head
289
342
  const hash = makeHash(message, parent, tree)
290
- const commit: Commit = { hash, message, files: new Map(tree), parent }
343
+ const commit: Commit = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
291
344
  this.commits.set(hash, commit)
292
345
  this.head = hash
293
346
  this.branches.set(this.currentBranch, hash)
@@ -307,7 +360,7 @@ export class InMemRepo {
307
360
  const parent = this.head
308
361
  const hash = makeHash(message, parent, tree)
309
362
 
310
- const commit: Commit = { hash, message, files: new Map(tree), parent }
363
+ const commit: Commit = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
311
364
  this.commits.set(hash, commit)
312
365
  this.head = hash
313
366
  this.branches.set(this.currentBranch, hash)
@@ -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
- | { readonly kind: "rewrite"; readonly file: string; readonly content: string }
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>>
@@ -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
- for (const line of script.split("\n")) runLine(repo, line.trim())
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]),