@pmelab/gtd 24.0.0 → 24.2.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 (203) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -13
  3. package/claude/hooks/access.ts +26 -1
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  12. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  13. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  14. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  15. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  16. package/dist/flows.mjs +525 -0
  17. package/dist/gtd.bundle.mjs +43339 -55575
  18. package/dist/steering.mjs +2 -0
  19. package/dist/testkit.mjs +13006 -0
  20. package/dist/wire.mjs +2 -0
  21. package/dist/workflow-kit.mjs +352 -0
  22. package/dist/workflows/feature.mjs +315 -0
  23. package/dist/workflows/fix.mjs +40 -0
  24. package/dist/workflows/review.mjs +25 -0
  25. package/dist/workflows.mjs +8 -6
  26. package/package.json +69 -8
  27. package/src/cli/Cli.test.ts +1329 -0
  28. package/src/cli/Cli.ts +1348 -0
  29. package/src/cli/Program.test.ts +2262 -0
  30. package/src/cli/Program.ts +1331 -0
  31. package/src/cli/ToolCommands.ts +43 -0
  32. package/src/cli/index.ts +14 -0
  33. package/src/edge/Edge.test.ts +1016 -0
  34. package/src/edge/Edge.ts +1225 -0
  35. package/src/edge/index.ts +16 -0
  36. package/src/emit/Emit.test.ts +425 -0
  37. package/src/emit/Emit.ts +139 -0
  38. package/src/emit/GitScript.test.ts +260 -0
  39. package/src/emit/GitScript.ts +131 -0
  40. package/src/emit/ModeContradiction.test.ts +120 -0
  41. package/src/emit/ModeContradiction.ts +66 -0
  42. package/src/emit/OutcomeScript.test.ts +238 -0
  43. package/src/emit/OutcomeScript.ts +73 -0
  44. package/src/emit/SteeringMode.test.ts +274 -0
  45. package/src/emit/SteeringMode.ts +256 -0
  46. package/src/emit/index.ts +40 -0
  47. package/src/install/ConfigScaffold.ts +19 -0
  48. package/src/install/Install.test.ts +426 -0
  49. package/src/install/Install.ts +634 -0
  50. package/src/install/Version.ts +27 -0
  51. package/src/install/index.ts +3 -0
  52. package/src/platform/CommandRunner.ts +74 -0
  53. package/src/platform/Commentary.test.ts +126 -0
  54. package/src/platform/Commentary.ts +92 -0
  55. package/src/platform/Git.test.ts +50 -0
  56. package/src/platform/Git.ts +552 -0
  57. package/src/platform/Host.ts +42 -0
  58. package/src/platform/RetainedHistory.test.ts +65 -0
  59. package/src/platform/RetainedHistory.ts +34 -0
  60. package/src/platform/Workspace.test.ts +669 -0
  61. package/src/platform/Workspace.ts +631 -0
  62. package/src/platform/WorktreeState.test.ts +162 -0
  63. package/src/platform/WorktreeState.ts +50 -0
  64. package/src/platform/index.ts +22 -0
  65. package/src/replay/Access.test.ts +53 -0
  66. package/src/replay/Access.ts +46 -0
  67. package/src/replay/Format.test.ts +20 -0
  68. package/src/replay/Format.ts +14 -0
  69. package/src/replay/Replay.test.ts +887 -0
  70. package/src/replay/Replay.ts +669 -0
  71. package/src/replay/Trailers.test.ts +117 -0
  72. package/src/replay/Trailers.ts +213 -0
  73. package/src/replay/Tree.test.ts +25 -0
  74. package/src/replay/Tree.ts +45 -0
  75. package/src/replay/index.ts +11 -0
  76. package/src/steering/Blocks.fixture.ts +6 -0
  77. package/src/steering/Blocks.test.ts +167 -0
  78. package/src/steering/Blocks.ts +321 -0
  79. package/src/steering/CodeThreads.test.ts +127 -0
  80. package/src/steering/CodeThreads.ts +197 -0
  81. package/src/steering/Edits.test.ts +19 -0
  82. package/src/steering/Edits.ts +20 -0
  83. package/src/steering/Eol.test.ts +16 -0
  84. package/src/steering/Eol.ts +2 -0
  85. package/src/steering/FORMATS.md +58 -0
  86. package/src/steering/Footnotes.test.ts +977 -0
  87. package/src/steering/Footnotes.ts +661 -0
  88. package/src/steering/Inline.fixture.ts +7 -0
  89. package/src/steering/Inline.test.ts +227 -0
  90. package/src/steering/Inline.ts +195 -0
  91. package/src/steering/MarkdownTree.test.ts +247 -0
  92. package/src/steering/MarkdownTree.ts +260 -0
  93. package/src/steering/Outline.ts +19 -0
  94. package/src/steering/SteeringFormat.test.ts +91 -0
  95. package/src/steering/SteeringFormat.ts +267 -0
  96. package/src/steering/SteeringFormats.test.ts +163 -0
  97. package/src/steering/Threads.ts +163 -0
  98. package/src/steering/contract.test.ts +264 -0
  99. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  100. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  101. package/src/steering/fixtures/free-form/notes.md +12 -0
  102. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  103. package/src/steering/fixtures/qa/answered.md +23 -0
  104. package/src/steering/fixtures/qa/answered.read.json +119 -0
  105. package/src/steering/fixtures/qa/requirements.md +160 -0
  106. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  107. package/src/steering/fixtures/review/review.md +76 -0
  108. package/src/steering/fixtures/review/review.read.json +237 -0
  109. package/src/steering/fixtures/review/ticked.md +23 -0
  110. package/src/steering/fixtures/review/ticked.read.json +99 -0
  111. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  112. package/src/steering/fixtures/review-notes/noted.md +19 -0
  113. package/src/steering/freeform.test.ts +311 -0
  114. package/src/steering/freeform.ts +148 -0
  115. package/src/steering/index.test.ts +62 -0
  116. package/src/steering/index.ts +140 -0
  117. package/src/steering/qa.test.ts +2160 -0
  118. package/src/steering/qa.ts +1104 -0
  119. package/src/steering/review.test.ts +1829 -0
  120. package/src/steering/review.ts +1180 -0
  121. package/src/step/LandStep.ts +31 -0
  122. package/src/step/RepoSnapshot.ts +29 -0
  123. package/src/step/accessRefusal.test.ts +58 -0
  124. package/src/step/accessRefusal.ts +26 -0
  125. package/src/step/index.ts +5 -0
  126. package/src/step/planStart.test.ts +202 -0
  127. package/src/step/planStart.ts +167 -0
  128. package/src/step/planStep.test.ts +104 -0
  129. package/src/step/planStep.ts +89 -0
  130. package/src/step/snapshot.fixture.ts +28 -0
  131. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  132. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  133. package/src/testkit/FakeGitOperations.ts +95 -0
  134. package/src/testkit/GitTiers.test.ts +11 -0
  135. package/src/testkit/GitTiers.ts +1052 -0
  136. package/src/testkit/InMemRepo.ts +467 -0
  137. package/src/testkit/Layers.test.ts +58 -0
  138. package/src/testkit/Layers.ts +241 -0
  139. package/src/testkit/StrictGitOperations.ts +31 -0
  140. package/src/testkit/TestRepo.test.ts +119 -0
  141. package/src/testkit/TestRepo.ts +274 -0
  142. package/src/testkit/cliIo.ts +36 -0
  143. package/src/testkit/index.ts +9 -0
  144. package/src/testkit/steps.ts +177 -0
  145. package/src/testkit/testkit.feature +65 -0
  146. package/src/wire/BeatDocument.test.ts +419 -0
  147. package/src/wire/BeatDocument.ts +114 -0
  148. package/src/wire/BeatPlain.test.ts +113 -0
  149. package/src/wire/BeatPlain.ts +127 -0
  150. package/src/wire/BeatStatus.test.ts +59 -0
  151. package/src/wire/BeatStatus.ts +81 -0
  152. package/src/wire/Demand.test.ts +142 -0
  153. package/src/wire/Demand.ts +108 -0
  154. package/src/wire/ExitCodes.test.ts +21 -0
  155. package/src/wire/ExitCodes.ts +16 -0
  156. package/src/wire/Glob.ts +29 -0
  157. package/src/wire/Judge.ts +22 -0
  158. package/src/wire/Land.test.ts +120 -0
  159. package/src/wire/Land.ts +75 -0
  160. package/src/wire/Select.test.ts +148 -0
  161. package/src/wire/Select.ts +78 -0
  162. package/src/wire/Sessions.test.ts +59 -0
  163. package/src/wire/Sessions.ts +39 -0
  164. package/src/wire/WireSchema.test.ts +162 -0
  165. package/src/wire/constants.ts +22 -0
  166. package/src/wire/golden/1/beat-capture.json +34 -0
  167. package/src/wire/golden/1/beat-message.json +34 -0
  168. package/src/wire/golden/1/beat-prompt.json +44 -0
  169. package/src/wire/golden/1/beat-script.json +34 -0
  170. package/src/wire/golden/1/beat-stalled.json +34 -0
  171. package/src/wire/golden/1/judge.json +14 -0
  172. package/src/wire/golden/1/land-noop.json +10 -0
  173. package/src/wire/golden/1/land.json +10 -0
  174. package/src/wire/index.ts +12 -0
  175. package/src/wire/types.ts +46 -0
  176. package/src/workflow/Diagnostic.test.ts +93 -0
  177. package/src/workflow/Diagnostic.ts +54 -0
  178. package/src/workflow/SteeringFormats.test.ts +27 -0
  179. package/src/workflow/SteeringFormats.ts +6 -0
  180. package/src/workflow/Workflow.ts +77 -0
  181. package/src/workflow/compile.test.ts +53 -0
  182. package/src/workflow/compile.ts +56 -0
  183. package/src/workflow/discovery.ts +122 -0
  184. package/src/workflow/fingerprint.test.ts +52 -0
  185. package/src/workflow/fingerprint.ts +53 -0
  186. package/src/workflow/index.ts +33 -0
  187. package/src/workflow/load.test.ts +1017 -0
  188. package/src/workflow/load.ts +836 -0
  189. package/src/workflow/skills.test.ts +109 -0
  190. package/src/workflow/skills.ts +90 -0
  191. package/src/workflow/vars.ts +33 -0
  192. package/src/workflows/feature/feature.test.ts +27 -33
  193. package/src/workflows/feature/index.ts +10 -0
  194. package/src/workflows/fix/fix.feature +80 -0
  195. package/src/workflows/fix/fix.test.ts +58 -78
  196. package/src/workflows/fix/index.ts +6 -0
  197. package/src/workflows/index.ts +2 -10
  198. package/src/workflows/review/index.ts +6 -0
  199. package/src/workflows/review/review.test.ts +85 -29
  200. package/src/workflows/review/review.ts +3 -1
  201. package/src/workflows/shared/config.fixture.ts +12 -0
  202. package/src/workflows/shared/reserved.ts +11 -0
  203. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,1052 @@
