@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- 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
|
+
})
|