@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,467 @@
1
+ // Pure TypeScript, no Effect, no real filesystem/git.
2
+ import { createHash } from "node:crypto"
3
+
4
+ /** Embedded in every value this fake reaches (see also `strictGitOperations`'s default message) so `scripts/assert-no-test-doubles.mjs` can catch a leak into the shipped bundle by string search. */
5
+ export const TEST_DOUBLE_SENTINEL = "gtd:test-double:never-ship"
6
+
7
+ interface Commit {
8
+ hash: string // 40-hex
9
+ message: string
10
+ files: Map<string, string> // full tree snapshot (path → UTF-8 content)
11
+ parent: string | null
12
+ }
13
+
14
+ function sha1(input: string): string {
15
+ return createHash("sha1").update(input).digest("hex")
16
+ }
17
+
18
+ function makeHash(message: string, parent: string | null, tree: Map<string, string>): string {
19
+ const treeStr = [...tree.entries()]
20
+ .sort(([a], [b]) => a.localeCompare(b))
21
+ .map(([k, v]) => `${k}:${v}`)
22
+ .join("\n")
23
+ return sha1(`${message}\n${parent ?? "null"}\n${treeStr}`)
24
+ }
25
+
26
+ export class InMemRepo {
27
+ // Data-only marker read by no code path: embeds the sentinel in every
28
+ // instance so a leaked object still carries it.
29
+ readonly testDouble = TEST_DOUBLE_SENTINEL
30
+
31
+ private commits: Map<string, Commit> = new Map()
32
+ private branches: Map<string, string> = new Map() // branch name → hash
33
+ private refs: Map<string, string> = new Map() // fully qualified ref (refs/gtd/…) → hash
34
+ private head: string | null = null
35
+ private currentBranch: string = "main"
36
+ private worktree: Map<string, string> = new Map()
37
+ private index: Map<string, string> = new Map()
38
+
39
+ private getCommit(hash: string): Commit | null {
40
+ return this.commits.get(hash) ?? null
41
+ }
42
+
43
+ private headCommit(): Commit | null {
44
+ if (this.head === null) return null
45
+ return this.getCommit(this.head) ?? null
46
+ }
47
+
48
+ private headTree(): Map<string, string> {
49
+ return this.headCommit()?.files ?? new Map()
50
+ }
51
+
52
+ /** The tree at `ref` (HEAD's own when `ref` is omitted), empty when it resolves to nothing. */
53
+ private treeAt(ref: string | undefined): Map<string, string> {
54
+ if (ref === undefined) return this.headTree()
55
+ const hash = this.resolveRef(ref)
56
+ return hash === null ? new Map() : (this.getCommit(hash)?.files ?? new Map())
57
+ }
58
+
59
+ // fallow-ignore-next-line complexity
60
+ statusPorcelain(): string {
61
+ const headTree = this.headTree()
62
+ const lines: string[] = []
63
+
64
+ const allPaths = new Set([...headTree.keys(), ...this.index.keys(), ...this.worktree.keys()])
65
+
66
+ for (const path of [...allPaths].sort()) {
67
+ const inHead = headTree.has(path)
68
+ const inIndex = this.index.has(path)
69
+ const inWorktree = this.worktree.has(path)
70
+ const headContent = headTree.get(path)
71
+ const indexContent = this.index.get(path)
72
+ const worktreeContent = this.worktree.get(path)
73
+
74
+ let X = " "
75
+ if (!inHead && inIndex) {
76
+ X = "A"
77
+ } else if (inHead && !inIndex) {
78
+ X = "D"
79
+ } else if (inHead && inIndex && headContent !== indexContent) {
80
+ X = "M"
81
+ }
82
+
83
+ let Y = " "
84
+ if (inIndex && !inWorktree) {
85
+ Y = "D"
86
+ } else if (!inIndex && inWorktree) {
87
+ Y = "?"
88
+ } else if (inIndex && inWorktree && indexContent !== worktreeContent) {
89
+ Y = "M"
90
+ }
91
+
92
+ if (!inHead && !inIndex && inWorktree) {
93
+ lines.push(`?? ${path}`)
94
+ continue
95
+ }
96
+
97
+ if (X === " " && Y === " ") continue
98
+
99
+ lines.push(`${X}${Y} ${path}`)
100
+ }
101
+
102
+ return lines.length > 0 ? lines.join("\n") + "\n" : ""
103
+ }
104
+
105
+ hasCommits(): boolean {
106
+ return this.head !== null
107
+ }
108
+
109
+ // fallow-ignore-next-line complexity
110
+ resolveRef(ref: string): string | null {
111
+ if (/^[0-9a-f]{40}$/.test(ref)) {
112
+ return this.commits.has(ref) ? ref : null
113
+ }
114
+
115
+ const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+))?$/.exec(ref)
116
+ if (tildeMatch) {
117
+ const base = tildeMatch[1]!
118
+ const steps = tildeMatch[3] !== undefined ? parseInt(tildeMatch[3], 10) : 0
119
+ let cur: string | null = base === "HEAD" ? this.head : this.commits.has(base) ? base : null
120
+ for (let i = 0; i < steps; i++) {
121
+ if (cur === null) return null
122
+ cur = this.getCommit(cur)?.parent ?? null
123
+ }
124
+ return cur
125
+ }
126
+
127
+ const refHash = this.refs.get(ref)
128
+ if (refHash !== undefined) return refHash
129
+
130
+ return this.branches.get(ref) ?? null
131
+ }
132
+
133
+ lastCommitSubject(ref = "HEAD"): string | null {
134
+ const hash = this.resolveRef(ref)
135
+ const c = hash !== null ? this.getCommit(hash) : null
136
+ if (!c) return null
137
+ return c.message.split("\n")[0] ?? null
138
+ }
139
+
140
+ lastCommitMessage(): string | null {
141
+ const c = this.headCommit()
142
+ if (!c) return null
143
+ return c.message
144
+ }
145
+
146
+ /**
147
+ * `head` (default `this.head`, mirroring production's `"HEAD"` default) is
148
+ * resolved through the SAME `resolveRef` logic as any other ref — so a
149
+ * symbolic name, a hash, or a `~N` expression all work exactly as they
150
+ * would for `head === undefined`. `Edge.ts`'s `restAt` passes the review
151
+ * checkout window's saved-head hash here while the window is open.
152
+ */
153
+ commitHistory(
154
+ base?: string,
155
+ head?: string,
156
+ ): Array<{
157
+ hash: string
158
+ message: string
159
+ touched: ReadonlyArray<string>
160
+ }> {
161
+ const headHash = head !== undefined ? this.resolveRef(head) : this.head
162
+ if (headHash === null) return []
163
+
164
+ const chain: Commit[] = []
165
+ let cur: string | null = headHash
166
+ while (cur !== null) {
167
+ const c = this.getCommit(cur)
168
+ if (!c) break
169
+ chain.push(c)
170
+ cur = c.parent
171
+ }
172
+
173
+ chain.reverse()
174
+
175
+ // Filter to base..HEAD range if base given — resolved through `resolveRef`
176
+ // so a `<hash>~N`/`HEAD~N` base (not just an already-resolved hash) walks
177
+ // the same range a real `git log <base>..<head>` would.
178
+ let filtered = chain
179
+ if (base !== undefined) {
180
+ const resolvedBase = this.resolveRef(base)
181
+ const baseIdx = resolvedBase === null ? -1 : chain.findIndex((c) => c.hash === resolvedBase)
182
+ if (baseIdx === -1) {
183
+ return []
184
+ }
185
+ filtered = chain.slice(baseIdx + 1)
186
+ }
187
+
188
+ // Counts every commit whose body/diff this call computes — the perf test
189
+ // in Edge.test.ts pins this staying bounded (independent of repo age)
190
+ // rather than growing with the total commit count.
191
+ this.bodyReadCount += filtered.length
192
+
193
+ return filtered.map((c) => {
194
+ const parentTree = c.parent ? (this.getCommit(c.parent)?.files ?? new Map()) : new Map()
195
+ const touched = diffTrees(parentTree, c.files).map((e) => e.path)
196
+ return { hash: c.hash, message: c.message, touched }
197
+ })
198
+ }
199
+
200
+ /** Total commits `commitHistory` has ever computed a body/diff for, across every call — see `commitHistory`'s own comment. */
201
+ bodyReadCount = 0
202
+
203
+ /**
204
+ * `{ hash, subject }` pairs for one page of history — the fake's
205
+ * counterpart to the real port's `git log --skip --max-count --reverse`:
206
+ * walk backward (newest-first) from `head`, drop `skip`, keep at most
207
+ * `pageSize`, then reverse the kept page to oldest→newest. Never touches
208
+ * `bodyReadCount` — a subject-only read costs nothing to model as cheap.
209
+ */
210
+ subjectHistory(
211
+ pageSize: number,
212
+ skip: number,
213
+ head?: string,
214
+ ): Array<{ hash: string; subject: string }> {
215
+ const headHash = head !== undefined ? this.resolveRef(head) : this.head
216
+ if (headHash === null) return []
217
+
218
+ const chain: Commit[] = []
219
+ let cur: string | null = headHash
220
+ while (cur !== null) {
221
+ const c = this.getCommit(cur)
222
+ if (!c) break
223
+ chain.push(c)
224
+ cur = c.parent
225
+ }
226
+
227
+ const page = chain.slice(skip, Number.isFinite(pageSize) ? skip + pageSize : undefined)
228
+ page.reverse()
229
+ return page.map((c) => ({ hash: c.hash, subject: c.message.split("\n")[0] ?? "" }))
230
+ }
231
+
232
+ fileAtRef(ref: string, path: string): string | null {
233
+ const hash = this.resolveRef(ref)
234
+ if (!hash) return null
235
+ const c = this.getCommit(hash)
236
+ if (!c) return null
237
+ return c.files.get(path) ?? null
238
+ }
239
+
240
+ /**
241
+ * `base` (default HEAD) mirrors the port's own optional base. Tree-vs-worktree
242
+ * comparison already subsumes production's untracked-file filtering: a path
243
+ * present at `base` with identical content is simply not a difference.
244
+ */
245
+ changedPathsWorktree(base?: string): Array<{ path: string; status: string }> {
246
+ const baseTree = this.treeAt(base)
247
+ const worktreeTree = new Map(this.worktree)
248
+ return diffTrees(baseTree, worktreeTree)
249
+ }
250
+
251
+ /** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
252
+ readFile(path: string): string | undefined {
253
+ return this.worktree.get(path)
254
+ }
255
+
256
+ /** True when `path` is exactly a worktree file, or a directory prefix of one (any worktree key starts with `path/`). */
257
+ hasPath(path: string): boolean {
258
+ if (this.worktree.has(path)) return true
259
+ const prefix = path.endsWith("/") ? path : `${path}/`
260
+ for (const key of this.worktree.keys()) {
261
+ if (key.startsWith(prefix)) return true
262
+ }
263
+ return false
264
+ }
265
+
266
+ /** Every worktree path under `prefix`, sorted. `prefix === ""` returns the whole worktree. */
267
+ /** Every path committed at `ref`, sorted — empty when it resolves to nothing. */
268
+ pathsAtRef(ref: string): ReadonlyArray<string> {
269
+ return [...this.treeAt(ref).keys()].sort()
270
+ }
271
+
272
+ pathsUnder(prefix: string): ReadonlyArray<string> {
273
+ if (prefix === "") return [...this.worktree.keys()].sort()
274
+ const dirPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`
275
+ return [...this.worktree.keys()]
276
+ .filter((key) => key === prefix || key.startsWith(dirPrefix))
277
+ .sort()
278
+ }
279
+
280
+ /** `git add -A` — the index becomes exactly the current worktree. */
281
+ stageAll(): void {
282
+ this.index = new Map(this.worktree)
283
+ }
284
+
285
+ /** Commit whatever is currently staged (the index) verbatim, with no implicit staging first — mirrors `git commit --allow-empty -m <message>` after a soft reset. */
286
+ commitAsIs(message: string): void {
287
+ const tree = new Map(this.index)
288
+ const parent = this.head
289
+ const hash = makeHash(message, parent, tree)
290
+ const commit: Commit = { hash, message, files: new Map(tree), parent }
291
+ this.commits.set(hash, commit)
292
+ this.head = hash
293
+ this.branches.set(this.currentBranch, hash)
294
+ }
295
+
296
+ /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
297
+ discardPending(): void {
298
+ this.index = new Map(this.worktree)
299
+ this.resetHard()
300
+ }
301
+
302
+ commitAllWithPrefix(prefix: string): void {
303
+ this.index = new Map(this.worktree)
304
+
305
+ const tree = new Map(this.index)
306
+ const message = prefix
307
+ const parent = this.head
308
+ const hash = makeHash(message, parent, tree)
309
+
310
+ const commit: Commit = { hash, message, files: new Map(tree), parent }
311
+ this.commits.set(hash, commit)
312
+ this.head = hash
313
+ this.branches.set(this.currentBranch, hash)
314
+ }
315
+
316
+ // Git's empty-tree object SHA: used as squash base when there's no parent commit.
317
+ private static readonly EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
318
+
319
+ softResetTo(ref: string): void {
320
+ if (ref === InMemRepo.EMPTY_TREE) {
321
+ this.head = null
322
+ this.branches.delete(this.currentBranch)
323
+ return
324
+ }
325
+ const hash = this.resolveRef(ref)
326
+ if (!hash) throw new Error(`Cannot resolve ref: ${ref}`)
327
+ this.head = hash
328
+ this.branches.set(this.currentBranch, hash)
329
+ }
330
+
331
+ /**
332
+ * `git reset --hard <ref>` — move HEAD, the index, AND the worktree to
333
+ * `ref`'s commit. Doubles as test-setup (a scenario simulating checking out
334
+ * a DIFFERENT point in history — e.g. to build two diverging tips off one
335
+ * shared base, so one of them is provably NOT an ancestor of the other,
336
+ * `gtd review <commitish>`'s ancestor guard) and as this repo's backing for
337
+ * the production `GitOperations.hardResetTo` (wired in `FakeGitOperations.ts`).
338
+ */
339
+ hardResetTo(ref: string): void {
340
+ const hash = this.resolveRef(ref)
341
+ if (!hash) throw new Error(`Cannot resolve ref: ${ref}`)
342
+ this.head = hash
343
+ this.branches.set(this.currentBranch, hash)
344
+ const tree = this.getCommit(hash)?.files ?? new Map()
345
+ this.index = new Map(tree)
346
+ this.worktree = new Map(tree)
347
+ }
348
+
349
+ resetHard(): void {
350
+ const headTree = this.headTree()
351
+ // Snapshot the old index before resetting it (needed to identify staged-new files).
352
+ const oldIndex = new Map(this.index)
353
+
354
+ this.index = new Map(headTree)
355
+
356
+ // Rebuild worktree: pure-untracked files (not in HEAD and not in old
357
+ // index) survive; staged-new files (in old index but not in HEAD) are
358
+ // removed; tracked files are restored to HEAD content.
359
+ const newWorktree = new Map<string, string>()
360
+
361
+ for (const [path, content] of this.worktree) {
362
+ if (!headTree.has(path) && !oldIndex.has(path)) {
363
+ newWorktree.set(path, content)
364
+ }
365
+ }
366
+
367
+ for (const [path, content] of headTree) {
368
+ newWorktree.set(path, content)
369
+ }
370
+
371
+ this.worktree = newWorktree
372
+ }
373
+
374
+ writeFile(path: string, content: string): void {
375
+ this.worktree.set(path, content)
376
+ }
377
+
378
+ deleteFile(path: string): void {
379
+ this.worktree.delete(path)
380
+ }
381
+
382
+ /** `git update-ref <ref> <hash>` — point a repo-local ref at a commit (resolves symbolic inputs first). */
383
+ updateRef(ref: string, hash: string): void {
384
+ this.refs.set(ref, this.resolveRef(hash) ?? hash)
385
+ }
386
+
387
+ /** `git update-ref -d <ref>` — idempotent removal of a repo-local ref. */
388
+ deleteRef(ref: string): void {
389
+ this.refs.delete(ref)
390
+ }
391
+
392
+ /**
393
+ * `git reset --mixed <ref>` — move HEAD and the index to `ref`'s commit,
394
+ * leaving the working tree untouched (so committed work re-surfaces as
395
+ * pending changes).
396
+ */
397
+ mixedResetTo(ref: string): void {
398
+ if (ref === InMemRepo.EMPTY_TREE) {
399
+ this.head = null
400
+ this.branches.delete(this.currentBranch)
401
+ this.index = new Map()
402
+ return
403
+ }
404
+ const hash = this.resolveRef(ref)
405
+ if (!hash) throw new Error(`Cannot resolve ref: ${ref}`)
406
+ this.head = hash
407
+ this.branches.set(this.currentBranch, hash)
408
+ this.index = new Map(this.getCommit(hash)?.files ?? new Map())
409
+ }
410
+
411
+ /** `hash` and its first-parent ancestors, nearest first. */
412
+ private chainFrom(hash: string): string[] {
413
+ const chain: string[] = []
414
+ for (let cur: string | null = hash; cur !== null; cur = this.getCommit(cur)?.parent ?? null) {
415
+ chain.push(cur)
416
+ }
417
+ return chain
418
+ }
419
+
420
+ /** The nearest commit on both first-parent chains, or `null`. */
421
+ mergeBase(a: string, b: string): string | null {
422
+ const ha = this.resolveRef(a)
423
+ const hb = this.resolveRef(b)
424
+ if (!ha || !hb) return null
425
+ const onA = new Set(this.chainFrom(ha))
426
+ return this.chainFrom(hb).find((hash) => onA.has(hash)) ?? null
427
+ }
428
+
429
+ /** True iff `a` is an ancestor of (or equal to) `b` on the first-parent chain. */
430
+ isAncestor(a: string, b: string): boolean {
431
+ const ha = this.resolveRef(a)
432
+ const hb = this.resolveRef(b)
433
+ if (!ha || !hb) return false
434
+ let cur: string | null = hb
435
+ while (cur !== null) {
436
+ if (cur === ha) return true
437
+ cur = this.getCommit(cur)?.parent ?? null
438
+ }
439
+ return false
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Unlike production's `parseNameStatus` (`src/platform/Git.ts`), this never reports a
445
+ * rename/copy (`R`/`C`) — every change is a plain add/delete/modify. A path
446
+ * that git would show as a rename therefore surfaces here as a delete-of-old
447
+ * plus an add-of-new, never as a rename, in either tier's contract tests.
448
+ */
449
+ function diffTrees(
450
+ treeA: Map<string, string>,
451
+ treeB: Map<string, string>,
452
+ ): Array<{ path: string; status: string }> {
453
+ const result: Array<{ path: string; status: string }> = []
454
+ const allPaths = new Set([...treeA.keys(), ...treeB.keys()])
455
+ for (const path of allPaths) {
456
+ const inA = treeA.has(path)
457
+ const inB = treeB.has(path)
458
+ if (!inA && inB) {
459
+ result.push({ path, status: "A" })
460
+ } else if (inA && !inB) {
461
+ result.push({ path, status: "D" })
462
+ } else if (inA && inB && treeA.get(path) !== treeB.get(path)) {
463
+ result.push({ path, status: "M" })
464
+ }
465
+ }
466
+ return result.sort((a, b) => a.path.localeCompare(b.path))
467
+ }
@@ -0,0 +1,58 @@
1
+ import { Effect } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { ConfigDiscovery, LEGACY_RC_FILES, walkUp } from "../workflow/index.js"
4
+ import { InMemRepo } from "./index.js"
5
+ import { testLayers } from "./Layers.js"
6
+
7
+ // The in-memory `ConfigDiscovery` counterpart walks the SAME root→home chain
8
+ // and looks for the SAME `LEGACY_RC_FILES` as `ConfigDiscovery.Live` — both
9
+ // imported from it, never copied.
10
+
11
+ const ROOT = "/home/user/project/sub"
12
+ const MIDDLE = "/home/user/project"
13
+ const HOME = "/home/user"
14
+ const ABOVE_HOME = "/home"
15
+
16
+ /** Writes `content` at `dir/name`, using a repo-relative key inside `ROOT` and an absolute literal key outside it — matching `makeInMemoryWorkspaceOps.atPath`'s own root-relative/ancestor split. */
17
+ const seed = (repo: InMemRepo, dir: string, name: string, content: string): void => {
18
+ const filepath = `${dir}/${name}`
19
+ const key = filepath.startsWith(`${ROOT}/`) ? filepath.slice(ROOT.length + 1) : filepath
20
+ repo.writeFile(key, content)
21
+ }
22
+
23
+ const runDiscovery = <A>(
24
+ repo: InMemRepo,
25
+ f: (d: ConfigDiscovery["Type"]) => Effect.Effect<A, Error>,
26
+ ) =>
27
+ Effect.runPromise(
28
+ Effect.gen(function* () {
29
+ const discovery = yield* ConfigDiscovery
30
+ return yield* f(discovery)
31
+ }).pipe(Effect.provide(testLayers(repo, { root: ROOT, home: HOME }))),
32
+ )
33
+
34
+ describe("the in-memory ConfigDiscovery counterpart", () => {
35
+ it("finds every leftover rc file on the shared walk, innermost first", async () => {
36
+ const repo = new InMemRepo()
37
+ seed(repo, HOME, LEGACY_RC_FILES[1]!, "{}\n")
38
+ seed(repo, ROOT, LEGACY_RC_FILES[0]!, "vars: {}\n")
39
+ seed(repo, ROOT, LEGACY_RC_FILES[1]!, "{}\n")
40
+ seed(repo, ABOVE_HOME, LEGACY_RC_FILES[0]!, "never-walked: true\n")
41
+ const found = (await runDiscovery(repo, (d) => d.legacyRcFiles(ROOT, HOME))).map((f) => f.path)
42
+ expect(walkUp(ROOT, HOME)).toEqual([ROOT, MIDDLE, HOME])
43
+ expect(found).toEqual([
44
+ `${ROOT}/${LEGACY_RC_FILES[0]}`,
45
+ `${ROOT}/${LEGACY_RC_FILES[1]}`,
46
+ `${HOME}/${LEGACY_RC_FILES[1]}`,
47
+ ])
48
+ })
49
+
50
+ it("also searches home when the walk never reaches it", async () => {
51
+ const repo = new InMemRepo()
52
+ seed(repo, HOME, LEGACY_RC_FILES[1]!, "{}\n")
53
+ const found = (
54
+ await runDiscovery(repo, (d) => d.legacyRcFiles("/elsewhere/project", HOME))
55
+ ).map((f) => f.path)
56
+ expect(found).toEqual([`${HOME}/${LEGACY_RC_FILES[1]}`])
57
+ })
58
+ })