1
+ import {
2
+ mkdtempSync,
3
+ mkdirSync,
4
+ rmSync,
5
+ writeFileSync,
6
+ existsSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ } from "node:fs"
10
+ import { join, dirname, basename } from "node:path"
11
+ import { tmpdir } from "node:os"
12
+ import { execSync, execFileSync } from "node:child_process"
13
+ // Imported directly (not just from *.test.ts) — a leak of this module into
14
+ // the shipped CLI bundle would try to inline `vitest` and fail the build
15
+ // loudly (`deps.alwaysBundle` in `tsdown.config.ts`), a guard alongside the
16
+ // `TEST_DOUBLE_SENTINEL` check.
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest"
18
+ import { Effect, Exit, Option } from "effect"
19
+ import { NodeContext } from "@effect/platform-node"
20
+ import { Narrator, GitService, Host, type GitOperations } from "../platform/index.js"
21
+ import {
22
+ commitAll,
23
+ commitAsIs,
24
+ softResetTo,
25
+ mixedResetTo,
26
+ hardResetTo,
27
+ discardPending,
28
+ updateRef,
29
+ deleteRef,
30
+ } from "../emit/index.js"
31
+ import { InMemRepo } from "./InMemRepo.js"
32
+ import { gitTestLayer } from "./Layers.js"
33
+
34
+ export interface GitTierCapabilities {
35
+ /** Assert file BYTES on disk, not just index/HEAD state (`hardResetTo`) — only the Live tier has a real working-tree file to read back. */
36
+ readonly onDiskContent: boolean
37
+ /** Install a real `.git/hooks/pre-commit` and prove the `--no-verify` retry — only the Live tier has a hooks directory. */
38
+ readonly commitHooks: boolean
39
+ /** `git worktree add` — sibling linked worktrees sharing one `.git`; the fake models a single worktree only. */
40
+ readonly linkedWorktrees: boolean
41
+ /** A cwd→home directory chain for config discovery — both tiers walk it through `Workspace`/`Host` now, so this is `true` for either. */
42
+ readonly directoryChainConfig: boolean
43
+ }
44
+
45
+ export interface GitTierSeed {
46
+ readonly commit: (message: string, files?: Record<string, string>) => void
47
+ readonly writeFile: (path: string, content: string) => void
48
+ readonly deleteFile: (path: string) => void
49
+ readonly commitDeletion: (path: string, message: string) => void
50
+ readonly stageAll: () => void
51
+ readonly updateRef: (ref: string, hash: string) => void
52
+ readonly mixedReset: (ref: string) => void
53
+ }
54
+
55
+ export interface GitTierObserve {
56
+ readonly resolveRef: (ref: string) => string
57
+ readonly statusPorcelain: () => string
58
+ readonly refExists: (ref: string) => boolean
59
+ readonly readWorktreeFile: (path: string) => string
60
+ readonly existsPath: (path: string) => boolean
61
+ }
62
+
63
+ export interface GitTier {
64
+ readonly name: "Live" | "InMemory"
65
+ readonly root: string
66
+ readonly capabilities: GitTierCapabilities
67
+ /** Provide `GitService` (retry-wrapped, exactly as production wires it) + a no-op `Narrator`. */
68
+ readonly provide: <A>(eff: Effect.Effect<A, Error, GitService | Narrator>) => Promise<A>
69
+ readonly provideExit: <A>(
70
+ eff: Effect.Effect<A, Error, GitService | Narrator>,
71
+ ) => Promise<Exit.Exit<A, Error>>
72
+ /** A second, commit-less repo of the same tier — for the empty-repo edge cases (`commitHistory`, `hasCommits`). */
73
+ readonly emptyRepo: () => GitTier
74
+ readonly seed: GitTierSeed
75
+ readonly observe: GitTierObserve
76
+ readonly dispose: () => void
77
+ }
78
+
79
+ /** No-op — these tests assert on git/config behavior, not narration. */
80
+ const noopNarratorLayer = Narrator.layer(() => {}, false)
81
+
82
+ const gitExecIn = (dir: string, ...args: string[]): string =>
83
+ execSync(`git ${args.join(" ")}`, { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim()
84
+
85
+ const makeLiveTier = (initialCommit = true): GitTier => {
86
+ // realpath'd once here so `t.root` matches `git rev-parse --show-toplevel`'s
87
+ // OWN symlink resolution byte-for-byte (macOS's tmpdir is under `/tmp`, a
88
+ // symlink to `/private/tmp` — `git` reports the resolved path).
89
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-")))
90
+ const gitExec = (...args: string[]) => gitExecIn(root, ...args)
91
+ gitExec("init")
92
+ gitExec(`config user.email "test@test.com"`)
93
+ gitExec(`config user.name "Test"`)
94
+ gitExec("config", "commit.gpgsign", "false")
95
+ if (initialCommit) {
96
+ writeFileSync(join(root, "readme.txt"), "hello")
97
+ gitExec("add", "-A")
98
+ gitExec(`commit -m "init: first commit"`)
99
+ }
100
+
101
+ const provide = <A>(eff: Effect.Effect<A, Error, GitService | Narrator>): Promise<A> =>
102
+ Effect.runPromise(
103
+ eff.pipe(
104
+ Effect.provide(GitService.Live),
105
+ Effect.provide(Host.layer({ root, home: root, env: {} })),
106
+ Effect.provide(NodeContext.layer),
107
+ Effect.provide(noopNarratorLayer),
108
+ ),
109
+ )
110
+
111
+ const provideExit = <A>(
112
+ eff: Effect.Effect<A, Error, GitService | Narrator>,
113
+ ): Promise<Exit.Exit<A, Error>> =>
114
+ Effect.runPromiseExit(
115
+ eff.pipe(
116
+ Effect.provide(GitService.Live),
117
+ Effect.provide(Host.layer({ root, home: root, env: {} })),
118
+ Effect.provide(NodeContext.layer),
119
+ Effect.provide(noopNarratorLayer),
120
+ ),
121
+ )
122
+
123
+ return {
124
+ name: "Live",
125
+ root,
126
+ capabilities: {
127
+ onDiskContent: true,
128
+ commitHooks: true,
129
+ linkedWorktrees: true,
130
+ directoryChainConfig: true,
131
+ },
132
+ provide,
133
+ provideExit,
134
+ emptyRepo: () => makeLiveTier(false),
135
+ seed: {
136
+ commit: (message, files = { "file.txt": message }) => {
137
+ for (const [path, content] of Object.entries(files)) {
138
+ mkdirSync(dirname(join(root, path)), { recursive: true })
139
+ writeFileSync(join(root, path), content)
140
+ }
141
+ gitExec("add", "-A")
142
+ // --allow-empty: a caller passing `files: {}` (no prior staged
143
+ // change either) means a deliberate empty commit — the state-entry
144
+ // shape many test seeders rely on.
145
+ gitExec(`commit --allow-empty -m "${message}"`)
146
+ },
147
+ writeFile: (path, content) => {
148
+ mkdirSync(dirname(join(root, path)), { recursive: true })
149
+ writeFileSync(join(root, path), content)
150
+ },
151
+ // A plain unlink, NOT `git rm` — the index is left exactly as it stood,
152
+ // the only way to model a reviewer deleting an untracked file (`git rm`
153
+ // refuses an untracked pathspec). Callers that want the deletion staged
154
+ // say `stageAll()`.
155
+ deleteFile: (path) => rmSync(join(root, path), { force: true }),
156
+ commitDeletion: (path, message) => {
157
+ gitExec("rm", path)
158
+ gitExec(`commit -m "${message}"`)
159
+ },
160
+ stageAll: () => gitExec("add", "-A"),
161
+ updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
162
+ mixedReset: (ref) => gitExec("reset", "--mixed", ref),
163
+ },
164
+ observe: {
165
+ resolveRef: (ref) => gitExec("rev-parse", ref),
166
+ statusPorcelain: () => gitExec("status", "--porcelain", "-uall"),
167
+ refExists: (ref) => {
168
+ try {
169
+ gitExec("rev-parse", "--verify", "--quiet", ref)
170
+ return true
171
+ } catch {
172
+ return false
173
+ }
174
+ },
175
+ readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
176
+ existsPath: (path) => existsSync(join(root, path)),
177
+ },
178
+ dispose: () => rmSync(root, { recursive: true, force: true }),
179
+ }
180
+ }
181
+
182
+ const IN_MEM_ROOT = "/repo"
183
+
184
+ const makeInMemTier = (initialCommit = true): GitTier => {
185
+ const repo = new InMemRepo()
186
+ if (initialCommit) {
187
+ repo.writeFile("readme.txt", "hello")
188
+ repo.commitAllWithPrefix("init: first commit")
189
+ }
190
+ const gitLayer = gitTestLayer(repo, IN_MEM_ROOT)
191
+
192
+ const provide = <A>(eff: Effect.Effect<A, Error, GitService | Narrator>): Promise<A> =>
193
+ Effect.runPromise(eff.pipe(Effect.provide(gitLayer), Effect.provide(noopNarratorLayer)))
194
+
195
+ const provideExit = <A>(
196
+ eff: Effect.Effect<A, Error, GitService | Narrator>,
197
+ ): Promise<Exit.Exit<A, Error>> =>
198
+ Effect.runPromiseExit(eff.pipe(Effect.provide(gitLayer), Effect.provide(noopNarratorLayer)))
199
+
200
+ return {
201
+ name: "InMemory",
202
+ root: IN_MEM_ROOT,
203
+ capabilities: {
204
+ onDiskContent: false,
205
+ commitHooks: false,
206
+ linkedWorktrees: false,
207
+ directoryChainConfig: true,
208
+ },
209
+ provide,
210
+ provideExit,
211
+ emptyRepo: () => makeInMemTier(false),
212
+ seed: {
213
+ commit: (message, files = { "file.txt": message }) => {
214
+ for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
215
+ repo.commitAllWithPrefix(message)
216
+ },
217
+ writeFile: (path, content) => repo.writeFile(path, content),
218
+ deleteFile: (path) => repo.deleteFile(path),
219
+ commitDeletion: (path, message) => {
220
+ repo.deleteFile(path)
221
+ repo.commitAllWithPrefix(message)
222
+ },
223
+ stageAll: () => repo.stageAll(),
224
+ updateRef: (ref, hash) => repo.updateRef(ref, hash),
225
+ mixedReset: (ref) => repo.mixedResetTo(ref),
226
+ },
227
+ observe: {
228
+ resolveRef: (ref) => {
229
+ const hash = repo.resolveRef(ref)
230
+ if (hash === null) throw new Error(`Cannot resolve ref: ${ref}`)
231
+ return hash
232
+ },
233
+ statusPorcelain: () => repo.statusPorcelain(),
234
+ refExists: (ref) => repo.resolveRef(ref) !== null,
235
+ readWorktreeFile: (path) => {
236
+ const content = repo.readFile(path)
237
+ if (content === undefined)
238
+ throw new Error(`ENOENT: no such file or directory, open '${path}'`)
239
+ return content
240
+ },
241
+ existsPath: (path) => repo.hasPath(path),
242
+ },
243
+ dispose: () => {},
244
+ }
245
+ }
246
+
247
+ /** Both tiers, freshly constructed per call — `beforeEach(() => { t = makeTier() })` in every consuming test file. */
248
+ export const gitTiers: ReadonlyArray<() => GitTier> = [makeLiveTier, makeInMemTier]
249
+
250
+ const runGit = <A>(t: GitTier, f: (g: GitOperations) => Effect.Effect<A, Error>): Promise<A> =>
251
+ t.provide(Effect.flatMap(GitService, f))
252
+
253
+ const runGitExit = <A>(
254
+ t: GitTier,
255
+ f: (g: GitOperations) => Effect.Effect<A, Error>,
256
+ ): Promise<Exit.Exit<A, Error>> => t.provideExit(Effect.flatMap(GitService, f))
257
+
258
+ /** Every `GitOperations` method the contract below exercises — asserted (in `GitTiers.test.ts`) to equal `fakeGitOperations`'s own key set, so a new port method reds until it is listed here. Listing it is not writing the contract case: this fires on a forgotten method, never on an unexercised one. */
259
+ export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new Set([
260
+ "lastCommitSubject",
261
+ "lastCommitMessage",
262
+ "hasCommits",
263
+ "resolveRef",
264
+ "readRefOption",
265
+ "isAncestor",
266
+ "mergeBase",
267
+ "defaultBranch",
268
+ "topLevel",
269
+ "gitDir",
270
+ "gitCommonDir",
271
+ "commitHistory",
272
+ "subjectHistory",
273
+ "readFileAtRef",
274
+ "changedPaths",
275
+ "commitAllWithPrefix",
276
+ "softResetTo",
277
+ "commitAsIs",
278
+ "discardPending",
279
+ "updateRef",
280
+ "deleteRef",
281
+ "mixedResetTo",
282
+ "hardResetTo",
283
+ ])
284
+
285
+ /**
286
+ * Filenames real `git diff --name-status` would mangle (or misparse) without
287
+ * `-z`: an embedded newline splits a `\n`-joined parser into two bogus
288
+ * entries, a double quote and non-ASCII byte trigger git's C-quoting of the
289
+ * path. `InMemRepo` needs none of this — it returns paths verbatim as plain
290
+ * JS strings — which is exactly why running these through both tiers is the
291
+ * point: only the Live tier's parser can regress here.
292
+ */
293
+ const PATHOLOGICAL_PATHS: ReadonlyArray<{ label: string; path: string }> = [
294
+ { label: "an embedded newline", path: "weird\nname.txt" },
295
+ { label: "a double quote", path: 'weird"name.txt' },
296
+ { label: "a non-ASCII UTF-8 byte", path: "café.txt" },
297
+ ]
298
+
299
+ /**
300
+ * Exercise all 21 `GitOperations` methods identically against `makeTier()` —
301
+ * called once per tier by `src/platform/Git.test.ts`. A capability-gated group
302
+ * (`t.capabilities.X`) is skipped, not faked, on a tier that can't support it.
303
+ */
304
+ // fallow-ignore-next-line complexity
305
+ export const runGitServiceContract = (makeTier: () => GitTier): void => {
306
+ // Capabilities are static per tier type — probed once so `it.skipIf(...)`
307
+ // predicates below can read them at DESCRIBE time (`t` itself isn't
308
+ // assigned until the first `beforeEach` runs, which is too late for a
309
+ // skipIf predicate evaluated while the suite is still being built).
310
+ const probe = makeTier()
311
+ const capabilities = probe.capabilities
312
+ probe.dispose()
313
+
314
+ let t: GitTier
315
+
316
+ beforeEach(() => {
317
+ t = makeTier()
318
+ })
319
+
320
+ afterEach(() => {
321
+ t.dispose()
322
+ })
323
+
324
+ describe("resolveRef", () => {
325
+ it("resolves HEAD to a 40-char hash", async () => {
326
+ const hash = await runGit(t, (g) => g.resolveRef("HEAD"))
327
+ expect(hash).toMatch(/^[0-9a-f]{40}$/)
328
+ })
329
+
330
+ it("resolves HEAD~1 when two commits exist", async () => {
331
+ t.seed.commit("feat: second commit", { "extra.txt": "extra" })
332
+ const headHash = t.observe.resolveRef("HEAD~1")
333
+ const resolved = await runGit(t, (g) => g.resolveRef("HEAD~1"))
334
+ expect(resolved).toBe(headHash)
335
+ })
336
+
337
+ it("errors on invalid ref", async () => {
338
+ const result = await runGitExit(t, (g) => g.resolveRef("totally-invalid-ref-xyz"))
339
+ expect(Exit.isFailure(result)).toBe(true)
340
+ })
341
+ })
342
+
343
+ describe("lastCommitSubject", () => {
344
+ it("returns HEAD's subject with no ref argument", async () => {
345
+ const subject = await runGit(t, (g) => g.lastCommitSubject())
346
+ expect(subject).toBe("init: first commit")
347
+ })
348
+
349
+ it("returns the given ref's subject, not HEAD's", async () => {
350
+ t.seed.commit("feat: second commit", { "extra.txt": "extra" })
351
+ const subject = await runGit(t, (g) => g.lastCommitSubject("HEAD~1"))
352
+ expect(subject).toBe("init: first commit")
353
+ })
354
+ })
355
+
356
+ describe("lastCommitMessage", () => {
357
+ it("returns the full commit message (subject + body) of HEAD", async () => {
358
+ t.seed.commit("feat: add thing\n\nA body explaining why.")
359
+ const message = await runGit(t, (g) => g.lastCommitMessage())
360
+ expect(message).toBe("feat: add thing\n\nA body explaining why.")
361
+ })
362
+
363
+ it("returns just the subject when HEAD carries no body", async () => {
364
+ t.seed.commit("feat: subject only")
365
+ const message = await runGit(t, (g) => g.lastCommitMessage())
366
+ expect(message).toBe("feat: subject only")
367
+ })
368
+ })
369
+
370
+ describe("hasCommits", () => {
371
+ it("returns false in an empty repo", async () => {
372
+ const empty = t.emptyRepo()
373
+ try {
374
+ expect(await runGit(empty, (g) => g.hasCommits())).toBe(false)
375
+ } finally {
376
+ empty.dispose()
377
+ }
378
+ })
379
+
380
+ it("returns true once a commit exists", async () => {
381
+ expect(await runGit(t, (g) => g.hasCommits())).toBe(true)
382
+ })
383
+ })
384
+
385
+ describe("commitHistory", () => {
386
+ it("returns [] for an empty repo", async () => {
387
+ const empty = t.emptyRepo()
388
+ try {
389
+ expect(await runGit(empty, (g) => g.commitHistory())).toEqual([])
390
+ } finally {
391
+ empty.dispose()
392
+ }
393
+ })
394
+
395
+ it("returns all commits oldest to newest with their messages", async () => {
396
+ t.seed.commit("feat: second", { "b.txt": "b" })
397
+ t.seed.commit("feat: third", { "c.txt": "c" })
398
+ const result = await runGit(t, (g) => g.commitHistory())
399
+ expect(result.length).toBe(3)
400
+ expect(result[0]?.message).toBe("init: first commit")
401
+ expect(result[2]?.message).toBe("feat: third")
402
+ })
403
+
404
+ it("limits to base..HEAD range when base is provided", async () => {
405
+ t.seed.commit("feat: second", { "b.txt": "b" })
406
+ const base = t.observe.resolveRef("HEAD")
407
+ t.seed.commit("feat: third", { "c.txt": "c" })
408
+ const result = await runGit(t, (g) => g.commitHistory(base))
409
+ expect(result.length).toBe(1)
410
+ expect(result[0]?.message).toBe("feat: third")
411
+ })
412
+
413
+ it("resolves a `<head>~1` base — never just an already-resolved hash — to exactly the head commit", async () => {
414
+ t.seed.commit("feat: second", { "b.txt": "b" })
415
+ const head = t.observe.resolveRef("HEAD")
416
+ const result = await runGit(t, (g) => g.commitHistory(`${head}~1`, head))
417
+ expect(result.length).toBe(1)
418
+ expect(result[0]?.message).toBe("feat: second")
419
+ })
420
+
421
+ it("reports the paths each commit's name-status diff touched, without extra subprocesses", async () => {
422
+ t.seed.commit("feat: add two files", { "a.txt": "a", "b.txt": "b" })
423
+ t.seed.commitDeletion("a.txt", "chore: remove a")
424
+ const result = await runGit(t, (g) => g.commitHistory())
425
+ const addTwo = result.find((c) => c.message === "feat: add two files")
426
+ const removeA = result.find((c) => c.message === "chore: remove a")
427
+ expect(addTwo?.touched).toEqual(expect.arrayContaining(["a.txt", "b.txt"]))
428
+ expect(removeA?.touched).toEqual(["a.txt"])
429
+ })
430
+
431
+ it("reports touched: [] for an empty commit — Edge.ts's headTurn.empty depends on this", async () => {
432
+ await runGit(t, (g) => g.commitAllWithPrefix("gtd(agent): working"))
433
+ const result = await runGit(t, (g) => g.commitHistory())
434
+ expect(result[result.length - 1]?.touched).toEqual([])
435
+ })
436
+
437
+ it("reads through an explicit head ref instead of literal HEAD when given one", async () => {
438
+ t.seed.commit("feat: second", { "b.txt": "b" })
439
+ const earlierHead = t.observe.resolveRef("HEAD")
440
+ // Moves real HEAD forward — a `head` argument must stop the walk at
441
+ // `earlierHead` instead, proving the read goes through the given ref
442
+ // rather than the literal `HEAD` the git CLI would default to.
443
+ t.seed.commit("feat: third", { "c.txt": "c" })
444
+ const result = await runGit(t, (g) => g.commitHistory(undefined, earlierHead))
445
+ expect(result.map((c) => c.message)).toEqual(["init: first commit", "feat: second"])
446
+ })
447
+
448
+ for (const { label, path } of PATHOLOGICAL_PATHS) {
449
+ it(`reports a touched path containing ${label}, verbatim`, async () => {
450
+ t.seed.commit("feat: add pathological file", { [path]: "content" })
451
+ const result = await runGit(t, (g) => g.commitHistory())
452
+ expect(result[result.length - 1]?.touched).toEqual([path])
453
+ })
454
+ }
455
+
456
+ it("expands a rename into a deletion of the old path and an addition of the new one", async () => {
457
+ t.seed.commit("chore: seed old", { "old.txt": "same content unique-marker\n" })
458
+ t.seed.deleteFile("old.txt")
459
+ t.seed.writeFile("new.txt", "same content unique-marker\n")
460
+ t.seed.stageAll()
461
+ t.seed.commit("feat: rename old to new", {})
462
+ const result = await runGit(t, (g) => g.commitHistory())
463
+ const renameCommit = result.find((c) => c.message === "feat: rename old to new")
464
+ expect([...(renameCommit?.touched ?? [])].sort()).toEqual(["new.txt", "old.txt"])
465
+ })
466
+ })
467
+
468
+ describe("subjectHistory", () => {
469
+ it("returns [] for an empty repo", async () => {
470
+ const empty = t.emptyRepo()
471
+ try {
472
+ expect(await runGit(empty, (g) => g.subjectHistory(32, 0))).toEqual([])
473
+ } finally {
474
+ empty.dispose()
475
+ }
476
+ })
477
+
478
+ it("returns hash+subject pairs oldest to newest, no body", async () => {
479
+ t.seed.commit("feat: second\n\nA body line.", { "b.txt": "b" })
480
+ t.seed.commit("feat: third", { "c.txt": "c" })
481
+ const result = await runGit(t, (g) => g.subjectHistory(32, 0))
482
+ expect(result.map((c) => c.subject)).toEqual([
483
+ "init: first commit",
484
+ "feat: second",
485
+ "feat: third",
486
+ ])
487
+ expect(result[2]?.hash).toBe(t.observe.resolveRef("HEAD"))
488
+ })
489
+
490
+ it("pages backward from head: skip drops the newest commits kept in an earlier page", async () => {
491
+ t.seed.commit("feat: second", { "b.txt": "b" })
492
+ t.seed.commit("feat: third", { "c.txt": "c" })
493
+ const firstPage = await runGit(t, (g) => g.subjectHistory(1, 0))
494
+ const secondPage = await runGit(t, (g) => g.subjectHistory(1, 1))
495
+ expect(firstPage.map((c) => c.subject)).toEqual(["feat: third"])
496
+ expect(secondPage.map((c) => c.subject)).toEqual(["feat: second"])
497
+ })
498
+
499
+ it("an unbounded page (non-finite pageSize) returns the whole history", async () => {
500
+ t.seed.commit("feat: second", { "b.txt": "b" })
501
+ t.seed.commit("feat: third", { "c.txt": "c" })
502
+ const result = await runGit(t, (g) => g.subjectHistory(Infinity, 0))
503
+ expect(result.map((c) => c.subject)).toEqual([
504
+ "init: first commit",
505
+ "feat: second",
506
+ "feat: third",
507
+ ])
508
+ })
509
+
510
+ it("reads through an explicit head ref instead of literal HEAD when given one", async () => {
511
+ t.seed.commit("feat: second", { "b.txt": "b" })
512
+ const earlierHead = t.observe.resolveRef("HEAD")
513
+ t.seed.commit("feat: third", { "c.txt": "c" })
514
+ const result = await runGit(t, (g) => g.subjectHistory(32, 0, earlierHead))
515
+ expect(result.map((c) => c.subject)).toEqual(["init: first commit", "feat: second"])
516
+ })
517
+
518
+ it("is no worse handled than commitHistory for a subject containing the \\x02 delimiter", async () => {
519
+ t.seed.commit("feat: has a \x02 byte in it", { "d.txt": "d" })
520
+ const result = await runGit(t, (g) => g.subjectHistory(32, 0))
521
+ expect(result[result.length - 1]?.hash).toBe(t.observe.resolveRef("HEAD"))
522
+ })
523
+ })
524
+
525
+ describe("commitAllWithPrefix", () => {
526
+ it("stages all pending changes and commits with the given prefix as the message", async () => {
527
+ t.seed.writeFile("new.ts", "export const x = 1")
528
+ t.seed.writeFile("TODO.md", "# Plan")
529
+ const headBefore = t.observe.resolveRef("HEAD")
530
+ await runGit(t, (g) => g.commitAllWithPrefix("gtd: building"))
531
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
532
+ const history = await runGit(t, (g) => g.commitHistory())
533
+ expect(history[history.length - 1]?.message).toBe("gtd: building")
534
+ expect(t.observe.statusPorcelain().trim()).toBe("")
535
+ })
536
+
537
+ it("commits even on a clean tree (--allow-empty) so a fixed-prefix commit never throws", async () => {
538
+ const headBefore = t.observe.resolveRef("HEAD")
539
+ await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"))
540
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
541
+ const history = await runGit(t, (g) => g.commitHistory())
542
+ expect(history[history.length - 1]?.message).toBe("gtd: grilled")
543
+ })
544
+
545
+ it.skipIf(!capabilities.commitHooks)(
546
+ "retries with --no-verify when a hook blocks the empty commit",
547
+ async () => {
548
+ const hookPath = join(t.root, ".git/hooks/pre-commit")
549
+ writeFileSync(
550
+ hookPath,
551
+ `#!/bin/sh\necho "lint-staged prevented an empty git commit." >&2\nexit 1\n`,
552
+ )
553
+ execSync(`chmod +x "${hookPath}"`)
554
+ const headBefore = t.observe.resolveRef("HEAD")
555
+ await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"))
556
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
557
+ const history = await runGit(t, (g) => g.commitHistory())
558
+ expect(history[history.length - 1]?.message).toBe("gtd: grilled")
559
+ },
560
+ )
561
+ })
562
+
563
+ describe("commitAsIs", () => {
564
+ it("commits exactly what's staged, ignoring later worktree writes (the squash mechanic)", async () => {
565
+ t.seed.writeFile("a.txt", "a")
566
+ t.seed.stageAll()
567
+ t.seed.writeFile("b.txt", "b")
568
+ await runGit(t, (g) => g.commitAsIs("feat: squash"))
569
+ const history = await runGit(t, (g) => g.commitHistory())
570
+ expect(history[history.length - 1]?.message).toBe("feat: squash")
571
+ // HEAD's tree has a.txt (staged before the commit)...
572
+ expect(await runGit(t, (g) => g.readFileAtRef("HEAD", "a.txt"))).toContain("a")
573
+ // ...and not b.txt (written to the worktree AFTER staging).
574
+ const bAtHead = await runGitExit(t, (g) => g.readFileAtRef("HEAD", "b.txt"))
575
+ expect(Exit.isFailure(bAtHead)).toBe(true)
576
+ })
577
+
578
+ it("commits even on a clean index (--allow-empty)", async () => {
579
+ const headBefore = t.observe.resolveRef("HEAD")
580
+ await runGit(t, (g) => g.commitAsIs("gtd: empty squash"))
581
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
582
+ })
583
+ })
584
+
585
+ describe("softResetTo", () => {
586
+ it("moves HEAD back but keeps worktree changes from second commit", async () => {
587
+ const firstHash = t.observe.resolveRef("HEAD")
588
+ t.seed.commit("feat: second", { "second.txt": "second content" })
589
+ await runGit(t, (g) => g.softResetTo(firstHash))
590
+ expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
591
+ expect(t.observe.statusPorcelain()).toContain("second.txt")
592
+ })
593
+ })
594
+
595
+ describe("hardResetTo", () => {
596
+ it("moves HEAD, index, and working tree content back to the target ref", async () => {
597
+ const firstHash = t.observe.resolveRef("HEAD")
598
+ t.seed.commit("feat: second", { "readme.txt": "modified content" })
599
+ await runGit(t, (g) => g.hardResetTo(firstHash))
600
+ expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
601
+ expect(t.observe.statusPorcelain()).toBe("")
602
+ if (t.capabilities.onDiskContent) {
603
+ expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello")
604
+ }
605
+ })
606
+ })
607
+
608
+ describe("mixedResetTo", () => {
609
+ it("moves HEAD and index, leaving the working tree untouched — the reset commit's content re-surfaces as pending", async () => {
610
+ const firstHash = t.observe.resolveRef("HEAD")
611
+ t.seed.commit("feat: second", { "second.txt": "second content" })
612
+ await runGit(t, (g) => g.mixedResetTo(firstHash))
613
+ expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
614
+ expect(t.observe.statusPorcelain()).toContain("second.txt")
615
+ })
616
+ })
617
+
618
+ describe("discardPending", () => {
619
+ it("discards every pending change, tracked or untracked", async () => {
620
+ t.seed.writeFile("readme.txt", "modified")
621
+ t.seed.writeFile("untracked.txt", "new")
622
+ await runGit(t, (g) => g.discardPending())
623
+ expect(t.observe.statusPorcelain()).toBe("")
624
+ })
625
+ })
626
+
627
+ describe("updateRef / deleteRef / readRefOption", () => {
628
+ it("updateRef points a ref at a commit; readRefOption resolves it", async () => {
629
+ const head = t.observe.resolveRef("HEAD")
630
+ await runGit(t, (g) => g.updateRef("refs/gtd/marker", head))
631
+ const resolved = await runGit(t, (g) => g.readRefOption("refs/gtd/marker"))
632
+ expect(resolved._tag).toBe("Some")
633
+ if (resolved._tag === "Some") expect(resolved.value).toBe(head)
634
+ })
635
+
636
+ it("readRefOption is None for a ref that doesn't exist — never a failure", async () => {
637
+ const resolved = await runGit(t, (g) => g.readRefOption("refs/gtd/nonexistent"))
638
+ expect(resolved._tag).toBe("None")
639
+ })
640
+
641
+ it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
642
+ const head = t.observe.resolveRef("HEAD")
643
+ await runGit(t, (g) => g.updateRef("refs/gtd/marker", head))
644
+ await runGit(t, (g) => g.deleteRef("refs/gtd/marker"))
645
+ await runGit(t, (g) => g.deleteRef("refs/gtd/marker"))
646
+ const resolved = await runGit(t, (g) => g.readRefOption("refs/gtd/marker"))
647
+ expect(resolved._tag).toBe("None")
648
+ })
649
+ })
650
+
651
+ describe("isAncestor", () => {
652
+ it("is true for an ancestor commit", async () => {
653
+ const base = t.observe.resolveRef("HEAD")
654
+ t.seed.commit("feat: second", { "b.txt": "b" })
655
+ const tip = t.observe.resolveRef("HEAD")
656
+ expect(await runGit(t, (g) => g.isAncestor(base, tip))).toBe(true)
657
+ })
658
+
659
+ it("is false for a diverged tip (neither is the other's ancestor)", async () => {
660
+ const base = t.observe.resolveRef("HEAD")
661
+ t.seed.updateRef("refs/gtd/side", base)
662
+ t.seed.commit("feat: main-branch-work", { "m.txt": "m" })
663
+ const mainTip = t.observe.resolveRef("HEAD")
664
+ await runGit(t, (g) => g.mixedResetTo(base))
665
+ t.seed.writeFile("s.txt", "s")
666
+ t.seed.stageAll()
667
+ t.seed.commit("feat: side-branch-work", {})
668
+ const sideTip = t.observe.resolveRef("HEAD")
669
+ expect(await runGit(t, (g) => g.isAncestor(mainTip, sideTip))).toBe(false)
670
+ })
671
+
672
+ it("is false (never a failure) for a nonexistent ref", async () => {
673
+ expect(await runGit(t, (g) => g.isAncestor("totally-invalid-ref", "HEAD"))).toBe(false)
674
+ })
675
+ })
676
+
677
+ describe("mergeBase", () => {
678
+ it("is the fork point of two diverged tips", async () => {
679
+ const base = t.observe.resolveRef("HEAD")
680
+ t.seed.updateRef("refs/gtd/side", base)
681
+ t.seed.commit("feat: main-branch-work", { "m.txt": "m" })
682
+ const mainTip = t.observe.resolveRef("HEAD")
683
+ await runGit(t, (g) => g.mixedResetTo(base))
684
+ t.seed.writeFile("s.txt", "s")
685
+ t.seed.stageAll()
686
+ t.seed.commit("feat: side-branch-work", {})
687
+ const sideTip = t.observe.resolveRef("HEAD")
688
+ const found = await runGit(t, (g) => g.mergeBase(mainTip, sideTip))
689
+ expect(found).toEqual(Option.some(base))
690
+ })
691
+
692
+ it("is the ancestor itself when one tip is the other's ancestor", async () => {
693
+ const base = t.observe.resolveRef("HEAD")
694
+ t.seed.commit("feat: second", { "b.txt": "b" })
695
+ const found = await runGit(t, (g) => g.mergeBase(base, "HEAD"))
696
+ expect(found).toEqual(Option.some(base))
697
+ })
698
+
699
+ it("is none (never a failure) for a nonexistent ref", async () => {
700
+ expect(await runGit(t, (g) => g.mergeBase("totally-invalid-ref", "HEAD"))).toEqual(
701
+ Option.none(),
702
+ )
703
+ })
704
+ })
705
+
706
+ describe("defaultBranch", () => {
707
+ it("falls back to main when no remote names a default", async () => {
708
+ expect(await runGit(t, (g) => g.defaultBranch())).toBe("main")
709
+ })
710
+ })
711
+
712
+ describe("readFileAtRef", () => {
713
+ it("reads a file's content as it stood at a given ref", async () => {
714
+ const base = t.observe.resolveRef("HEAD")
715
+ t.seed.commit("feat: change readme", { "readme.txt": "changed" })
716
+ const content = await runGit(t, (g) => g.readFileAtRef(base, "readme.txt"))
717
+ expect(content.trim()).toBe("hello")
718
+ })
719
+
720
+ it("fails when the path does not exist at that ref", async () => {
721
+ const result = await runGitExit(t, (g) => g.readFileAtRef("HEAD", "nonexistent.txt"))
722
+ expect(Exit.isFailure(result)).toBe(true)
723
+ })
724
+ })
725
+
726
+ describe("changedPaths", () => {
727
+ it("unions a tracked modification, a tracked deletion, and an untracked addition, deduplicated by path", async () => {
728
+ t.seed.commit("chore: seed", { "modme.txt": "orig", "delme.txt": "bye" })
729
+ t.seed.writeFile("modme.txt", "changed")
730
+ t.seed.deleteFile("delme.txt")
731
+ t.seed.writeFile("newfile.txt", "new")
732
+ const changed = await runGit(t, (g) => g.changedPaths())
733
+ const byPath = Object.fromEntries(changed.map((c) => [c.path, c.status]))
734
+ expect(byPath["modme.txt"]).toBe("M")
735
+ expect(byPath["delme.txt"]).toBe("D")
736
+ expect(byPath["newfile.txt"]).toBe("A")
737
+ })
738
+
739
+ it("returns [] on a clean tree", async () => {
740
+ expect(await runGit(t, (g) => g.changedPaths())).toEqual([])
741
+ })
742
+
743
+ for (const { label, path } of PATHOLOGICAL_PATHS) {
744
+ it(`reports an untracked path containing ${label}, verbatim`, async () => {
745
+ t.seed.writeFile(path, "content")
746
+ const changed = await runGit(t, (g) => g.changedPaths())
747
+ expect(changed).toEqual([{ path, status: "A" }])
748
+ })
749
+ }
750
+
751
+ it("expands a rename into a deletion of the old path and an addition of the new one", async () => {
752
+ t.seed.commit("chore: seed old", { "old.txt": "same content unique-marker\n" })
753
+ t.seed.deleteFile("old.txt")
754
+ t.seed.writeFile("new.txt", "same content unique-marker\n")
755
+ t.seed.stageAll()
756
+ const changed = await runGit(t, (g) => g.changedPaths())
757
+ expect([...changed].sort((a, b) => a.path.localeCompare(b.path))).toEqual([
758
+ { path: "new.txt", status: "A" },
759
+ { path: "old.txt", status: "D" },
760
+ ])
761
+ })
762
+
763
+ // `git reset --mixed <base>` drops every path a commit added out of the
764
+ // index, leaving it untracked but present on disk. An index-based answer
765
+ // calls each of those a deletion (`git diff --name-status <base>` compares
766
+ // base to the INDEX). The port answers by CONTENT instead — these four
767
+ // cases are that contract.
768
+ describe("with a base, over paths the index no longer carries", () => {
769
+ /** Commit `REVIEW.md` on top of a seed commit, then mixed-reset the index back to that seed. Returns the head the caller measures against. */
770
+ const commitThenRewindIndex = (content: string): string => {
771
+ t.seed.commit("chore: seed", { "kept.txt": "seed" })
772
+ const base = t.observe.resolveRef("HEAD")
773
+ t.seed.commit("gtd(agent): reviewing", { "REVIEW.md": content })
774
+ const head = t.observe.resolveRef("HEAD")
775
+ t.seed.mixedReset(base)
776
+ return head
777
+ }
778
+
779
+ it("omits an untracked path whose bytes match the base — present and unchanged is not a change", async () => {
780
+ const head = commitThenRewindIndex("- [ ] one\n")
781
+ expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([])
782
+ })
783
+
784
+ it("reports an untracked path edited since the base as M, never D", async () => {
785
+ const head = commitThenRewindIndex("- [ ] one\n")
786
+ t.seed.writeFile("REVIEW.md", "- [x] one\n")
787
+ expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([
788
+ { path: "REVIEW.md", status: "M" },
789
+ ])
790
+ })
791
+
792
+ it("reports an untracked path REMOVED from disk as D — the deletion the require-revert guard must catch", async () => {
793
+ const head = commitThenRewindIndex("- [ ] one\n")
794
+ t.seed.deleteFile("REVIEW.md")
795
+ expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([
796
+ { path: "REVIEW.md", status: "D" },
797
+ ])
798
+ })
799
+
800
+ it("still reports a genuinely new untracked path as A", async () => {
801
+ const head = commitThenRewindIndex("- [ ] one\n")
802
+ t.seed.writeFile("NOTE.md", "a reviewer's note\n")
803
+ expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([
804
+ { path: "NOTE.md", status: "A" },
805
+ ])
806
+ })
807
+
808
+ // "Different bytes" has to mean what GIT means by it. Under
809
+ // `text=auto` the committed blob is normalized to LF while the working
810
+ // tree legitimately holds CRLF, so a RAW byte comparison calls an
811
+ // untouched file modified — and a spurious `M` on the review doc is a
812
+ // spurious "the human edited something real", which flips a clean
813
+ // sign-off onto the feedback edge — a full re-plan nobody asked for. The fake has no filters at all, so it answers
814
+ // "unchanged" by construction; this pins real git to the same answer.
815
+ it("omits an untracked path that only differs by a clean filter's normalization", async () => {
816
+ t.seed.writeFile(".gitattributes", "* text=auto\n")
817
+ t.seed.commit("chore: normalize line endings", {})
818
+ const base = t.observe.resolveRef("HEAD")
819
+ // Committed through `git add`, so the blob is normalized to LF while
820
+ // the file on disk keeps its CRLF bytes.
821
+ t.seed.writeFile("REVIEW.md", "- [ ] one\r\n- [ ] two\r\n")
822
+ t.seed.commit("gtd(agent): reviewing", {})
823
+ const head = t.observe.resolveRef("HEAD")
824
+ t.seed.mixedReset(base)
825
+ expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([])
826
+ })
827
+
828
+ it("still reports a real edit to such a path as M", async () => {
829
+ t.seed.writeFile(".gitattributes", "* text=auto\n")
830
+ t.seed.commit("chore: normalize line endings", {})
831
+ const base = t.observe.resolveRef("HEAD")
832
+ t.seed.writeFile("REVIEW.md", "- [ ] one\r\n- [ ] two\r\n")
833
+ t.seed.commit("gtd(agent): reviewing", {})
834
+ const head = t.observe.resolveRef("HEAD")
835
+ t.seed.mixedReset(base)
836
+ t.seed.writeFile("REVIEW.md", "- [x] one\r\n- [ ] two\r\n")
837
+ expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([
838
+ { path: "REVIEW.md", status: "M" },
839
+ ])
840
+ })
841
+ })
842
+ })
843
+
844
+ describe("topLevel", () => {
845
+ it("resolves to this tier's own root", async () => {
846
+ expect(await runGit(t, (g) => g.topLevel())).toBe(t.root)
847
+ })
848
+ })
849
+
850
+ describe("gitDir", () => {
851
+ it("resolves to an absolute path ending in .git, for the main worktree", async () => {
852
+ const dir = await runGit(t, (g) => g.gitDir())
853
+ expect(dir).toBe(join(t.root, ".git"))
854
+ })
855
+
856
+ it.skipIf(!capabilities.linkedWorktrees)(
857
+ "resolves to a linked worktree's own private .git/worktrees/<name> dir, not the main worktree's",
858
+ async () => {
859
+ const siblingDir = `${t.root}-gitdir-sibling`
860
+ gitExecIn(t.root, "worktree", "add", "-q", "-b", "gitdir-sibling", siblingDir, "HEAD")
861
+ try {
862
+ const mainGitDir = await runGit(t, (g) => g.gitDir())
863
+ const siblingGitDir = await Effect.runPromise(
864
+ Effect.flatMap(GitService, (g) => g.gitDir()).pipe(
865
+ Effect.provide(GitService.Live),
866
+ Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
867
+ Effect.provide(NodeContext.layer),
868
+ ),
869
+ )
870
+ expect(siblingGitDir).not.toBe(mainGitDir)
871
+ // git names a linked worktree's internal directory after the
872
+ // worktree PATH's basename, not the `-b` branch name given below.
873
+ expect(siblingGitDir).toBe(join(mainGitDir, "worktrees", basename(siblingDir)))
874
+ } finally {
875
+ rmSync(siblingDir, { recursive: true, force: true })
876
+ }
877
+ },
878
+ )
879
+ })
880
+
881
+ describe("gitCommonDir", () => {
882
+ it("resolves to the same absolute path as gitDir, for the main worktree", async () => {
883
+ const [common, dir] = await runGit(t, (g) => Effect.all([g.gitCommonDir(), g.gitDir()]))
884
+ expect(common).toBe(dir)
885
+ })
886
+
887
+ it.skipIf(!capabilities.linkedWorktrees)(
888
+ "resolves to the SAME shared directory from a linked worktree, unlike gitDir",
889
+ async () => {
890
+ const siblingDir = `${t.root}-commondir-sibling`
891
+ gitExecIn(t.root, "worktree", "add", "-q", "-b", "commondir-sibling", siblingDir, "HEAD")
892
+ try {
893
+ const mainCommonDir = await runGit(t, (g) => g.gitCommonDir())
894
+ const siblingCommonDir = await Effect.runPromise(
895
+ Effect.flatMap(GitService, (g) => g.gitCommonDir()).pipe(
896
+ Effect.provide(GitService.Live),
897
+ Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
898
+ Effect.provide(NodeContext.layer),
899
+ ),
900
+ )
901
+ const siblingGitDir = await Effect.runPromise(
902
+ Effect.flatMap(GitService, (g) => g.gitDir()).pipe(
903
+ Effect.provide(GitService.Live),
904
+ Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
905
+ Effect.provide(NodeContext.layer),
906
+ ),
907
+ )
908
+ expect(siblingCommonDir).toBe(mainCommonDir)
909
+ expect(siblingCommonDir).not.toBe(siblingGitDir)
910
+ } finally {
911
+ rmSync(siblingDir, { recursive: true, force: true })
912
+ }
913
+ },
914
+ )
915
+ })
916
+ }
917
+
918
+ /**
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.
923
+ */
924
+ const execScript = (t: GitTier, script: string): void => {
925
+ execFileSync("bash", ["-c", script], { cwd: t.root, stdio: "pipe" })
926
+ }
927
+
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 => {
944
+ let t: GitTier
945
+
946
+ beforeEach(() => {
947
+ t = makeLiveTier()
948
+ })
949
+
950
+ afterEach(() => {
951
+ t.dispose()
952
+ })
953
+
954
+ describe("commitAll", () => {
955
+ it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
956
+ t.seed.writeFile("new.ts", "export const x = 1")
957
+ const headBefore = t.observe.resolveRef("HEAD")
958
+ execScript(t, commitAll("gtd: building"))
959
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
960
+ expect(headSubject(t)).toBe("gtd: building")
961
+ expect(headTouchedPaths(t)).toEqual(["new.ts"])
962
+ expect(t.observe.statusPorcelain().trim()).toBe("")
963
+ })
964
+
965
+ it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
966
+ const headBefore = t.observe.resolveRef("HEAD")
967
+ execScript(t, commitAll("gtd: grilled"))
968
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
969
+ expect(headSubject(t)).toBe("gtd: grilled")
970
+ })
971
+ })
972
+
973
+ describe("commitAsIs", () => {
974
+ it("does NOT stage — a worktree write after staging is excluded (the squash mechanic)", () => {
975
+ t.seed.writeFile("a.txt", "a")
976
+ t.seed.stageAll()
977
+ 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"])
981
+ })
982
+
983
+ it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
984
+ const headBefore = t.observe.resolveRef("HEAD")
985
+ execScript(t, commitAsIs("gtd: empty squash"))
986
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
987
+ expect(headSubject(t)).toBe("gtd: empty squash")
988
+ })
989
+ })
990
+
991
+ describe("softResetTo", () => {
992
+ it("moves HEAD back but keeps worktree changes from the second commit", () => {
993
+ const firstHash = t.observe.resolveRef("HEAD")
994
+ t.seed.commit("feat: second", { "second.txt": "second content" })
995
+ execScript(t, softResetTo(firstHash))
996
+ expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
997
+ expect(t.observe.statusPorcelain()).toContain("second.txt")
998
+ })
999
+ })
1000
+
1001
+ describe("hardResetTo", () => {
1002
+ it("moves HEAD, index, and working tree content back to the target ref", () => {
1003
+ const firstHash = t.observe.resolveRef("HEAD")
1004
+ t.seed.commit("feat: second", { "readme.txt": "modified content" })
1005
+ execScript(t, hardResetTo(firstHash))
1006
+ expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
1007
+ expect(t.observe.statusPorcelain()).toBe("")
1008
+ expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello")
1009
+ })
1010
+ })
1011
+
1012
+ describe("mixedResetTo", () => {
1013
+ it("moves HEAD and index, leaving the working tree untouched", () => {
1014
+ const firstHash = t.observe.resolveRef("HEAD")
1015
+ t.seed.commit("feat: second", { "second.txt": "second content" })
1016
+ execScript(t, mixedResetTo(firstHash))
1017
+ expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
1018
+ expect(t.observe.statusPorcelain()).toContain("second.txt")
1019
+ })
1020
+ })
1021
+
1022
+ describe("discardPending", () => {
1023
+ it("drops both tracked modifications and untracked files", () => {
1024
+ t.seed.writeFile("readme.txt", "modified")
1025
+ t.seed.writeFile("untracked.txt", "new")
1026
+ execScript(t, discardPending())
1027
+ expect(t.observe.statusPorcelain()).toBe("")
1028
+ })
1029
+ })
1030
+
1031
+ describe("updateRef / deleteRef", () => {
1032
+ it("updateRef points a ref at a commit", () => {
1033
+ const head = t.observe.resolveRef("HEAD")
1034
+ execScript(t, updateRef("refs/gtd/marker", head))
1035
+ expect(t.observe.refExists("refs/gtd/marker")).toBe(true)
1036
+ expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head)
1037
+ })
1038
+
1039
+ it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
1040
+ 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"))
1044
+ expect(t.observe.refExists("refs/gtd/marker")).toBe(false)
1045
+ })
1046
+
1047
+ it("deleteRef tolerates a ref that was never created", () => {
1048
+ expect(() => execScript(t, deleteRef("refs/gtd/never-created"))).not.toThrow()
1049
+ expect(t.observe.refExists("refs/gtd/never-created")).toBe(false)
1050
+ })
1051
+ })
1052
+ }