@pmelab/gtd 24.0.0 → 24.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -5
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43339 -55575
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +8 -6
  19. package/package.json +69 -8
  20. package/src/cli/Cli.test.ts +1329 -0
  21. package/src/cli/Cli.ts +1348 -0
  22. package/src/cli/Program.test.ts +2262 -0
  23. package/src/cli/Program.ts +1331 -0
  24. package/src/cli/ToolCommands.ts +43 -0
  25. package/src/cli/index.ts +14 -0
  26. package/src/edge/Edge.test.ts +1016 -0
  27. package/src/edge/Edge.ts +1225 -0
  28. package/src/edge/index.ts +16 -0
  29. package/src/emit/Emit.test.ts +425 -0
  30. package/src/emit/Emit.ts +139 -0
  31. package/src/emit/GitScript.test.ts +260 -0
  32. package/src/emit/GitScript.ts +131 -0
  33. package/src/emit/ModeContradiction.test.ts +120 -0
  34. package/src/emit/ModeContradiction.ts +66 -0
  35. package/src/emit/OutcomeScript.test.ts +238 -0
  36. package/src/emit/OutcomeScript.ts +73 -0
  37. package/src/emit/SteeringMode.test.ts +274 -0
  38. package/src/emit/SteeringMode.ts +256 -0
  39. package/src/emit/index.ts +40 -0
  40. package/src/install/ConfigScaffold.ts +19 -0
  41. package/src/install/Install.test.ts +426 -0
  42. package/src/install/Install.ts +634 -0
  43. package/src/install/Version.ts +27 -0
  44. package/src/install/index.ts +3 -0
  45. package/src/platform/CommandRunner.ts +74 -0
  46. package/src/platform/Commentary.test.ts +126 -0
  47. package/src/platform/Commentary.ts +92 -0
  48. package/src/platform/Git.test.ts +50 -0
  49. package/src/platform/Git.ts +552 -0
  50. package/src/platform/Host.ts +42 -0
  51. package/src/platform/RetainedHistory.test.ts +65 -0
  52. package/src/platform/RetainedHistory.ts +34 -0
  53. package/src/platform/Workspace.test.ts +669 -0
  54. package/src/platform/Workspace.ts +631 -0
  55. package/src/platform/WorktreeState.test.ts +162 -0
  56. package/src/platform/WorktreeState.ts +50 -0
  57. package/src/platform/index.ts +22 -0
  58. package/src/replay/Access.test.ts +53 -0
  59. package/src/replay/Access.ts +46 -0
  60. package/src/replay/Format.test.ts +20 -0
  61. package/src/replay/Format.ts +14 -0
  62. package/src/replay/Replay.test.ts +887 -0
  63. package/src/replay/Replay.ts +669 -0
  64. package/src/replay/Trailers.test.ts +117 -0
  65. package/src/replay/Trailers.ts +213 -0
  66. package/src/replay/Tree.test.ts +25 -0
  67. package/src/replay/Tree.ts +45 -0
  68. package/src/replay/index.ts +11 -0
  69. package/src/steering/Blocks.fixture.ts +6 -0
  70. package/src/steering/Blocks.test.ts +167 -0
  71. package/src/steering/Blocks.ts +321 -0
  72. package/src/steering/CodeThreads.test.ts +127 -0
  73. package/src/steering/CodeThreads.ts +197 -0
  74. package/src/steering/Edits.test.ts +19 -0
  75. package/src/steering/Edits.ts +20 -0
  76. package/src/steering/Eol.test.ts +16 -0
  77. package/src/steering/Eol.ts +2 -0
  78. package/src/steering/FORMATS.md +58 -0
  79. package/src/steering/Footnotes.test.ts +977 -0
  80. package/src/steering/Footnotes.ts +661 -0
  81. package/src/steering/Inline.fixture.ts +7 -0
  82. package/src/steering/Inline.test.ts +227 -0
  83. package/src/steering/Inline.ts +195 -0
  84. package/src/steering/MarkdownTree.test.ts +247 -0
  85. package/src/steering/MarkdownTree.ts +260 -0
  86. package/src/steering/Outline.ts +19 -0
  87. package/src/steering/SteeringFormat.test.ts +91 -0
  88. package/src/steering/SteeringFormat.ts +267 -0
  89. package/src/steering/SteeringFormats.test.ts +163 -0
  90. package/src/steering/Threads.ts +163 -0
  91. package/src/steering/contract.test.ts +264 -0
  92. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  93. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  94. package/src/steering/fixtures/free-form/notes.md +12 -0
  95. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  96. package/src/steering/fixtures/qa/answered.md +23 -0
  97. package/src/steering/fixtures/qa/answered.read.json +119 -0
  98. package/src/steering/fixtures/qa/requirements.md +160 -0
  99. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  100. package/src/steering/fixtures/review/review.md +76 -0
  101. package/src/steering/fixtures/review/review.read.json +237 -0
  102. package/src/steering/fixtures/review/ticked.md +23 -0
  103. package/src/steering/fixtures/review/ticked.read.json +99 -0
  104. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  105. package/src/steering/fixtures/review-notes/noted.md +19 -0
  106. package/src/steering/freeform.test.ts +311 -0
  107. package/src/steering/freeform.ts +148 -0
  108. package/src/steering/index.test.ts +62 -0
  109. package/src/steering/index.ts +140 -0
  110. package/src/steering/qa.test.ts +2160 -0
  111. package/src/steering/qa.ts +1104 -0
  112. package/src/steering/review.test.ts +1829 -0
  113. package/src/steering/review.ts +1180 -0
  114. package/src/step/LandStep.ts +31 -0
  115. package/src/step/RepoSnapshot.ts +29 -0
  116. package/src/step/accessRefusal.test.ts +58 -0
  117. package/src/step/accessRefusal.ts +26 -0
  118. package/src/step/index.ts +5 -0
  119. package/src/step/planStart.test.ts +202 -0
  120. package/src/step/planStart.ts +167 -0
  121. package/src/step/planStep.test.ts +104 -0
  122. package/src/step/planStep.ts +89 -0
  123. package/src/step/snapshot.fixture.ts +28 -0
  124. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  125. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  126. package/src/testkit/FakeGitOperations.ts +95 -0
  127. package/src/testkit/GitTiers.test.ts +11 -0
  128. package/src/testkit/GitTiers.ts +1052 -0
  129. package/src/testkit/InMemRepo.ts +467 -0
  130. package/src/testkit/Layers.test.ts +58 -0
  131. package/src/testkit/Layers.ts +241 -0
  132. package/src/testkit/StrictGitOperations.ts +31 -0
  133. package/src/testkit/TestRepo.test.ts +119 -0
  134. package/src/testkit/TestRepo.ts +274 -0
  135. package/src/testkit/cliIo.ts +36 -0
  136. package/src/testkit/index.ts +9 -0
  137. package/src/testkit/steps.ts +177 -0
  138. package/src/testkit/testkit.feature +65 -0
  139. package/src/wire/BeatDocument.test.ts +419 -0
  140. package/src/wire/BeatDocument.ts +114 -0
  141. package/src/wire/BeatPlain.test.ts +113 -0
  142. package/src/wire/BeatPlain.ts +127 -0
  143. package/src/wire/BeatStatus.test.ts +59 -0
  144. package/src/wire/BeatStatus.ts +81 -0
  145. package/src/wire/Demand.test.ts +142 -0
  146. package/src/wire/Demand.ts +108 -0
  147. package/src/wire/ExitCodes.test.ts +21 -0
  148. package/src/wire/ExitCodes.ts +16 -0
  149. package/src/wire/Glob.ts +29 -0
  150. package/src/wire/Judge.ts +22 -0
  151. package/src/wire/Land.test.ts +120 -0
  152. package/src/wire/Land.ts +75 -0
  153. package/src/wire/Select.test.ts +148 -0
  154. package/src/wire/Select.ts +78 -0
  155. package/src/wire/Sessions.test.ts +59 -0
  156. package/src/wire/Sessions.ts +39 -0
  157. package/src/wire/WireSchema.test.ts +162 -0
  158. package/src/wire/constants.ts +22 -0
  159. package/src/wire/golden/1/beat-capture.json +34 -0
  160. package/src/wire/golden/1/beat-message.json +34 -0
  161. package/src/wire/golden/1/beat-prompt.json +44 -0
  162. package/src/wire/golden/1/beat-script.json +34 -0
  163. package/src/wire/golden/1/beat-stalled.json +34 -0
  164. package/src/wire/golden/1/judge.json +14 -0
  165. package/src/wire/golden/1/land-noop.json +10 -0
  166. package/src/wire/golden/1/land.json +10 -0
  167. package/src/wire/index.ts +12 -0
  168. package/src/wire/types.ts +46 -0
  169. package/src/workflow/Diagnostic.test.ts +93 -0
  170. package/src/workflow/Diagnostic.ts +54 -0
  171. package/src/workflow/SteeringFormats.test.ts +27 -0
  172. package/src/workflow/SteeringFormats.ts +6 -0
  173. package/src/workflow/Workflow.ts +77 -0
  174. package/src/workflow/compile.test.ts +53 -0
  175. package/src/workflow/compile.ts +56 -0
  176. package/src/workflow/discovery.ts +122 -0
  177. package/src/workflow/fingerprint.test.ts +52 -0
  178. package/src/workflow/fingerprint.ts +53 -0
  179. package/src/workflow/index.ts +33 -0
  180. package/src/workflow/load.test.ts +1017 -0
  181. package/src/workflow/load.ts +836 -0
  182. package/src/workflow/skills.test.ts +109 -0
  183. package/src/workflow/skills.ts +90 -0
  184. package/src/workflow/vars.ts +33 -0
  185. package/src/workflows/feature/feature.test.ts +27 -33
  186. package/src/workflows/feature/index.ts +10 -0
  187. package/src/workflows/fix/fix.feature +80 -0
  188. package/src/workflows/fix/fix.test.ts +58 -78
  189. package/src/workflows/fix/index.ts +6 -0
  190. package/src/workflows/index.ts +2 -10
  191. package/src/workflows/review/index.ts +6 -0
  192. package/src/workflows/review/review.test.ts +85 -29
  193. package/src/workflows/review/review.ts +3 -1
  194. package/src/workflows/shared/config.fixture.ts +12 -0
  195. package/src/workflows/shared/reserved.ts +11 -0
  196. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,552 @@
