@pmelab/gtd 24.3.0 → 25.0.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 +28 -22
- package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +780 -813
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +666 -161
- package/dist/workflow-kit.mjs +55 -96
- package/dist/workflows/feature.mjs +171 -76
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +353 -310
- package/package.json +1 -2
- package/src/cli/Program.test.ts +3 -8
- package/src/cli/Program.ts +1 -8
- package/src/edge/Edge.test.ts +5 -2
- package/src/edge/Edge.ts +3 -4
- package/src/emit/index.ts +1 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/replay/Replay.ts +2 -1
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +148 -51
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +140 -49
- package/src/testkit/InMemRepo.ts +35 -2
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflow-kit/scenarios.test.ts +116 -0
- package/src/workflow-kit/scenarios.ts +53 -113
- package/src/workflows/feature/feature.ts +2 -5
- package/src/workflows/feature/packages.test.ts +17 -34
- package/src/workflows/feature/packages.ts +13 -52
- package/src/workflows/feature/planning.ts +1 -1
- package/src/workflows/feature/review.test.ts +233 -54
- package/src/workflows/feature/review.ts +178 -41
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/index.ts +0 -2
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/health.test.ts +1 -21
- package/src/workflows/shared/health.ts +4 -39
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -1
- package/src/workflows/shared/steps.ts +18 -11
- package/src/workflows/shared/tail.test.ts +1 -41
- package/src/workflows/shared/tail.ts +17 -41
- package/src/workflows/shared/text.fixture.ts +1 -0
- package/src/workflows/shared/text.test.ts +129 -41
- package/src/workflows/shared/text.ts +111 -75
- package/src/workflows/shared/vars.ts +1 -1
- package/src/workflows/shared/scenarios.test.ts +0 -256
- package/src/workflows/shared/scenarios.ts +0 -35
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
checkScript,
|
|
4
|
+
moveScript,
|
|
5
|
+
removeScript,
|
|
6
|
+
restoreScript,
|
|
7
|
+
revertScript,
|
|
8
|
+
showScript,
|
|
9
|
+
} from "../flows/index.js"
|
|
10
|
+
import { gitTiers, type GitTier, type ScriptedCommand } from "./index.js"
|
|
11
|
+
|
|
12
|
+
// Every case runs through real `sh` and through `applyFlowScript`. A check's
|
|
13
|
+
// command is declared for the in-memory twin, and Live runs it for real — a
|
|
14
|
+
// declaration that lies about the command fails here, not in a scenario.
|
|
15
|
+
const FAILING_WITH_OUTPUT = "printf 'boom\\n'; exit 3"
|
|
16
|
+
const FAILING_SILENTLY = "exit 4"
|
|
17
|
+
const PASSING = "true"
|
|
18
|
+
const WRITING = "printf 'x\\n' > out.txt"
|
|
19
|
+
|
|
20
|
+
const commands = new Map<string, ScriptedCommand>([
|
|
21
|
+
[FAILING_WITH_OUTPUT, { kind: "exit", status: 3, output: "boom\n" }],
|
|
22
|
+
[FAILING_SILENTLY, { kind: "exit", status: 4, output: "" }],
|
|
23
|
+
[PASSING, { kind: "exit", status: 0, output: "" }],
|
|
24
|
+
[WRITING, { kind: "rewrite", file: "out.txt", content: "x\n" }],
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
const REPORT = ".gtd/FEEDBACK.md"
|
|
28
|
+
|
|
29
|
+
for (const makeTier of gitTiers) {
|
|
30
|
+
describe(`flows scripts [${makeTier().name}]`, () => {
|
|
31
|
+
let t: GitTier
|
|
32
|
+
const run = (script: string) => t.runScript(script, commands)
|
|
33
|
+
const read = (path: string) =>
|
|
34
|
+
t.observe.existsPath(path) ? t.observe.readWorktreeFile(path) : undefined
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
t = makeTier()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
t.dispose()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("removeScript", () => {
|
|
45
|
+
it("removes files and whole directories, and tolerates absent paths", () => {
|
|
46
|
+
t.seed.writeFile("out/a.txt", "a")
|
|
47
|
+
t.seed.writeFile("out/deep/b.txt", "b")
|
|
48
|
+
t.seed.writeFile("c.txt", "c")
|
|
49
|
+
t.seed.writeFile("keep.txt", "k")
|
|
50
|
+
expect(run(removeScript(["out", "c.txt", "missing.txt"])).ok).toBe(true)
|
|
51
|
+
expect(read("out/a.txt")).toBeUndefined()
|
|
52
|
+
expect(read("out/deep/b.txt")).toBeUndefined()
|
|
53
|
+
expect(read("c.txt")).toBeUndefined()
|
|
54
|
+
expect(read("keep.txt")).toBe("k")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("moveScript", () => {
|
|
59
|
+
it("moves a file, creating the target directory", () => {
|
|
60
|
+
t.seed.writeFile("a.txt", "a")
|
|
61
|
+
expect(run(moveScript("a.txt", "deep/dir/b.txt")).ok).toBe(true)
|
|
62
|
+
expect(read("a.txt")).toBeUndefined()
|
|
63
|
+
expect(read("deep/dir/b.txt")).toBe("a")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("fails on an absent source", () => {
|
|
67
|
+
expect(run(moveScript("missing.txt", "b.txt")).ok).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe("showScript", () => {
|
|
72
|
+
it("writes the committed blob, not the working copy", () => {
|
|
73
|
+
t.seed.commit("feat: notes", { "notes.md": "committed" })
|
|
74
|
+
const head = t.observe.resolveRef("HEAD")
|
|
75
|
+
t.seed.writeFile("notes.md", "edited")
|
|
76
|
+
expect(run(showScript(head, "notes.md", "notes.orig.md")).ok).toBe(true)
|
|
77
|
+
expect(read("notes.orig.md")).toBe("committed")
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe("restoreScript", () => {
|
|
82
|
+
it("restores and removes only the paths still exactly as the commit left them", () => {
|
|
83
|
+
t.seed.commit("feat: base", { "a.txt": "a0", "b.txt": "b0" })
|
|
84
|
+
t.seed.commit("feat: edit", {
|
|
85
|
+
"a.txt": "a1",
|
|
86
|
+
"b.txt": "b1",
|
|
87
|
+
"new.txt": "n1",
|
|
88
|
+
"new-edited.txt": "e1",
|
|
89
|
+
})
|
|
90
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
91
|
+
t.seed.writeFile("b.txt", "b2")
|
|
92
|
+
t.seed.writeFile("new-edited.txt", "e2")
|
|
93
|
+
const script = restoreScript(commit, {
|
|
94
|
+
restore: ["a.txt", "b.txt"],
|
|
95
|
+
remove: ["new.txt", "new-edited.txt"],
|
|
96
|
+
})
|
|
97
|
+
// `sh` exits with the last line's status, and new-edited.txt was left alone.
|
|
98
|
+
expect(run(script).ok).toBe(false)
|
|
99
|
+
expect(read("a.txt")).toBe("a0")
|
|
100
|
+
expect(read("b.txt")).toBe("b2")
|
|
101
|
+
expect(read("new.txt")).toBeUndefined()
|
|
102
|
+
expect(read("new-edited.txt")).toBe("e2")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("restores from the given source, deleting a path the source lacks", () => {
|
|
106
|
+
const first = t.observe.resolveRef("HEAD")
|
|
107
|
+
t.seed.commit("feat: a", { "a.txt": "a1" })
|
|
108
|
+
t.seed.commit("feat: a again", { "a.txt": "a2" })
|
|
109
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
110
|
+
expect(run(restoreScript(commit, { restore: ["a.txt"], remove: [] }, first)).ok).toBe(true)
|
|
111
|
+
expect(read("a.txt")).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("fails on a source that names no commit, touching nothing", () => {
|
|
115
|
+
const root = t.observe.resolveRef("HEAD")
|
|
116
|
+
t.seed.commit("feat: a", { "a.txt": "a1" })
|
|
117
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
118
|
+
const missing = "0".repeat(40)
|
|
119
|
+
expect(run(restoreScript(commit, { restore: ["a.txt"], remove: [] }, missing)).ok).toBe(
|
|
120
|
+
false,
|
|
121
|
+
)
|
|
122
|
+
expect(read("a.txt")).toBe("a1")
|
|
123
|
+
// The default source of a root commit is its absent parent.
|
|
124
|
+
expect(run(restoreScript(root, { restore: ["absent.txt"], remove: [] })).ok).toBe(false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("leaves a deleted path alone", () => {
|
|
128
|
+
t.seed.commit("feat: base", { "a.txt": "a0" })
|
|
129
|
+
t.seed.commit("feat: edit", { "a.txt": "a1" })
|
|
130
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
131
|
+
t.seed.deleteFile("a.txt")
|
|
132
|
+
expect(run(restoreScript(commit, { restore: ["a.txt"], remove: [] })).ok).toBe(false)
|
|
133
|
+
expect(read("a.txt")).toBeUndefined()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe("revertScript", () => {
|
|
138
|
+
it("reverses a commit's modifications, additions and deletions in the working tree", () => {
|
|
139
|
+
t.seed.commit("feat: base", { "mod.txt": "before", "gone.txt": "g" })
|
|
140
|
+
t.seed.commit("feat: change", { "mod.txt": "after", "added.txt": "new" })
|
|
141
|
+
t.seed.commitDeletion("gone.txt", "feat: delete")
|
|
142
|
+
t.seed.commit("feat: squash", { "mod.txt": "after2" })
|
|
143
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
144
|
+
expect(run(revertScript(commit, REPORT, "could not revert")).ok).toBe(true)
|
|
145
|
+
expect(read("mod.txt")).toBe("after")
|
|
146
|
+
expect(read(REPORT)).toBeUndefined()
|
|
147
|
+
expect(t.observe.resolveRef("HEAD")).toBe(commit)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("reverses an addition and a deletion", () => {
|
|
151
|
+
t.seed.commit("feat: base", { "gone.txt": "g" })
|
|
152
|
+
t.seed.writeFile("added.txt", "new")
|
|
153
|
+
t.seed.deleteFile("gone.txt")
|
|
154
|
+
t.seed.stageAll()
|
|
155
|
+
t.seed.commit("feat: swap", {})
|
|
156
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
157
|
+
expect(run(revertScript(commit, REPORT, "could not revert")).ok).toBe(true)
|
|
158
|
+
expect(read("added.txt")).toBeUndefined()
|
|
159
|
+
expect(read("gone.txt")).toBe("g")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("does nothing for a commit with nothing to undo", () => {
|
|
163
|
+
t.seed.commit("feat: empty", {})
|
|
164
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
165
|
+
expect(run(revertScript(commit, REPORT, "could not revert")).ok).toBe(true)
|
|
166
|
+
expect(read(REPORT)).toBeUndefined()
|
|
167
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("reports a revert that cannot apply, leaving the tree untouched", () => {
|
|
171
|
+
t.seed.commit("feat: base", { "a.txt": "a0" })
|
|
172
|
+
t.seed.commit("feat: change", { "a.txt": "a1", "b.txt": "b1" })
|
|
173
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
174
|
+
t.seed.deleteFile("b.txt")
|
|
175
|
+
expect(run(revertScript(commit, REPORT, "could not revert")).ok).toBe(true)
|
|
176
|
+
expect(read(REPORT)).toMatch(/^could not revert\n\n\S/)
|
|
177
|
+
expect(read("a.txt")).toBe("a1")
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("checkScript", () => {
|
|
182
|
+
const options = { report: REPORT, stamp: "abc1234" }
|
|
183
|
+
|
|
184
|
+
it("keeps a failing command's output in the report, stamped", () => {
|
|
185
|
+
expect(run(checkScript(FAILING_WITH_OUTPUT, options)).ok).toBe(true)
|
|
186
|
+
expect(read(REPORT)).toBe("boom\n\n<!-- gtd check abc1234 -->\n")
|
|
187
|
+
expect(read(`${REPORT}.output`)).toBeUndefined()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("says so when a failing command printed nothing", () => {
|
|
191
|
+
run(checkScript(FAILING_SILENTLY, options))
|
|
192
|
+
expect(read(REPORT)).toBe(
|
|
193
|
+
"the test command failed with exit code 4 and produced no output.\n<!-- gtd check abc1234 -->\n",
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("removes the report and sweeps on green", () => {
|
|
198
|
+
t.seed.writeFile(REPORT, "old")
|
|
199
|
+
t.seed.writeFile("stale/a.txt", "a")
|
|
200
|
+
run(checkScript(PASSING, { ...options, sweepOnGreen: ["stale"] }))
|
|
201
|
+
expect(read(REPORT)).toBeUndefined()
|
|
202
|
+
expect(read("stale/a.txt")).toBeUndefined()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("keeps what a passing command wrote", () => {
|
|
206
|
+
run(checkScript(WRITING, options))
|
|
207
|
+
expect(read("out.txt")).toBe("x\n")
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("sweeps before the command, and not on red", () => {
|
|
211
|
+
t.seed.writeFile("before.txt", "b")
|
|
212
|
+
t.seed.writeFile("green.txt", "g")
|
|
213
|
+
run(
|
|
214
|
+
checkScript(FAILING_SILENTLY, {
|
|
215
|
+
...options,
|
|
216
|
+
sweep: ["before.txt"],
|
|
217
|
+
sweepOnGreen: ["green.txt"],
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
expect(read("before.txt")).toBeUndefined()
|
|
221
|
+
expect(read("green.txt")).toBe("g")
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("runs the preamble first; its exit 0 skips the command and leaves the report", () => {
|
|
225
|
+
t.seed.writeFile(REPORT, "old")
|
|
226
|
+
t.seed.writeFile("drop.txt", "d")
|
|
227
|
+
const preamble = [
|
|
228
|
+
"mkdir -p '.gtd'",
|
|
229
|
+
"printf '%s\\n' 'set it\nup' > '.gtd/SETUP.md'",
|
|
230
|
+
"printf '%s\\n' 'set it up' >&2",
|
|
231
|
+
"rm -f 'drop.txt'",
|
|
232
|
+
"exit 0",
|
|
233
|
+
]
|
|
234
|
+
const result = run(checkScript(FAILING_WITH_OUTPUT, { ...options, preamble }))
|
|
235
|
+
expect(result.output).toBe("set it up\n")
|
|
236
|
+
expect(read(".gtd/SETUP.md")).toBe("set it\nup\n")
|
|
237
|
+
expect(read("drop.txt")).toBeUndefined()
|
|
238
|
+
expect(read(REPORT)).toBe("old")
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// The in-memory twin of `sh` for the scripts `src/flows/scripts.ts` builds.
|
|
2
|
+
// Each script is recognized by RE-RUNNING its builder on the values pulled
|
|
3
|
+
// out of it and comparing bytes, then applied to the `InMemRepo` the way the
|
|
4
|
+
// real shell would. `FlowScripts.test.ts` runs the same cases through real
|
|
5
|
+
// `sh` and this twin, so the two cannot drift apart silently.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
checkScript,
|
|
9
|
+
moveScript,
|
|
10
|
+
removeScript,
|
|
11
|
+
restoreScript,
|
|
12
|
+
revertScript,
|
|
13
|
+
showScript,
|
|
14
|
+
} from "../flows/index.js"
|
|
15
|
+
import type { InMemRepo } from "./InMemRepo.js"
|
|
16
|
+
import type { ScriptedCommand } from "./Layers.js"
|
|
17
|
+
|
|
18
|
+
export interface FlowScriptResult {
|
|
19
|
+
readonly ok: boolean
|
|
20
|
+
/** Present exactly when `ok` is `false`. */
|
|
21
|
+
readonly error?: string
|
|
22
|
+
/** What the script printed to output. */
|
|
23
|
+
readonly output: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SHEBANG = "#!/usr/bin/env sh"
|
|
27
|
+
|
|
28
|
+
const words = (text: string): string[] =>
|
|
29
|
+
[...text.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) => m[1]!.replaceAll("'\\''", "'"))
|
|
30
|
+
|
|
31
|
+
const ok = (output = ""): FlowScriptResult => ({ ok: true, output })
|
|
32
|
+
|
|
33
|
+
/** `rm -rf -- <paths>`. `InMemRepo.pathsUnder("")` is the whole tree, where a real `rm -- ''` removes nothing. */
|
|
34
|
+
const removeAll = (repo: InMemRepo, paths: readonly string[]): void => {
|
|
35
|
+
if (paths.includes("")) throw new Error("testkit: rm: refusing an empty path")
|
|
36
|
+
for (const path of paths) for (const file of repo.pathsUnder(path)) repo.deleteFile(file)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const deleteIfPresent = (repo: InMemRepo, path: string): void => {
|
|
40
|
+
if (repo.readFile(path) !== undefined) repo.deleteFile(path)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const recognizeRemove = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
|
|
44
|
+
const line = script.split("\n").find((l) => l.startsWith("rm -rf -- ")) ?? ""
|
|
45
|
+
const paths = words(line)
|
|
46
|
+
if (removeScript(paths) !== script) return undefined
|
|
47
|
+
removeAll(repo, paths)
|
|
48
|
+
return ok()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const recognizeMove = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
|
|
52
|
+
const line = script.split("\n").find((l) => l.startsWith("mv -- ")) ?? ""
|
|
53
|
+
const [from, to] = words(line)
|
|
54
|
+
if (from === undefined || to === undefined || moveScript(from, to) !== script) return undefined
|
|
55
|
+
const content = repo.readFile(from)
|
|
56
|
+
if (content === undefined) return { ok: false, error: `mv: ${from}: No such file`, output: "" }
|
|
57
|
+
repo.deleteFile(from)
|
|
58
|
+
repo.writeFile(to, content)
|
|
59
|
+
return ok()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const recognizeShow = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
|
|
63
|
+
const line = script.split("\n").find((l) => l.startsWith("git show ")) ?? ""
|
|
64
|
+
const [source, to] = words(line)
|
|
65
|
+
const at = source?.indexOf(":") ?? -1
|
|
66
|
+
if (source === undefined || to === undefined || at === -1) return undefined
|
|
67
|
+
const ref = source.slice(0, at)
|
|
68
|
+
const path = source.slice(at + 1)
|
|
69
|
+
if (showScript(ref, path, to) !== script) return undefined
|
|
70
|
+
const content = repo.fileAtRef(ref, path)
|
|
71
|
+
if (content === null)
|
|
72
|
+
return { ok: false, error: `git show: ${source} does not exist`, output: "" }
|
|
73
|
+
repo.writeFile(to, content)
|
|
74
|
+
return ok()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `git diff --quiet <commit> -- <path>`: the worktree copy of a tracked path, or nothing for an untracked one, against `commit`'s. */
|
|
78
|
+
const unchangedSince = (repo: InMemRepo, commit: string, path: string): boolean => {
|
|
79
|
+
const current = repo.isTracked(path) ? (repo.readFile(path) ?? null) : null
|
|
80
|
+
return current === repo.fileAtRef(commit, path)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const RESTORE_LINE = / && git restore --source=/
|
|
84
|
+
const REMOVE_LINE = / && rm -f -- /
|
|
85
|
+
|
|
86
|
+
interface RestoreLine {
|
|
87
|
+
readonly commit: string
|
|
88
|
+
readonly path: string
|
|
89
|
+
/** Set on a restore line, absent on a removal. */
|
|
90
|
+
readonly source?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const parseRestoreLine = (line: string): RestoreLine | undefined => {
|
|
94
|
+
const [commit, path, source] = words(line)
|
|
95
|
+
if (commit === undefined || path === undefined) return undefined
|
|
96
|
+
if (RESTORE_LINE.test(line) && source !== undefined) return { commit, path, source }
|
|
97
|
+
return REMOVE_LINE.test(line) ? { commit, path } : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Applies one line, returning its exit status as `sh` would see it. */
|
|
101
|
+
const applyRestoreLine = (repo: InMemRepo, { commit, path, source }: RestoreLine): boolean => {
|
|
102
|
+
if (!unchangedSince(repo, commit, path)) return false
|
|
103
|
+
// `git restore` rejects a source that names no commit, touching nothing.
|
|
104
|
+
if (source !== undefined && repo.resolveRef(source) === null) return false
|
|
105
|
+
const content = source === undefined ? null : repo.fileAtRef(source, path)
|
|
106
|
+
if (content !== null) repo.writeFile(path, content)
|
|
107
|
+
else if (source === undefined || repo.isTracked(path)) deleteIfPresent(repo, path)
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const recognizeRestore = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
|
|
112
|
+
const parsed = script.split("\n").slice(2, -1).map(parseRestoreLine)
|
|
113
|
+
if (parsed.includes(undefined)) return undefined
|
|
114
|
+
const lines = parsed as RestoreLine[]
|
|
115
|
+
const restoring = lines.filter((l) => l.source !== undefined)
|
|
116
|
+
const paths = {
|
|
117
|
+
restore: restoring.map((l) => l.path),
|
|
118
|
+
remove: lines.filter((l) => l.source === undefined).map((l) => l.path),
|
|
119
|
+
}
|
|
120
|
+
const commit = lines[0]?.commit ?? ""
|
|
121
|
+
if (restoreScript(commit, paths, restoring[0]?.source) !== script) return undefined
|
|
122
|
+
// `sh` exits with the last line's status: a final path left alone fails it.
|
|
123
|
+
const statuses = lines.map((line) => applyRestoreLine(repo, line))
|
|
124
|
+
if (statuses.at(-1) === false) {
|
|
125
|
+
return { ok: false, error: "the last path changed since the commit", output: "" }
|
|
126
|
+
}
|
|
127
|
+
return ok()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const PATCH_FAILED = "error: patch does not apply"
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `git diff C^ C | git apply -R`, which is all or nothing. A file still
|
|
134
|
+
* exactly as `C` left it reverts; a file gone, or one `C` added and that
|
|
135
|
+
* changed since, fails the whole patch. A file `C` modified and that changed
|
|
136
|
+
* since may still apply through its context — that needs real git, so it
|
|
137
|
+
* throws rather than guess.
|
|
138
|
+
*/
|
|
139
|
+
const reverseApply = (repo: InMemRepo, commit: string): string | undefined => {
|
|
140
|
+
const parent = `${commit}^`
|
|
141
|
+
const paths = new Set([...repo.pathsAtRef(parent), ...repo.pathsAtRef(commit)])
|
|
142
|
+
const changed = [...paths].filter((p) => repo.fileAtRef(parent, p) !== repo.fileAtRef(commit, p))
|
|
143
|
+
for (const path of changed) {
|
|
144
|
+
const after = repo.fileAtRef(commit, path)
|
|
145
|
+
const current = repo.readFile(path) ?? null
|
|
146
|
+
if (current === after) continue
|
|
147
|
+
if (current === null || after === null || repo.fileAtRef(parent, path) === null) {
|
|
148
|
+
return `${PATCH_FAILED}: ${path}`
|
|
149
|
+
}
|
|
150
|
+
throw new Error(
|
|
151
|
+
`testkit: cannot tell in memory whether reverting ${commit} applies to the hand-edited ${path} — run this @live`,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
for (const path of changed) {
|
|
155
|
+
const before = repo.fileAtRef(parent, path)
|
|
156
|
+
if (before === null) repo.deleteFile(path)
|
|
157
|
+
else repo.writeFile(path, before)
|
|
158
|
+
}
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const recognizeRevert = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
|
|
163
|
+
const lines = script.split("\n")
|
|
164
|
+
const [, commit] = words(lines.find((l) => l.startsWith("if ! git diff --quiet ")) ?? "")
|
|
165
|
+
const [, failure] = words(lines.find((l) => l.includes("printf '%s\\n\\n' ")) ?? "")
|
|
166
|
+
const [report] = words(lines.find((l) => l.startsWith(" } > ")) ?? "")
|
|
167
|
+
if (commit === undefined || failure === undefined || report === undefined) return undefined
|
|
168
|
+
if (revertScript(commit, report, failure) !== script) return undefined
|
|
169
|
+
const error = reverseApply(repo, commit)
|
|
170
|
+
if (error !== undefined) repo.writeFile(report, `${failure}\n\n${error}\n`)
|
|
171
|
+
return ok()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type PreambleOutcome = { readonly exit: boolean; readonly output: string }
|
|
175
|
+
|
|
176
|
+
/** One preamble line — the closed vocabulary the bundled workflows write there. */
|
|
177
|
+
const runPreambleLine = (repo: InMemRepo, line: string): PreambleOutcome => {
|
|
178
|
+
const quoted = words(line)
|
|
179
|
+
if (line === "exit 0") return { exit: true, output: "" }
|
|
180
|
+
if (line.startsWith("mkdir -p ")) return { exit: false, output: "" }
|
|
181
|
+
if (line.startsWith("rm -f ")) {
|
|
182
|
+
for (const path of quoted) deleteIfPresent(repo, path)
|
|
183
|
+
return { exit: false, output: "" }
|
|
184
|
+
}
|
|
185
|
+
if (line.startsWith("rm -rf -- ")) {
|
|
186
|
+
removeAll(repo, quoted)
|
|
187
|
+
return { exit: false, output: "" }
|
|
188
|
+
}
|
|
189
|
+
const printf = /^printf '%s\\n' '(?:[^']|'\\'')*' (>&2|> '(?:[^']|'\\'')*')$/.exec(line)
|
|
190
|
+
if (printf !== null) {
|
|
191
|
+
const [, text, file] = quoted
|
|
192
|
+
if (printf[1] === ">&2") return { exit: false, output: `${text}\n` }
|
|
193
|
+
repo.writeFile(file!, `${text}\n`)
|
|
194
|
+
return { exit: false, output: "" }
|
|
195
|
+
}
|
|
196
|
+
throw new Error(`testkit: cannot run the preamble line \`${line}\` in memory`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Rejoins lines a single-quoted word spans, so a preamble entry carrying a newline stays one line. */
|
|
200
|
+
const shellLines = (lines: readonly string[]): string[] => {
|
|
201
|
+
const joined: string[] = []
|
|
202
|
+
let open = false
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
if (open) joined[joined.length - 1] += `\n${line}`
|
|
205
|
+
else joined.push(line)
|
|
206
|
+
for (let i = 0; i < line.length; i++) {
|
|
207
|
+
if (line[i] === "'" && (open || line[i - 1] !== "\\")) open = !open
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return joined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface ParsedCheck {
|
|
214
|
+
readonly command: string
|
|
215
|
+
readonly report: string
|
|
216
|
+
readonly stamp: string
|
|
217
|
+
readonly sweep: readonly string[]
|
|
218
|
+
readonly sweepOnGreen: readonly string[]
|
|
219
|
+
readonly preamble: readonly string[]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const outsideCommand = (lines: readonly string[], mkdir: number) => {
|
|
223
|
+
const before = shellLines(lines.slice(2, mkdir))
|
|
224
|
+
const swept = before.at(-1)?.startsWith("rm -rf -- ") === true
|
|
225
|
+
const green = lines.find((l) => l.startsWith(" rm -rf -- "))
|
|
226
|
+
return {
|
|
227
|
+
sweep: swept ? words(before.at(-1)!) : [],
|
|
228
|
+
sweepOnGreen: green === undefined ? [] : words(green),
|
|
229
|
+
preamble: swept ? before.slice(0, -1) : before,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const parseCheck = (script: string): ParsedCheck | undefined => {
|
|
234
|
+
const lines = script.split("\n")
|
|
235
|
+
const mkdir = lines.findIndex((l) => l.startsWith('mkdir -p "$(dirname '))
|
|
236
|
+
const close = lines.findIndex((l, i) => i > mkdir && /^\) > '.*' 2>&1$/.test(l))
|
|
237
|
+
if (mkdir === -1 || lines[mkdir + 1] !== "(" || close === -1) return undefined
|
|
238
|
+
const [report] = words(lines[mkdir]!)
|
|
239
|
+
const [, stamp] = words(lines.find((l) => l.includes("<!-- gtd check %s -->")) ?? "")
|
|
240
|
+
if (report === undefined || stamp === undefined) return undefined
|
|
241
|
+
return {
|
|
242
|
+
command: lines.slice(mkdir + 2, close).join("\n"),
|
|
243
|
+
report,
|
|
244
|
+
stamp,
|
|
245
|
+
...outsideCommand(lines, mkdir),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const runCommand = (
|
|
250
|
+
repo: InMemRepo,
|
|
251
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
252
|
+
command: string,
|
|
253
|
+
): { readonly status: number; readonly output: string } => {
|
|
254
|
+
const scripted = commands.get(command)
|
|
255
|
+
if (scripted === undefined) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`testkit: cannot run \`${command}\` in memory — script it ("the shell command … exits …")`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
if (scripted.kind === "exit") return scripted
|
|
261
|
+
if (scripted.kind === "missing") return { status: 127, output: `sh: ${command}: not found\n` }
|
|
262
|
+
repo.writeFile(scripted.file, rewritten(repo, scripted))
|
|
263
|
+
return { status: 0, output: "" }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** What a scripted rewrite leaves in its file. */
|
|
267
|
+
export const rewritten = (
|
|
268
|
+
repo: InMemRepo,
|
|
269
|
+
command: Extract<ScriptedCommand, { readonly kind: "rewrite" }>,
|
|
270
|
+
): string =>
|
|
271
|
+
command.append === true
|
|
272
|
+
? `${repo.readFile(command.file) ?? ""}${command.content}`
|
|
273
|
+
: command.content
|
|
274
|
+
|
|
275
|
+
const recognizeCheck = (
|
|
276
|
+
repo: InMemRepo,
|
|
277
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
278
|
+
script: string,
|
|
279
|
+
): FlowScriptResult | undefined => {
|
|
280
|
+
const parsed = parseCheck(script)
|
|
281
|
+
if (parsed === undefined) return undefined
|
|
282
|
+
const { command, report, stamp, sweep, sweepOnGreen, preamble } = parsed
|
|
283
|
+
if (checkScript(command, { report, stamp, sweep, sweepOnGreen, preamble }) !== script) {
|
|
284
|
+
return undefined
|
|
285
|
+
}
|
|
286
|
+
let printed = ""
|
|
287
|
+
for (const line of preamble) {
|
|
288
|
+
const outcome = runPreambleLine(repo, line)
|
|
289
|
+
printed += outcome.output
|
|
290
|
+
if (outcome.exit) return ok(printed)
|
|
291
|
+
}
|
|
292
|
+
removeAll(repo, sweep)
|
|
293
|
+
const { status, output } = runCommand(repo, commands, command)
|
|
294
|
+
if (status === 0) {
|
|
295
|
+
deleteIfPresent(repo, report)
|
|
296
|
+
removeAll(repo, sweepOnGreen)
|
|
297
|
+
return ok(printed)
|
|
298
|
+
}
|
|
299
|
+
const body =
|
|
300
|
+
output.length > 0
|
|
301
|
+
? output
|
|
302
|
+
: `the test command failed with exit code ${status} and produced no output.`
|
|
303
|
+
repo.writeFile(report, `${body}\n<!-- gtd check ${stamp} -->\n`)
|
|
304
|
+
return ok(printed)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Applies a `src/flows/scripts.ts` script to `repo`, or `undefined` when
|
|
309
|
+
* `script` is none of them. `commands` stands in for the real programs a
|
|
310
|
+
* check runs, keyed by the exact command string.
|
|
311
|
+
*/
|
|
312
|
+
export const applyFlowScript = (
|
|
313
|
+
repo: InMemRepo,
|
|
314
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
315
|
+
script: string,
|
|
316
|
+
): FlowScriptResult | undefined => {
|
|
317
|
+
if (!script.startsWith(`${SHEBANG}\n`)) return undefined
|
|
318
|
+
return (
|
|
319
|
+
recognizeRemove(repo, script) ??
|
|
320
|
+
recognizeMove(repo, script) ??
|
|
321
|
+
recognizeShow(repo, script) ??
|
|
322
|
+
recognizeRevert(repo, script) ??
|
|
323
|
+
recognizeRestore(repo, script) ??
|
|
324
|
+
recognizeCheck(repo, commands, script)
|
|
325
|
+
)
|
|
326
|
+
}
|