1
+ import { Command, CommandExecutor } from "@effect/platform"
2
+ import { Context, Effect, Layer, Option, Stream } from "effect"
3
+ import { Host } from "./Host.js"
4
+
5
+ export interface GitReaderOperations {
6
+ readonly lastCommitSubject: (ref?: string) => Effect.Effect<string, Error>
7
+ /**
8
+ * Full commit message (subject + body) of HEAD — keeps the body, unlike
9
+ * `lastCommitSubject`. No current caller: it read back a `Gtd-History:`
10
+ * trailer for the now-removed squash finale's `restorability` check; kept
11
+ * deliberately rather than deleted, as a plain, reusable git primitive a
12
+ * future edge concern may need again.
13
+ */
14
+ readonly lastCommitMessage: () => Effect.Effect<string, Error>
15
+ readonly hasCommits: () => Effect.Effect<boolean, Error>
16
+ readonly resolveRef: (ref: string) => Effect.Effect<string, Error>
17
+ /** The ref's hash if it resolves, `Option.none` if it doesn't exist (never fails). Used to read the retained-history ref. */
18
+ readonly readRefOption: (ref: string) => Effect.Effect<Option.Option<string>, Error>
19
+ /** Never fails (a non-zero exit reports `false`). */
20
+ readonly isAncestor: (a: string, b: string) => Effect.Effect<boolean, Error>
21
+ /** The best common ancestor of `a` and `b`; `Option.none` when they share none or either does not resolve (never fails). */
22
+ readonly mergeBase: (a: string, b: string) => Effect.Effect<Option.Option<string>, Error>
23
+ /** `origin`'s default branch (`origin/main`, say) when the remote names one, else `main`. */
24
+ readonly defaultBranch: () => Effect.Effect<string, Error>
25
+ readonly topLevel: () => Effect.Effect<string, Error>
26
+ /**
27
+ * The absolute, per-worktree git directory — not derived from
28
+ * `Host.root + "/.git"`, since a linked worktree's `.git` is a FILE pointing
29
+ * elsewhere, and two worktrees looping concurrently must never collide on
30
+ * one file.
31
+ */
32
+ readonly gitDir: () => Effect.Effect<string, Error>
33
+ /**
34
+ * The absolute git COMMON directory — shared by every linked worktree of
35
+ * one clone, unlike `gitDir` above. A commit hash or blob id means the
36
+ * same thing in every worktree, so caches keyed on one (`gtd-cache/` under
37
+ * this directory) are deliberately shared rather than duplicated per
38
+ * worktree.
39
+ */
40
+ readonly gitCommonDir: () => Effect.Effect<string, Error>
41
+ /**
42
+ * First-parent history from `base..head` (or through `head` if no base),
43
+ * oldest→newest; `head` defaults to `"HEAD"`. Pass a resolved hash to walk a
44
+ * head other than the literal `HEAD`.
45
+ * `touched` lists the paths the commit's name-status diff mentions, from
46
+ * the same git invocation.
47
+ */
48
+ readonly commitHistory: (
49
+ base?: string,
50
+ head?: string,
51
+ ) => Effect.Effect<
52
+ ReadonlyArray<{
53
+ readonly hash: string
54
+ readonly message: string
55
+ readonly touched: ReadonlyArray<string>
56
+ }>,
57
+ Error
58
+ >
59
+ /**
60
+ * `{ hash, subject }` pairs, first-parent, for one PAGE of history: `skip`
61
+ * commits are dropped and at most `pageSize` kept, walking backward from
62
+ * `head` (default `"HEAD"`) newest-first before the kept page is reversed
63
+ * to oldest→newest — the same selection `git log --skip --max-count
64
+ * --reverse` makes. No commit body or diff is read, which is the point:
65
+ * `Edge.ts` uses this to find an episode's boundary commit without paying
66
+ * for `commitHistory`'s `%B`/`--name-status` over the whole repository.
67
+ * `pageSize` may be non-finite (e.g. `Infinity`) for an unbounded page —
68
+ * the whole history from `skip` onward, with no `--max-count` at all.
69
+ */
70
+ readonly subjectHistory: (
71
+ pageSize: number,
72
+ skip: number,
73
+ head?: string,
74
+ ) => Effect.Effect<ReadonlyArray<{ readonly hash: string; readonly subject: string }>, Error>
75
+ /** Fails when the path doesn't exist at `ref` — a caller expecting that (e.g. the review sign-off gate) handles it with an explicit `catchAll`. */
76
+ readonly readFileAtRef: (ref: string, path: string) => Effect.Effect<string, Error>
77
+ /**
78
+ * Pending working-tree changes vs `base` (default `HEAD`), as
79
+ * `{path, status}` pairs: tracked diff unioned with untracked files.
80
+ *
81
+ * An untracked path is classified by CONTENT against `base`, not the index:
82
+ * the index's view calls a present-but-untracked file `D` whenever it
83
+ * doesn't match the working tree. A REAL deletion still reports `D` — it is
84
+ * absent from the untracked list, so the tracked diff's own `D` stands.
85
+ */
86
+ readonly changedPaths: (
87
+ base?: string,
88
+ ) => Effect.Effect<ReadonlyArray<{ readonly path: string; readonly status: string }>, Error>
89
+ }
90
+
91
+ export interface GitWriterOperations {
92
+ /**
93
+ * `git add -A` then commit. `--allow-empty` is load-bearing: the machine
94
+ * emits a fixed subject even on a clean tree, and neither that nor an
95
+ * empty feedback-fixing commit must throw "nothing to commit". `message`
96
+ * may carry a trailing `Gtd-Cost:` trailer body — `-m` preserves embedded
97
+ * newlines verbatim.
98
+ */
99
+ readonly commitAllWithPrefix: (message: string) => Effect.Effect<void, Error>
100
+ /**
101
+ * `git reset --soft <ref>` — moves HEAD (and only HEAD; index/worktree stay
102
+ * put). No current caller: it was the first half of the now-removed squash
103
+ * finale, kept on the port deliberately rather than deleted, since it's a
104
+ * plain, reusable git primitive a future edge concern may need again.
105
+ */
106
+ readonly softResetTo: (ref: string) => Effect.Effect<void, Error>
107
+ /**
108
+ * Commits whatever is CURRENTLY STAGED, without an implicit `git add` first
109
+ * (unlike `commitAllWithPrefix`). No current caller: it paired with
110
+ * `softResetTo` as the squash finale's second half (re-committing the
111
+ * pre-reset index verbatim); kept for the same reason `softResetTo` is.
112
+ * Retries once without the pre-commit hook, same as `commitAllWithPrefix`.
113
+ */
114
+ readonly commitAsIs: (message: string) => Effect.Effect<void, Error>
115
+ /**
116
+ * Discards every pending change, tracked or untracked, by staging first
117
+ * (`git add -A`) so the hard reset also drops untracked survivors. No
118
+ * current caller: it discarded the squash finale's leftover
119
+ * message-template file after `commitAsIs` landed; kept for the same
120
+ * reason `softResetTo` is.
121
+ */
122
+ readonly discardPending: () => Effect.Effect<void, Error>
123
+ readonly updateRef: (ref: string, hash: string) => Effect.Effect<void, Error>
124
+ /** Idempotent: deleting a missing ref is a no-op. */
125
+ readonly deleteRef: (ref: string) => Effect.Effect<void, Error>
126
+ /** Used by `gtd abandon`'s reset. */
127
+ readonly mixedResetTo: (ref: string) => Effect.Effect<void, Error>
128
+ readonly hardResetTo: (ref: string) => Effect.Effect<void, Error>
129
+ }
130
+
131
+ export interface GitOperations extends GitReaderOperations, GitWriterOperations {}
132
+
133
+ const splitNul = (out: string): Array<string> => out.split("\0").filter((s) => s.length > 0)
134
+
135
+ /**
136
+ * Parse a `git diff --name-status -z` token stream into `{ path, status }`
137
+ * pairs. Statuses are identified by POSITION, never shape, so a file legally
138
+ * named e.g. `M` is never misread as a status token. A rename/copy status is
139
+ * followed by two paths; expanded into a deletion of the old one and an
140
+ * addition of the new one.
141
+ */
142
+ const parseNameStatus = (
143
+ tokens: ReadonlyArray<string>,
144
+ ): Array<{ path: string; status: string }> => {
145
+ const result: Array<{ path: string; status: string }> = []
146
+ let i = 0
147
+ while (i < tokens.length) {
148
+ const status = tokens[i]!
149
+ i += 1
150
+ if (status.startsWith("R") || status.startsWith("C")) {
151
+ const oldPath = tokens[i]
152
+ const newPath = tokens[i + 1]
153
+ i += 2
154
+ if (oldPath !== undefined) result.push({ path: oldPath, status: "D" })
155
+ if (newPath !== undefined) result.push({ path: newPath, status: "A" })
156
+ } else {
157
+ const path = tokens[i]
158
+ i += 1
159
+ if (path !== undefined) result.push({ path, status: status[0]! })
160
+ }
161
+ }
162
+ return result
163
+ }
164
+
165
+ /**
166
+ * `git log -z --format=…` NUL-terminates the commit entry in place of the
167
+ * blank line before its diff, and (when a diff follows) still emits that
168
+ * blank line's own newline byte literally. Both are seam artifacts, not diff
169
+ * data — stripped here before `splitNul`/`parseNameStatus`.
170
+ */
171
+ const stripCommitSeam = (tail: string): string => {
172
+ const afterNul = tail.startsWith("\x00") ? tail.slice(1) : tail
173
+ return afterNul.startsWith("\n") ? afterNul.slice(1) : afterNul
174
+ }
175
+
176
+ type GitExec = (...args: [string, ...Array<string>]) => Effect.Effect<string, Error>
177
+
178
+ /**
179
+ * `path → blob object id` at `ref`; a path absent from the map doesn't exist
180
+ * there. An unresolvable `ref` (an empty repo) yields an empty map, so every
181
+ * candidate reads as "not at `ref`".
182
+ */
183
+ const blobsAtRef = (
184
+ exec: GitExec,
185
+ ref: string,
186
+ paths: ReadonlyArray<string>,
187
+ ): Effect.Effect<Map<string, string>, Error> =>
188
+ exec("git", "ls-tree", "-r", "-z", ref, "--", ...paths).pipe(
189
+ Effect.catchAll(() => Effect.succeed("")),
190
+ Effect.map((out) => {
191
+ const blobs = new Map<string, string>()
192
+ for (const entry of splitNul(out)) {
193
+ const tab = entry.indexOf("\t")
194
+ if (tab === -1) continue
195
+ const [, type, oid] = entry.slice(0, tab).split(" ")
196
+ if (type === "blob" && oid !== undefined) blobs.set(entry.slice(tab + 1), oid)
197
+ }
198
+ return blobs
199
+ }),
200
+ )
201
+
202
+ /**
203
+ * `path → object id` for every path's CURRENT bytes, in one call. An empty map
204
+ * on failure, so every candidate reads as "differs" — the safe direction,
205
+ * since a file that EXISTS must never be reported deleted.
206
+ *
207
+ * NEVER add `--no-filters`: these ids are comparable with `git ls-tree`'s only
208
+ * because hash-object applies the repo's own clean filters, and without them a
209
+ * `text=auto` repo's untouched CRLF file reports `M`, flipping a clean
210
+ * sign-off onto the feedback edge. Both tiers pin this.
211
+ *
212
+ * A symlink is the residual inexactness: hash-object hashes the target's
213
+ * content rather than the link text, and reads as `M`, never `D`.
214
+ */
215
+ const hashObjects = (
216
+ exec: GitExec,
217
+ paths: ReadonlyArray<string>,
218
+ ): Effect.Effect<Map<string, string>, Error> =>
219
+ exec("git", "hash-object", "--", ...paths).pipe(
220
+ Effect.map(
221
+ (out) =>
222
+ new Map(
223
+ out
224
+ .trim()
225
+ .split("\n")
226
+ .map((oid, i) => [paths[i] ?? "", oid.trim()] as const),
227
+ ),
228
+ ),
229
+ Effect.catchAll(() => Effect.succeed(new Map<string, string>())),
230
+ )
231
+
232
+ /**
233
+ * Classify every untracked path against `ref`'s tree, by CONTENT: not at
234
+ * `ref` → `A`; different bytes → `M`; identical bytes → no entry. The latter
235
+ * two exist because "untracked" does not mean "new": against a `ref` older
236
+ * than HEAD, a path can be absent from the index yet already present in
237
+ * `ref`'s tree with the same or different bytes. "Different bytes" goes
238
+ * through `hashObjects`'s clean filters, matching how git itself
239
+ * decides it, so an untouched `text=auto` file is never over-reported `M`.
240
+ */
241
+ const classifyUntracked = (
242
+ exec: GitExec,
243
+ ref: string,
244
+ untrackedPaths: ReadonlyArray<string>,
245
+ ): Effect.Effect<Array<{ path: string; status: string }>, Error> =>
246
+ Effect.gen(function* () {
247
+ const atRef = yield* blobsAtRef(exec, ref, untrackedPaths)
248
+ const candidates = untrackedPaths.filter((path) => atRef.has(path))
249
+ const onDisk =
250
+ candidates.length === 0 ? new Map<string, string>() : yield* hashObjects(exec, candidates)
251
+ return untrackedPaths.flatMap((path) => {
252
+ const at = atRef.get(path)
253
+ if (at === undefined) return [{ path, status: "A" }]
254
+ return onDisk.get(path) === at ? [] : [{ path, status: "M" }]
255
+ })
256
+ })
257
+
258
+ /**
259
+ * Run a command and return its stdout — FAILING on a non-zero exit code with
260
+ * the command line and stderr in the error message. `Command.string` alone
261
+ * silently ignores exit codes, which used to make gtd report success on
262
+ * rejected commits and lose files whose quoted paths broke a swallowed
263
+ * `git add`. Callers that expect a probe to fail handle it with an explicit
264
+ * `catchAll`.
265
+ */
266
+ const run = (
267
+ root: string,
268
+ ...args: [string, ...Array<string>]
269
+ ): Effect.Effect<string, Error, CommandExecutor.CommandExecutor> =>
270
+ Effect.scoped(
271
+ Effect.gen(function* () {
272
+ const executor = yield* CommandExecutor.CommandExecutor
273
+ const process = yield* executor.start(
274
+ Command.make(...args).pipe(
275
+ Command.workingDirectory(root),
276
+ Command.stdout("pipe"),
277
+ Command.stderr("pipe"),
278
+ ),
279
+ )
280
+ const collect = (stream: typeof process.stdout) =>
281
+ stream.pipe(Stream.decodeText(), Stream.mkString)
282
+ const [stdout, stderr, exitCode] = yield* Effect.all(
283
+ [collect(process.stdout), collect(process.stderr), process.exitCode],
284
+ { concurrency: "unbounded" },
285
+ )
286
+ if (exitCode !== 0) {
287
+ return yield* Effect.fail(
288
+ new Error(`${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim()}`),
289
+ )
290
+ }
291
+ return stdout
292
+ }),
293
+ ).pipe(Effect.mapError((e) => (e instanceof Error ? e : new Error(String(e)))))
294
+
295
+ /**
296
+ * Same shape as `Commentary.ts`'s `GtdError` (a `message` plus remediation
297
+ * `detail` lines, duck-typed by its `renderFailure`) — a LOCAL class rather
298
+ * than importing `GtdError` itself: `platform` may not depend on root
299
+ * (no outward target in `.fallowrc.json`'s `boundaries`), and `Git.ts` moved
300
+ * here with it.
301
+ */
302
+ export class InvalidRefError extends Error {
303
+ readonly detail: readonly string[]
304
+
305
+ constructor(message: string, detail: readonly string[] = []) {
306
+ super(message)
307
+ this.name = "InvalidRefError"
308
+ this.detail = detail
309
+ }
310
+ }
311
+
312
+ /**
313
+ * `resolveRef`'s validation for when `git rev-parse --verify <ref>` exits
314
+ * zero but its stdout isn't a 40-hex-char hash — a corrupted ref, not a
315
+ * missing one. A separate pure function so this rare branch is unit-testable
316
+ * without a real git repository contriving the condition.
317
+ */
318
+ export const resolvedRefOrCorrupted = (
319
+ ref: string,
320
+ hash: string,
321
+ ): Effect.Effect<string, InvalidRefError> =>
322
+ /^[0-9a-f]{40}$/.test(hash)
323
+ ? Effect.succeed(hash)
324
+ : Effect.fail(new InvalidRefError(`Invalid ref: ${ref}`, [`ref: ${ref}`]))
325
+
326
+ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): GitOperations => {
327
+ const exec = (...args: [string, ...Array<string>]) =>
328
+ run(root, ...args).pipe(
329
+ Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, executor)),
330
+ )
331
+
332
+ return {
333
+ lastCommitSubject: (ref = "HEAD") =>
334
+ exec("git", "log", "-1", "--pretty=%s", ref).pipe(Effect.map((s) => s.trim())),
335
+
336
+ lastCommitMessage: () =>
337
+ exec("git", "log", "-1", "--pretty=%B").pipe(Effect.map((s) => s.trim())),
338
+
339
+ hasCommits: () =>
340
+ exec("git", "rev-parse", "--verify", "HEAD").pipe(
341
+ Effect.map(() => true),
342
+ Effect.catchAll(() => Effect.succeed(false)),
343
+ ),
344
+
345
+ readFileAtRef: (ref: string, path: string) => exec("git", "show", `${ref}:${path}`),
346
+
347
+ changedPaths: (base?: string) =>
348
+ Effect.gen(function* () {
349
+ const ref = base ?? "HEAD"
350
+ // Tolerates the empty-repo case (no HEAD), where the diff has no ref
351
+ // to compare against and reports no tracked changes.
352
+ const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(
353
+ Effect.catchAll(() => Effect.succeed("")),
354
+ )
355
+ const trackedPaths = parseNameStatus(splitNul(nameStatusOut))
356
+
357
+ const untrackedRaw = yield* exec("git", "ls-files", "--others", "--exclude-standard", "-z")
358
+ const untrackedPaths = splitNul(untrackedRaw)
359
+ // Nothing untracked: the index-based diff above already IS the answer,
360
+ // and the two classification reads below are not paid for at all.
361
+ if (untrackedPaths.length === 0) return trackedPaths
362
+
363
+ const untrackedChanges = yield* classifyUntracked(exec, ref, untrackedPaths)
364
+
365
+ // The untracked classification OVERRIDES the tracked diff for its own
366
+ // paths: `git diff --name-status <ref>` compares `ref` to the INDEX, so
367
+ // a path the index no longer carries reports `D` even while sitting
368
+ // right there on disk — a phantom deletion, not a real one.
369
+ const untrackedSet = new Set(untrackedPaths)
370
+ const seen = new Set<string>()
371
+ const all: Array<{ path: string; status: string }> = []
372
+ for (const entry of [
373
+ ...trackedPaths.filter((e) => !untrackedSet.has(e.path)),
374
+ ...untrackedChanges,
375
+ ]) {
376
+ if (!seen.has(entry.path)) {
377
+ seen.add(entry.path)
378
+ all.push(entry)
379
+ }
380
+ }
381
+ return all
382
+ }),
383
+
384
+ resolveRef: (ref: string) =>
385
+ exec("git", "rev-parse", "--verify", ref).pipe(
386
+ Effect.map((s) => s.trim()),
387
+ Effect.flatMap((hash) => resolvedRefOrCorrupted(ref, hash)),
388
+ ),
389
+
390
+ readRefOption: (ref: string) =>
391
+ exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(
392
+ Effect.map((s) => Option.some(s.trim())),
393
+ Effect.catchAll(() => Effect.succeed(Option.none<string>())),
394
+ ),
395
+
396
+ isAncestor: (a: string, b: string) =>
397
+ run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(
398
+ Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, executor)),
399
+ Effect.map(() => true),
400
+ Effect.catchAll(() => Effect.succeed(false)),
401
+ ),
402
+
403
+ mergeBase: (a: string, b: string) =>
404
+ exec("git", "merge-base", a, b).pipe(
405
+ Effect.map((s) => Option.some(s.trim())),
406
+ Effect.catchAll(() => Effect.succeed(Option.none<string>())),
407
+ ),
408
+
409
+ defaultBranch: () =>
410
+ exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(
411
+ Effect.map((s) => s.trim()),
412
+ Effect.catchAll(() => Effect.succeed("")),
413
+ Effect.map((name) => (name === "" ? "main" : name)),
414
+ ),
415
+
416
+ topLevel: () => exec("git", "rev-parse", "--show-toplevel").pipe(Effect.map((s) => s.trim())),
417
+
418
+ gitDir: () => exec("git", "rev-parse", "--absolute-git-dir").pipe(Effect.map((s) => s.trim())),
419
+
420
+ gitCommonDir: () =>
421
+ exec("git", "rev-parse", "--path-format=absolute", "--git-common-dir").pipe(
422
+ Effect.map((s) => s.trim()),
423
+ ),
424
+
425
+ commitHistory: (base?: string, head = "HEAD") => {
426
+ const range = base !== undefined ? `${base}..${head}` : head !== "HEAD" ? head : undefined
427
+ const args: [string, ...Array<string>] = [
428
+ "git",
429
+ "log",
430
+ "--first-parent",
431
+ "--reverse",
432
+ "-z",
433
+ "--format=%x01%H%x02%B%x02",
434
+ "--name-status",
435
+ ...(range !== undefined ? [range] : []),
436
+ ]
437
+ return exec(...args).pipe(
438
+ Effect.map((out) =>
439
+ out
440
+ .split("\x01")
441
+ .filter((chunk) => chunk.length > 0)
442
+ .map((chunk) => {
443
+ const parts = chunk.split("\x02")
444
+ const hash = (parts[0] ?? "").trim()
445
+ const message = (parts[1] ?? "").trim()
446
+ // A literal \x02 byte inside the commit message (the documented
447
+ // residual risk) would otherwise silently drop the extra split
448
+ // pieces — rejoining keeps that failure mode no worse than
449
+ // before, without pretending to fix it.
450
+ const tail = parts.slice(2).join("")
451
+ const entries = parseNameStatus(splitNul(stripCommitSeam(tail)))
452
+ const touched = entries.map((e) => e.path)
453
+ return { hash, message, touched }
454
+ }),
455
+ ),
456
+ // Empty repo (no HEAD) makes `git log` fail; treat as no commits.
457
+ Effect.catchAll(() =>
458
+ Effect.succeed(
459
+ [] as ReadonlyArray<{
460
+ readonly hash: string
461
+ readonly message: string
462
+ readonly touched: ReadonlyArray<string>
463
+ }>,
464
+ ),
465
+ ),
466
+ )
467
+ },
468
+
469
+ subjectHistory: (pageSize: number, skip: number, head = "HEAD") => {
470
+ const ref = head !== "HEAD" ? head : undefined
471
+ const args: [string, ...Array<string>] = [
472
+ "git",
473
+ "log",
474
+ "--first-parent",
475
+ "--reverse",
476
+ "-z",
477
+ "--format=%H%x02%s",
478
+ `--skip=${skip}`,
479
+ ...(Number.isFinite(pageSize) ? [`--max-count=${pageSize}`] : []),
480
+ ...(ref !== undefined ? [ref] : []),
481
+ ]
482
+ return exec(...args).pipe(
483
+ Effect.map((out) =>
484
+ splitNul(out).map((chunk) => {
485
+ const sep = chunk.indexOf("\x02")
486
+ const hash = (sep === -1 ? chunk : chunk.slice(0, sep)).trim()
487
+ const subject = (sep === -1 ? "" : chunk.slice(sep + 1)).trim()
488
+ return { hash, subject }
489
+ }),
490
+ ),
491
+ // Empty repo (no HEAD), or an unresolvable `head`, makes `git log`
492
+ // fail; treat as no commits — the same fold `commitHistory` applies.
493
+ Effect.catchAll(() =>
494
+ Effect.succeed([] as ReadonlyArray<{ readonly hash: string; readonly subject: string }>),
495
+ ),
496
+ )
497
+ },
498
+
499
+ commitAllWithPrefix: (prefix: string) =>
500
+ Effect.gen(function* () {
501
+ yield* exec("git", "add", "-A")
502
+ yield* exec("git", "commit", "--allow-empty", "-m", prefix).pipe(
503
+ Effect.catchAll((error) =>
504
+ // Hooks like lint-staged block empty commits even with --allow-empty.
505
+ // gtd's workflow commits have nothing for code-quality hooks to validate,
506
+ // so retry without the pre-commit hook when that guard fires.
507
+ error.message.includes("empty git commit")
508
+ ? exec("git", "commit", "--allow-empty", "--no-verify", "-m", prefix)
509
+ : Effect.fail(error),
510
+ ),
511
+ )
512
+ }).pipe(Effect.asVoid),
513
+
514
+ softResetTo: (ref: string) => exec("git", "reset", "--soft", ref).pipe(Effect.asVoid),
515
+
516
+ commitAsIs: (message: string) =>
517
+ exec("git", "commit", "--allow-empty", "-m", message)
518
+ .pipe(
519
+ Effect.catchAll((error) =>
520
+ error.message.includes("empty git commit")
521
+ ? exec("git", "commit", "--allow-empty", "--no-verify", "-m", message)
522
+ : Effect.fail(error),
523
+ ),
524
+ )
525
+ .pipe(Effect.asVoid),
526
+
527
+ discardPending: () =>
528
+ Effect.gen(function* () {
529
+ yield* exec("git", "add", "-A")
530
+ yield* exec("git", "reset", "--hard", "HEAD")
531
+ }).pipe(Effect.asVoid),
532
+
533
+ updateRef: (ref: string, hash: string) =>
534
+ exec("git", "update-ref", ref, hash).pipe(Effect.asVoid),
535
+
536
+ deleteRef: (ref: string) => exec("git", "update-ref", "-d", ref).pipe(Effect.asVoid),
537
+
538
+ mixedResetTo: (ref: string) => exec("git", "reset", "--mixed", ref).pipe(Effect.asVoid),
539
+
540
+ hardResetTo: (ref: string) => exec("git", "reset", "--hard", ref).pipe(Effect.asVoid),
541
+ }
542
+ }
543
+
544
+ const makeLiveEffect = Effect.gen(function* () {
545
+ const executor = yield* CommandExecutor.CommandExecutor
546
+ const { root } = yield* Host
547
+ return makeGitImpl(executor, root)
548
+ })
549
+
550
+ export class GitService extends Context.Tag("GitService")<GitService, GitOperations>() {
551
+ static Live = Layer.effect(GitService, makeLiveEffect)
552
+ }
@@ -0,0 +1,42 @@
1
+ import { realpathSync } from "node:fs"
2
+ import { homedir, tmpdir } from "node:os"
3
+ import { Context, Effect, Layer } from "effect"
4
+
5
+ const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
6
+
7
+ export interface HostOps {
8
+ readonly root: string
9
+ readonly home: string
10
+ readonly env: Readonly<Record<string, string | undefined>>
11
+ /** `env["TMPDIR"]` when set, `node:os`'s `tmpdir()` otherwise — never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
12
+ readonly scratchDir: string
13
+ /** The real, symlink-resolved form of `path` — used to compare a possibly-symlinked cwd (macOS `/tmp` → `/private/tmp`) against git's own resolution. */
14
+ readonly realPath: (path: string) => Effect.Effect<string, Error>
15
+ }
16
+
17
+ interface HostOpsInput {
18
+ readonly root: string
19
+ readonly home: string
20
+ readonly env: Readonly<Record<string, string | undefined>>
21
+ readonly scratchDir?: string
22
+ readonly realPath?: (path: string) => Effect.Effect<string, Error>
23
+ }
24
+
25
+ const makeHostOps = (opts: HostOpsInput): HostOps => ({
26
+ root: opts.root,
27
+ home: opts.home,
28
+ env: opts.env,
29
+ scratchDir:
30
+ opts.scratchDir ??
31
+ (opts.env["TMPDIR"] !== undefined && opts.env["TMPDIR"]!.length > 0
32
+ ? opts.env["TMPDIR"]!
33
+ : tmpdir()),
34
+ realPath:
35
+ opts.realPath ??
36
+ ((path: string) => Effect.try({ try: () => realpathSync(path), catch: toError })),
37
+ })
38
+
39
+ export class Host extends Context.Tag("Host")<Host, HostOps>() {
40
+ static layer = (opts: HostOpsInput): Layer.Layer<Host> => Layer.succeed(Host, makeHostOps(opts))
41
+ static Live = Host.layer({ root: process.cwd(), home: homedir(), env: process.env })
42
+ }
@@ -0,0 +1,65 @@
1
+ import { Effect, Option } from "effect"
2
+ import { describe, expect, it, vi } from "vitest"
3
+ import { strictGitOperations as stubGit } from "../testkit/index.js"
4
+ import {
5
+ clearRetainedHistory,
6
+ HISTORY_REF,
7
+ readRetainedHistory,
8
+ restorability,
9
+ } from "./RetainedHistory.js"
10
+
11
+ /**
12
+ * Unit coverage for the retention edge module (`src/platform/RetainedHistory.ts`),
13
+ * using `strictGitOperations` (`src/testkit/StrictGitOperations.ts`) as its
14
+ * `GitOperations` double — a Proxy failing on any method a test didn't
15
+ * override, so an unexpected call fails loudly instead of silently succeeding.
16
+ */
17
+
18
+ const run = <A>(effect: Effect.Effect<A, Error>): Promise<A> => Effect.runPromise(effect)
19
+
20
+ describe("readRetainedHistory", () => {
21
+ it("returns Option.none() when readRefOption resolves to none", async () => {
22
+ const git = stubGit({ readRefOption: () => Effect.succeed(Option.none()) })
23
+ const result = await run(readRetainedHistory(git))
24
+ expect(Option.isNone(result)).toBe(true)
25
+ })
26
+
27
+ it("returns Option.some(hash) when readRefOption resolves to some hash", async () => {
28
+ const git = stubGit({ readRefOption: () => Effect.succeed(Option.some("abc123")) })
29
+ const result = await run(readRetainedHistory(git))
30
+ expect(result).toEqual(Option.some("abc123"))
31
+ })
32
+ })
33
+
34
+ describe("restorability", () => {
35
+ it("HEAD === tipHash (the degenerate ancestor case) is safe", async () => {
36
+ const git = stubGit({ isAncestor: () => Effect.succeed(true) })
37
+ const result = await run(restorability(git, "tip123", "tip123"))
38
+ expect(result).toEqual({ ok: true })
39
+ })
40
+
41
+ it("cleaned abandon / fast-forward — headHash !== tipHash, isAncestor true — is safe", async () => {
42
+ const git = stubGit({ isAncestor: () => Effect.succeed(true) })
43
+ const result = await run(restorability(git, "head789", "tip123"))
44
+ expect(result).toEqual({ ok: true })
45
+ })
46
+
47
+ it("refuses when HEAD is not an ancestor of the retained tip", async () => {
48
+ const git = stubGit({ isAncestor: () => Effect.succeed(false) })
49
+ const result = await run(restorability(git, "head789", "tip123"))
50
+ expect(result.ok).toBe(false)
51
+ if (!result.ok) {
52
+ expect(typeof result.reason).toBe("string")
53
+ expect(result.reason.length).toBeGreaterThan(0)
54
+ }
55
+ })
56
+ })
57
+
58
+ describe("clearRetainedHistory", () => {
59
+ it("deletes HISTORY_REF", async () => {
60
+ const deleteRef = vi.fn(() => Effect.succeed(undefined))
61
+ const git = stubGit({ deleteRef })
62
+ await run(clearRetainedHistory(git))
63
+ expect(deleteRef).toHaveBeenCalledWith(HISTORY_REF)
64
+ })
65
+ })