@pmelab/gtd 24.2.0 → 24.4.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 +41 -6
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +1114 -865
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1135 -218
- package/dist/workflow-kit.mjs +9 -7
- package/dist/workflows/feature.mjs +152 -35
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +223 -54
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +10 -9
- package/src/cli/Program.ts +24 -72
- package/src/edge/Edge.test.ts +261 -3
- package/src/edge/Edge.ts +218 -41
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +2 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- 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/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +149 -52
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +269 -47
- package/src/testkit/InMemRepo.ts +61 -8
- 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/workflows/feature/review.test.ts +238 -12
- package/src/workflows/feature/review.ts +172 -31
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/tail.ts +3 -11
- package/src/workflows/shared/text.test.ts +106 -0
- package/src/workflows/shared/text.ts +57 -1
- package/src/workflows/shared/vars.ts +1 -0
package/src/testkit/GitTiers.ts
CHANGED
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
writeFileSync,
|
|
6
6
|
existsSync,
|
|
7
7
|
readFileSync,
|
|
8
|
+
readdirSync,
|
|
8
9
|
realpathSync,
|
|
10
|
+
statSync,
|
|
9
11
|
} from "node:fs"
|
|
10
|
-
import { join, dirname, basename } from "node:path"
|
|
12
|
+
import { join, dirname, basename, sep } from "node:path"
|
|
11
13
|
import { tmpdir } from "node:os"
|
|
12
|
-
import { execSync,
|
|
14
|
+
import { execFileSync, execSync, spawnSync } from "node:child_process"
|
|
13
15
|
// Imported directly (not just from *.test.ts) — a leak of this module into
|
|
14
16
|
// the shipped CLI bundle would try to inline `vitest` and fail the build
|
|
15
17
|
// loudly (`deps.alwaysBundle` in `tsdown.config.ts`), a guard alongside the
|
|
@@ -26,10 +28,12 @@ import {
|
|
|
26
28
|
hardResetTo,
|
|
27
29
|
discardPending,
|
|
28
30
|
updateRef,
|
|
31
|
+
fetchHistoryRefs,
|
|
29
32
|
deleteRef,
|
|
30
33
|
} from "../emit/index.js"
|
|
34
|
+
import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
31
35
|
import { InMemRepo } from "./InMemRepo.js"
|
|
32
|
-
import { gitTestLayer } from "./Layers.js"
|
|
36
|
+
import { gitTestLayer, type ScriptedCommand } from "./Layers.js"
|
|
33
37
|
|
|
34
38
|
export interface GitTierCapabilities {
|
|
35
39
|
/** Assert file BYTES on disk, not just index/HEAD state (`hardResetTo`) — only the Live tier has a real working-tree file to read back. */
|
|
@@ -40,6 +44,8 @@ export interface GitTierCapabilities {
|
|
|
40
44
|
readonly linkedWorktrees: boolean
|
|
41
45
|
/** A cwd→home directory chain for config discovery — both tiers walk it through `Workspace`/`Host` now, so this is `true` for either. */
|
|
42
46
|
readonly directoryChainConfig: boolean
|
|
47
|
+
/** Fetch from another repository — only the Live tier has a remote to reach. */
|
|
48
|
+
readonly remotes: boolean
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface GitTierSeed {
|
|
@@ -58,6 +64,11 @@ export interface GitTierObserve {
|
|
|
58
64
|
readonly refExists: (ref: string) => boolean
|
|
59
65
|
readonly readWorktreeFile: (path: string) => string
|
|
60
66
|
readonly existsPath: (path: string) => boolean
|
|
67
|
+
/** Everything git keeps on disk; equal snapshots mean nothing was written. Settles git's racy-index stat refresh first. */
|
|
68
|
+
readonly snapshot: () => Promise<string>
|
|
69
|
+
readonly headSubject: () => string
|
|
70
|
+
/** Paths HEAD's own commit touched, against its parent — not the whole tree. */
|
|
71
|
+
readonly headTouchedPaths: () => ReadonlyArray<string>
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
export interface GitTier {
|
|
@@ -73,6 +84,15 @@ export interface GitTier {
|
|
|
73
84
|
readonly emptyRepo: () => GitTier
|
|
74
85
|
readonly seed: GitTierSeed
|
|
75
86
|
readonly observe: GitTierObserve
|
|
87
|
+
/**
|
|
88
|
+
* Runs a script gtd hands a driver: real `sh` on Live, the in-memory twin on
|
|
89
|
+
* InMemory. `commands` declares what each program a check runs does — Live
|
|
90
|
+
* runs the real thing, so a contract case passes only when the two agree.
|
|
91
|
+
*/
|
|
92
|
+
readonly runScript: (
|
|
93
|
+
script: string,
|
|
94
|
+
commands?: ReadonlyMap<string, ScriptedCommand>,
|
|
95
|
+
) => { readonly ok: boolean; readonly status: number; readonly output: string }
|
|
76
96
|
readonly dispose: () => void
|
|
77
97
|
}
|
|
78
98
|
|
|
@@ -88,6 +108,18 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
88
108
|
// symlink to `/private/tmp` — `git` reports the resolved path).
|
|
89
109
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-")))
|
|
90
110
|
const gitExec = (...args: string[]) => gitExecIn(root, ...args)
|
|
111
|
+
// Distinct, increasing committer dates, so ref ordering by tip date is deterministic.
|
|
112
|
+
let tick = 0
|
|
113
|
+
const commitEnv = () => ({
|
|
114
|
+
...process.env,
|
|
115
|
+
GIT_COMMITTER_DATE: new Date(Date.UTC(2024, 0, 1, 0, 0, ++tick)).toISOString(),
|
|
116
|
+
})
|
|
117
|
+
const gitCommit = (message: string, allowEmpty = false) =>
|
|
118
|
+
execFileSync("git", ["commit", ...(allowEmpty ? ["--allow-empty"] : []), "-m", message], {
|
|
119
|
+
cwd: root,
|
|
120
|
+
env: commitEnv(),
|
|
121
|
+
stdio: "pipe",
|
|
122
|
+
})
|
|
91
123
|
gitExec("init")
|
|
92
124
|
gitExec(`config user.email "test@test.com"`)
|
|
93
125
|
gitExec(`config user.name "Test"`)
|
|
@@ -95,7 +127,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
95
127
|
if (initialCommit) {
|
|
96
128
|
writeFileSync(join(root, "readme.txt"), "hello")
|
|
97
129
|
gitExec("add", "-A")
|
|
98
|
-
|
|
130
|
+
gitCommit("init: first commit")
|
|
99
131
|
}
|
|
100
132
|
|
|
101
133
|
const provide = <A>(eff: Effect.Effect<A, Error, GitService | Narrator>): Promise<A> =>
|
|
@@ -128,6 +160,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
128
160
|
commitHooks: true,
|
|
129
161
|
linkedWorktrees: true,
|
|
130
162
|
directoryChainConfig: true,
|
|
163
|
+
remotes: true,
|
|
131
164
|
},
|
|
132
165
|
provide,
|
|
133
166
|
provideExit,
|
|
@@ -142,7 +175,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
142
175
|
// --allow-empty: a caller passing `files: {}` (no prior staged
|
|
143
176
|
// change either) means a deliberate empty commit — the state-entry
|
|
144
177
|
// shape many test seeders rely on.
|
|
145
|
-
|
|
178
|
+
gitCommit(message, true)
|
|
146
179
|
},
|
|
147
180
|
writeFile: (path, content) => {
|
|
148
181
|
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
@@ -174,11 +207,51 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
174
207
|
},
|
|
175
208
|
readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
|
|
176
209
|
existsPath: (path) => existsSync(join(root, path)),
|
|
210
|
+
snapshot: async () => {
|
|
211
|
+
// A tracked file's mtime in the same second as the index makes the next
|
|
212
|
+
// `git status` rewrite the index — git's own racy-git refresh, not a write
|
|
213
|
+
// of ours. Let the clock pass it before taking the baseline.
|
|
214
|
+
gitExec("status", "--porcelain")
|
|
215
|
+
await new Promise((r) => setTimeout(r, 1100))
|
|
216
|
+
gitExec("status", "--porcelain")
|
|
217
|
+
const gitDir = join(root, ".git")
|
|
218
|
+
const files = readdirSync(gitDir, { recursive: true, withFileTypes: true })
|
|
219
|
+
.filter((entry) => entry.isFile())
|
|
220
|
+
.map((entry) => join(entry.parentPath, entry.name))
|
|
221
|
+
.filter((path) => !path.includes(`${sep}gtd-cache${sep}`))
|
|
222
|
+
.sort()
|
|
223
|
+
.map((path) => `${path}:${statSync(path).size}:${statSync(path).mtimeMs}`)
|
|
224
|
+
return JSON.stringify({ files, status: gitExec("status", "--porcelain", "-uall") })
|
|
225
|
+
},
|
|
226
|
+
headSubject: () => gitExec("log", "-1", "--pretty=%s"),
|
|
227
|
+
headTouchedPaths: () =>
|
|
228
|
+
gitExec("show", "--name-only", "--pretty=format:", "HEAD")
|
|
229
|
+
.split("\n")
|
|
230
|
+
.filter((line) => line.length > 0),
|
|
231
|
+
},
|
|
232
|
+
runScript: (script) => {
|
|
233
|
+
// One stream, as a driver's terminal shows it.
|
|
234
|
+
const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
|
|
235
|
+
cwd: root,
|
|
236
|
+
encoding: "utf8",
|
|
237
|
+
env: { ...process.env, PATH: `${gtdShimDir()}:${process.env["PATH"] ?? ""}` },
|
|
238
|
+
})
|
|
239
|
+
return { ok: result.status === 0, status: result.status ?? 1, output: result.stdout }
|
|
177
240
|
},
|
|
178
241
|
dispose: () => rmSync(root, { recursive: true, force: true }),
|
|
179
242
|
}
|
|
180
243
|
}
|
|
181
244
|
|
|
245
|
+
/** A dir whose `gtd` runs this build's bundle, so a script's bare `gtd` is the code under test. */
|
|
246
|
+
let shimDir: string | undefined
|
|
247
|
+
const gtdShimDir = (): string => {
|
|
248
|
+
if (shimDir !== undefined) return shimDir
|
|
249
|
+
shimDir = mkdtempSync(join(tmpdir(), "gtd-tier-shim-"))
|
|
250
|
+
const bundle = join(import.meta.dirname, "../../dist/gtd.bundle.mjs")
|
|
251
|
+
writeFileSync(join(shimDir, "gtd"), `#!/bin/sh\nexec node "${bundle}" "$@"\n`, { mode: 0o755 })
|
|
252
|
+
return shimDir
|
|
253
|
+
}
|
|
254
|
+
|
|
182
255
|
const IN_MEM_ROOT = "/repo"
|
|
183
256
|
|
|
184
257
|
const makeInMemTier = (initialCommit = true): GitTier => {
|
|
@@ -205,6 +278,7 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
205
278
|
commitHooks: false,
|
|
206
279
|
linkedWorktrees: false,
|
|
207
280
|
directoryChainConfig: true,
|
|
281
|
+
remotes: false,
|
|
208
282
|
},
|
|
209
283
|
provide,
|
|
210
284
|
provideExit,
|
|
@@ -239,6 +313,19 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
239
313
|
return content
|
|
240
314
|
},
|
|
241
315
|
existsPath: (path) => repo.hasPath(path),
|
|
316
|
+
snapshot: async () => repo.snapshot(),
|
|
317
|
+
headSubject: () => repo.lastCommitSubject() ?? "",
|
|
318
|
+
headTouchedPaths: () => {
|
|
319
|
+
const paths = new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])
|
|
320
|
+
return [...paths]
|
|
321
|
+
.filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p))
|
|
322
|
+
.sort()
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
runScript: (script, commands = new Map()) => {
|
|
326
|
+
const { ok, status, output, error } = applyEmittedScript(repo, commands, script)
|
|
327
|
+
if (error?.startsWith("unrecognized script block:") === true) throw new Error(error)
|
|
328
|
+
return { ok, status, output }
|
|
242
329
|
},
|
|
243
330
|
dispose: () => {},
|
|
244
331
|
}
|
|
@@ -259,6 +346,8 @@ const runGitExit = <A>(
|
|
|
259
346
|
export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new Set([
|
|
260
347
|
"lastCommitSubject",
|
|
261
348
|
"lastCommitMessage",
|
|
349
|
+
"currentBranch",
|
|
350
|
+
"listRefs",
|
|
262
351
|
"hasCommits",
|
|
263
352
|
"resolveRef",
|
|
264
353
|
"readRefOption",
|
|
@@ -321,6 +410,36 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
321
410
|
t.dispose()
|
|
322
411
|
})
|
|
323
412
|
|
|
413
|
+
it("no reader writes to the repository", async () => {
|
|
414
|
+
t.seed.commit("feat: second", { "second.txt": "two" })
|
|
415
|
+
t.seed.writeFile("dirty.txt", "dirty")
|
|
416
|
+
const before = await t.observe.snapshot()
|
|
417
|
+
await runGit(t, (g) =>
|
|
418
|
+
Effect.all([
|
|
419
|
+
g.lastCommitSubject(),
|
|
420
|
+
g.lastCommitMessage(),
|
|
421
|
+
g.hasCommits(),
|
|
422
|
+
g.resolveRef("HEAD"),
|
|
423
|
+
g.readRefOption("refs/gtd/none"),
|
|
424
|
+
g.isAncestor("HEAD~1", "HEAD"),
|
|
425
|
+
g.mergeBase("HEAD~1", "HEAD"),
|
|
426
|
+
g.defaultBranch(),
|
|
427
|
+
g.topLevel(),
|
|
428
|
+
g.gitDir(),
|
|
429
|
+
g.gitCommonDir(),
|
|
430
|
+
g.commitHistory(),
|
|
431
|
+
g.subjectHistory(10, 0),
|
|
432
|
+
g.readFileAtRef("HEAD", "second.txt"),
|
|
433
|
+
g.changedPaths(),
|
|
434
|
+
g.changedPaths("HEAD~1"),
|
|
435
|
+
]),
|
|
436
|
+
)
|
|
437
|
+
expect(await t.observe.snapshot()).toBe(before)
|
|
438
|
+
// The negative control: a snapshot that saw nothing would pass the above vacuously.
|
|
439
|
+
await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")))
|
|
440
|
+
expect(await t.observe.snapshot()).not.toBe(before)
|
|
441
|
+
})
|
|
442
|
+
|
|
324
443
|
describe("resolveRef", () => {
|
|
325
444
|
it("resolves HEAD to a 40-char hash", async () => {
|
|
326
445
|
const hash = await runGit(t, (g) => g.resolveRef("HEAD"))
|
|
@@ -367,6 +486,73 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
367
486
|
})
|
|
368
487
|
})
|
|
369
488
|
|
|
489
|
+
describe("lastCommitMessage with a ref", () => {
|
|
490
|
+
it("reads the given ref's full message, not HEAD's", async () => {
|
|
491
|
+
t.seed.commit("feat: first\n\nGtd-Branch: one")
|
|
492
|
+
t.seed.commit("feat: second", { "extra.txt": "extra" })
|
|
493
|
+
const message = await runGit(t, (g) => g.lastCommitMessage("HEAD~1"))
|
|
494
|
+
expect(message).toBe("feat: first\n\nGtd-Branch: one")
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
describe("currentBranch", () => {
|
|
499
|
+
it("is the checked-out branch's name", async () => {
|
|
500
|
+
const branch = await runGit(t, (g) => g.currentBranch())
|
|
501
|
+
expect(Option.isSome(branch)).toBe(true)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it.skipIf(!capabilities.onDiskContent)(
|
|
505
|
+
"is none on a detached HEAD, never a failure",
|
|
506
|
+
async () => {
|
|
507
|
+
gitExecIn(t.root, "checkout", "--detach", "HEAD")
|
|
508
|
+
const branch = await runGit(t, (g) => g.currentBranch())
|
|
509
|
+
expect(Option.isNone(branch)).toBe(true)
|
|
510
|
+
},
|
|
511
|
+
)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
describe("listRefs", () => {
|
|
515
|
+
it("is [] when no ref lives under the prefix", async () => {
|
|
516
|
+
const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
|
|
517
|
+
expect(refs).toEqual([])
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it("lists {ref, hash, date} for refs under the prefix only, newest tip first", async () => {
|
|
521
|
+
const first = t.observe.resolveRef("HEAD")
|
|
522
|
+
t.seed.commit("feat: second", { "second.txt": "2" })
|
|
523
|
+
const second = t.observe.resolveRef("HEAD")
|
|
524
|
+
t.seed.updateRef("refs/gtd/history/old", first)
|
|
525
|
+
t.seed.updateRef("refs/gtd/history/new", second)
|
|
526
|
+
t.seed.updateRef("refs/gtd/other/elsewhere", second)
|
|
527
|
+
const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
|
|
528
|
+
expect(refs.map((r) => [r.ref, r.hash])).toEqual([
|
|
529
|
+
["refs/gtd/history/new", second],
|
|
530
|
+
["refs/gtd/history/old", first],
|
|
531
|
+
])
|
|
532
|
+
for (const r of refs) expect(r.date.length).toBeGreaterThan(0)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it("reports the date as ISO 8601 strict", async () => {
|
|
536
|
+
t.seed.updateRef("refs/gtd/history/x", t.observe.resolveRef("HEAD"))
|
|
537
|
+
const [entry] = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
|
|
538
|
+
expect(entry?.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/)
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
describe("history reachable only from a retained ref", () => {
|
|
543
|
+
it("commitHistory and readFileAtRef read it after HEAD moved off those commits", async () => {
|
|
544
|
+
t.seed.commit("feat: second", { "kept.txt": "kept" })
|
|
545
|
+
const tip = t.observe.resolveRef("HEAD")
|
|
546
|
+
t.seed.updateRef("refs/gtd/history/tip", tip)
|
|
547
|
+
t.seed.mixedReset("HEAD~1")
|
|
548
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(tip)
|
|
549
|
+
const history = await runGit(t, (g) => g.commitHistory(undefined, "refs/gtd/history/tip"))
|
|
550
|
+
expect(history.map((c) => c.message)).toEqual(["init: first commit", "feat: second"])
|
|
551
|
+
const content = await runGit(t, (g) => g.readFileAtRef("refs/gtd/history/tip", "kept.txt"))
|
|
552
|
+
expect(content).toBe("kept")
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
|
|
370
556
|
describe("hasCommits", () => {
|
|
371
557
|
it("returns false in an empty repo", async () => {
|
|
372
558
|
const empty = t.emptyRepo()
|
|
@@ -916,35 +1102,20 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
916
1102
|
}
|
|
917
1103
|
|
|
918
1104
|
/**
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
* `&&`, a misquoted path) fails here exactly as it would in production.
|
|
1105
|
+
* The SAME writer postconditions `runGitServiceContract` asserts against
|
|
1106
|
+
* `GitOperations`, asserted here against `GitScript.ts`'s builders as a driver
|
|
1107
|
+
* runs them: real `sh` on Live, the in-memory twin on InMemory.
|
|
923
1108
|
*/
|
|
924
|
-
const
|
|
925
|
-
|
|
1109
|
+
const run = (t: GitTier, script: string): void => {
|
|
1110
|
+
const result = t.runScript(script)
|
|
1111
|
+
if (!result.ok) throw new Error(`script failed on ${t.name}: ${result.output}`)
|
|
926
1112
|
}
|
|
927
1113
|
|
|
928
|
-
|
|
929
|
-
const headTouchedPaths = (t: GitTier): ReadonlyArray<string> =>
|
|
930
|
-
gitExecIn(t.root, "show", "--name-only", "--pretty=format:", "HEAD")
|
|
931
|
-
.split("\n")
|
|
932
|
-
.filter((line) => line.length > 0)
|
|
933
|
-
|
|
934
|
-
const headSubject = (t: GitTier): string => gitExecIn(t.root, "log", "-1", "--pretty=%s")
|
|
935
|
-
|
|
936
|
-
/**
|
|
937
|
-
* The SAME 9 writer postconditions `runGitServiceContract` asserts against
|
|
938
|
-
* `GitOperations`, asserted here against `GitScript.ts`'s bash builders
|
|
939
|
-
* instead — driven through real `bash` against a real repo (`makeLiveTier`),
|
|
940
|
-
* never the in-memory fake, since a fake root (`/repo`) has no shell to run
|
|
941
|
-
* against.
|
|
942
|
-
*/
|
|
943
|
-
export const runGitScriptContract = (): void => {
|
|
1114
|
+
export const runGitScriptContract = (makeTier: () => GitTier): void => {
|
|
944
1115
|
let t: GitTier
|
|
945
1116
|
|
|
946
1117
|
beforeEach(() => {
|
|
947
|
-
t =
|
|
1118
|
+
t = makeTier()
|
|
948
1119
|
})
|
|
949
1120
|
|
|
950
1121
|
afterEach(() => {
|
|
@@ -955,18 +1126,18 @@ export const runGitScriptContract = (): void => {
|
|
|
955
1126
|
it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
|
|
956
1127
|
t.seed.writeFile("new.ts", "export const x = 1")
|
|
957
1128
|
const headBefore = t.observe.resolveRef("HEAD")
|
|
958
|
-
|
|
1129
|
+
run(t, commitAll("gtd: building"))
|
|
959
1130
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
960
|
-
expect(headSubject(
|
|
961
|
-
expect(headTouchedPaths(
|
|
1131
|
+
expect(t.observe.headSubject()).toBe("gtd: building")
|
|
1132
|
+
expect(t.observe.headTouchedPaths()).toEqual(["new.ts"])
|
|
962
1133
|
expect(t.observe.statusPorcelain().trim()).toBe("")
|
|
963
1134
|
})
|
|
964
1135
|
|
|
965
1136
|
it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
|
|
966
1137
|
const headBefore = t.observe.resolveRef("HEAD")
|
|
967
|
-
|
|
1138
|
+
run(t, commitAll("gtd: grilled"))
|
|
968
1139
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
969
|
-
expect(headSubject(
|
|
1140
|
+
expect(t.observe.headSubject()).toBe("gtd: grilled")
|
|
970
1141
|
})
|
|
971
1142
|
})
|
|
972
1143
|
|
|
@@ -975,16 +1146,16 @@ export const runGitScriptContract = (): void => {
|
|
|
975
1146
|
t.seed.writeFile("a.txt", "a")
|
|
976
1147
|
t.seed.stageAll()
|
|
977
1148
|
t.seed.writeFile("b.txt", "b")
|
|
978
|
-
|
|
979
|
-
expect(headSubject(
|
|
980
|
-
expect(headTouchedPaths(
|
|
1149
|
+
run(t, commitAsIs("feat: squash"))
|
|
1150
|
+
expect(t.observe.headSubject()).toBe("feat: squash")
|
|
1151
|
+
expect(t.observe.headTouchedPaths()).toEqual(["a.txt"])
|
|
981
1152
|
})
|
|
982
1153
|
|
|
983
1154
|
it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
|
|
984
1155
|
const headBefore = t.observe.resolveRef("HEAD")
|
|
985
|
-
|
|
1156
|
+
run(t, commitAsIs("gtd: empty squash"))
|
|
986
1157
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
987
|
-
expect(headSubject(
|
|
1158
|
+
expect(t.observe.headSubject()).toBe("gtd: empty squash")
|
|
988
1159
|
})
|
|
989
1160
|
})
|
|
990
1161
|
|
|
@@ -992,7 +1163,7 @@ export const runGitScriptContract = (): void => {
|
|
|
992
1163
|
it("moves HEAD back but keeps worktree changes from the second commit", () => {
|
|
993
1164
|
const firstHash = t.observe.resolveRef("HEAD")
|
|
994
1165
|
t.seed.commit("feat: second", { "second.txt": "second content" })
|
|
995
|
-
|
|
1166
|
+
run(t, softResetTo(firstHash))
|
|
996
1167
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
997
1168
|
expect(t.observe.statusPorcelain()).toContain("second.txt")
|
|
998
1169
|
})
|
|
@@ -1002,7 +1173,7 @@ export const runGitScriptContract = (): void => {
|
|
|
1002
1173
|
it("moves HEAD, index, and working tree content back to the target ref", () => {
|
|
1003
1174
|
const firstHash = t.observe.resolveRef("HEAD")
|
|
1004
1175
|
t.seed.commit("feat: second", { "readme.txt": "modified content" })
|
|
1005
|
-
|
|
1176
|
+
run(t, hardResetTo(firstHash))
|
|
1006
1177
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
1007
1178
|
expect(t.observe.statusPorcelain()).toBe("")
|
|
1008
1179
|
expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello")
|
|
@@ -1013,7 +1184,7 @@ export const runGitScriptContract = (): void => {
|
|
|
1013
1184
|
it("moves HEAD and index, leaving the working tree untouched", () => {
|
|
1014
1185
|
const firstHash = t.observe.resolveRef("HEAD")
|
|
1015
1186
|
t.seed.commit("feat: second", { "second.txt": "second content" })
|
|
1016
|
-
|
|
1187
|
+
run(t, mixedResetTo(firstHash))
|
|
1017
1188
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
1018
1189
|
expect(t.observe.statusPorcelain()).toContain("second.txt")
|
|
1019
1190
|
})
|
|
@@ -1023,7 +1194,7 @@ export const runGitScriptContract = (): void => {
|
|
|
1023
1194
|
it("drops both tracked modifications and untracked files", () => {
|
|
1024
1195
|
t.seed.writeFile("readme.txt", "modified")
|
|
1025
1196
|
t.seed.writeFile("untracked.txt", "new")
|
|
1026
|
-
|
|
1197
|
+
run(t, discardPending())
|
|
1027
1198
|
expect(t.observe.statusPorcelain()).toBe("")
|
|
1028
1199
|
})
|
|
1029
1200
|
})
|
|
@@ -1031,22 +1202,73 @@ export const runGitScriptContract = (): void => {
|
|
|
1031
1202
|
describe("updateRef / deleteRef", () => {
|
|
1032
1203
|
it("updateRef points a ref at a commit", () => {
|
|
1033
1204
|
const head = t.observe.resolveRef("HEAD")
|
|
1034
|
-
|
|
1205
|
+
run(t, updateRef("refs/gtd/marker", head))
|
|
1035
1206
|
expect(t.observe.refExists("refs/gtd/marker")).toBe(true)
|
|
1036
1207
|
expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head)
|
|
1037
1208
|
})
|
|
1038
1209
|
|
|
1210
|
+
it("updateRef with HEAD points the ref at the commit just made", () => {
|
|
1211
|
+
run(t, commitAll("feat: just made"))
|
|
1212
|
+
run(t, updateRef("refs/gtd/history/x", "HEAD"))
|
|
1213
|
+
expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"))
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1039
1216
|
it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
|
|
1040
1217
|
const head = t.observe.resolveRef("HEAD")
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1218
|
+
run(t, updateRef("refs/gtd/marker", head))
|
|
1219
|
+
run(t, deleteRef("refs/gtd/marker"))
|
|
1220
|
+
run(t, deleteRef("refs/gtd/marker"))
|
|
1044
1221
|
expect(t.observe.refExists("refs/gtd/marker")).toBe(false)
|
|
1045
1222
|
})
|
|
1046
1223
|
|
|
1047
1224
|
it("deleteRef tolerates a ref that was never created", () => {
|
|
1048
|
-
expect(() =>
|
|
1225
|
+
expect(() => run(t, deleteRef("refs/gtd/never-created"))).not.toThrow()
|
|
1049
1226
|
expect(t.observe.refExists("refs/gtd/never-created")).toBe(false)
|
|
1050
1227
|
})
|
|
1051
1228
|
})
|
|
1229
|
+
|
|
1230
|
+
describe("fetchHistoryRefs", () => {
|
|
1231
|
+
it("pulls every refs/gtd/history/* from a bare remote and prints the count", (ctx) => {
|
|
1232
|
+
if (!t.capabilities.remotes) ctx.skip()
|
|
1233
|
+
const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
|
|
1234
|
+
try {
|
|
1235
|
+
gitExecIn(bare, "init", "-q", "--bare")
|
|
1236
|
+
gitExecIn(t.root, "remote", "add", "origin", bare)
|
|
1237
|
+
const head = t.observe.resolveRef("HEAD")
|
|
1238
|
+
gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/one`)
|
|
1239
|
+
gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/two`)
|
|
1240
|
+
gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/other/skip`)
|
|
1241
|
+
const out = execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
|
|
1242
|
+
cwd: t.root,
|
|
1243
|
+
encoding: "utf8",
|
|
1244
|
+
})
|
|
1245
|
+
expect(t.observe.refExists("refs/gtd/history/one")).toBe(true)
|
|
1246
|
+
expect(t.observe.refExists("refs/gtd/history/two")).toBe(true)
|
|
1247
|
+
expect(t.observe.refExists("refs/gtd/other/skip")).toBe(false)
|
|
1248
|
+
expect(out.trim()).toBe("2 history refs arrived")
|
|
1249
|
+
} finally {
|
|
1250
|
+
rmSync(bare, { recursive: true, force: true })
|
|
1251
|
+
}
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
it("fails loudly when a local history ref conflicts with the remote's", (ctx) => {
|
|
1255
|
+
if (!t.capabilities.remotes) ctx.skip()
|
|
1256
|
+
const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
|
|
1257
|
+
try {
|
|
1258
|
+
gitExecIn(bare, "init", "-q", "--bare")
|
|
1259
|
+
gitExecIn(t.root, "remote", "add", "origin", bare)
|
|
1260
|
+
const first = t.observe.resolveRef("HEAD")
|
|
1261
|
+
t.seed.commit("feat: second", { "second.txt": "x" })
|
|
1262
|
+
const second = t.observe.resolveRef("HEAD")
|
|
1263
|
+
gitExecIn(t.root, "push", "-q", "origin", `${first}:refs/gtd/history/one`)
|
|
1264
|
+
t.seed.updateRef("refs/gtd/history/one", second)
|
|
1265
|
+
expect(() =>
|
|
1266
|
+
execFileSync("bash", ["-c", fetchHistoryRefs("origin")], { cwd: t.root, stdio: "pipe" }),
|
|
1267
|
+
).toThrow()
|
|
1268
|
+
expect(t.observe.resolveRef("refs/gtd/history/one")).toBe(second)
|
|
1269
|
+
} finally {
|
|
1270
|
+
rmSync(bare, { recursive: true, force: true })
|
|
1271
|
+
}
|
|
1272
|
+
})
|
|
1273
|
+
})
|
|
1052
1274
|
}
|
package/src/testkit/InMemRepo.ts
CHANGED
|
@@ -9,6 +9,8 @@ interface Commit {
|
|
|
9
9
|
message: string
|
|
10
10
|
files: Map<string, string> // full tree snapshot (path → UTF-8 content)
|
|
11
11
|
parent: string | null
|
|
12
|
+
/** Creation order — the twin's stand-in for a committer date. */
|
|
13
|
+
seq: number
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
function sha1(input: string): string {
|
|
@@ -35,6 +37,7 @@ export class InMemRepo {
|
|
|
35
37
|
private currentBranch: string = "main"
|
|
36
38
|
private worktree: Map<string, string> = new Map()
|
|
37
39
|
private index: Map<string, string> = new Map()
|
|
40
|
+
private nextSeq = 1
|
|
38
41
|
|
|
39
42
|
private getCommit(hash: string): Commit | null {
|
|
40
43
|
return this.commits.get(hash) ?? null
|
|
@@ -112,10 +115,11 @@ export class InMemRepo {
|
|
|
112
115
|
return this.commits.has(ref) ? ref : null
|
|
113
116
|
}
|
|
114
117
|
|
|
115
|
-
const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+))?$/.exec(ref)
|
|
118
|
+
const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref)
|
|
116
119
|
if (tildeMatch) {
|
|
117
120
|
const base = tildeMatch[1]!
|
|
118
|
-
const steps =
|
|
121
|
+
const steps =
|
|
122
|
+
tildeMatch[3] !== undefined ? parseInt(tildeMatch[3], 10) : tildeMatch[2] === "^" ? 1 : 0
|
|
119
123
|
let cur: string | null = base === "HEAD" ? this.head : this.commits.has(base) ? base : null
|
|
120
124
|
for (let i = 0; i < steps; i++) {
|
|
121
125
|
if (cur === null) return null
|
|
@@ -137,10 +141,27 @@ export class InMemRepo {
|
|
|
137
141
|
return c.message.split("\n")[0] ?? null
|
|
138
142
|
}
|
|
139
143
|
|
|
140
|
-
lastCommitMessage(): string | null {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
return c.message
|
|
144
|
+
lastCommitMessage(ref?: string): string | null {
|
|
145
|
+
const hash = ref === undefined ? this.head : this.resolveRef(ref)
|
|
146
|
+
const c = hash === null ? null : this.getCommit(hash)
|
|
147
|
+
return c === null ? null : c.message
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
branchName(): string {
|
|
151
|
+
return this.currentBranch
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
|
|
155
|
+
listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
|
|
156
|
+
return [...this.refs.entries()]
|
|
157
|
+
.filter(([ref]) => ref.startsWith(prefix))
|
|
158
|
+
.map(([ref, hash]) => ({ ref, hash, seq: this.getCommit(hash)?.seq ?? 0 }))
|
|
159
|
+
.sort((a, b) => b.seq - a.seq)
|
|
160
|
+
.map(({ ref, hash, seq }) => ({
|
|
161
|
+
ref,
|
|
162
|
+
hash,
|
|
163
|
+
date: new Date(seq * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
164
|
+
}))
|
|
144
165
|
}
|
|
145
166
|
|
|
146
167
|
/**
|
|
@@ -277,6 +298,38 @@ export class InMemRepo {
|
|
|
277
298
|
.sort()
|
|
278
299
|
}
|
|
279
300
|
|
|
301
|
+
/** Everything git would keep on disk, serialized — two snapshots compare equal exactly when nothing was written. */
|
|
302
|
+
snapshot(): string {
|
|
303
|
+
const sorted = (m: Map<string, string>) =>
|
|
304
|
+
[...m.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
305
|
+
return JSON.stringify({
|
|
306
|
+
head: this.head,
|
|
307
|
+
branch: this.currentBranch,
|
|
308
|
+
commits: [...this.commits.keys()].sort(),
|
|
309
|
+
branches: sorted(this.branches),
|
|
310
|
+
refs: sorted(this.refs),
|
|
311
|
+
index: sorted(this.index),
|
|
312
|
+
worktree: sorted(this.worktree),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Every ref name under `prefix` (`refs/…`), sorted — `git for-each-ref <prefix>`. */
|
|
317
|
+
refNamesUnder(prefix: string): ReadonlyArray<string> {
|
|
318
|
+
return [...this.refs.keys()].filter((ref) => ref.startsWith(prefix)).sort()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Whether `path` is in the index — what `git diff <commit> -- <path>` compares against the worktree. */
|
|
322
|
+
isTracked(path: string): boolean {
|
|
323
|
+
return this.index.has(path)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** `git add <path>` — the index takes `path` as the worktree has it, or drops it. */
|
|
327
|
+
stage(path: string): void {
|
|
328
|
+
const content = this.worktree.get(path)
|
|
329
|
+
if (content === undefined) this.index.delete(path)
|
|
330
|
+
else this.index.set(path, content)
|
|
331
|
+
}
|
|
332
|
+
|
|
280
333
|
/** `git add -A` — the index becomes exactly the current worktree. */
|
|
281
334
|
stageAll(): void {
|
|
282
335
|
this.index = new Map(this.worktree)
|
|
@@ -287,7 +340,7 @@ export class InMemRepo {
|
|
|
287
340
|
const tree = new Map(this.index)
|
|
288
341
|
const parent = this.head
|
|
289
342
|
const hash = makeHash(message, parent, tree)
|
|
290
|
-
const commit: Commit = { hash, message, files: new Map(tree), parent }
|
|
343
|
+
const commit: Commit = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
|
|
291
344
|
this.commits.set(hash, commit)
|
|
292
345
|
this.head = hash
|
|
293
346
|
this.branches.set(this.currentBranch, hash)
|
|
@@ -307,7 +360,7 @@ export class InMemRepo {
|
|
|
307
360
|
const parent = this.head
|
|
308
361
|
const hash = makeHash(message, parent, tree)
|
|
309
362
|
|
|
310
|
-
const commit: Commit = { hash, message, files: new Map(tree), parent }
|
|
363
|
+
const commit: Commit = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
|
|
311
364
|
this.commits.set(hash, commit)
|
|
312
365
|
this.head = hash
|
|
313
366
|
this.branches.set(this.currentBranch, hash)
|
package/src/testkit/Layers.ts
CHANGED
|
@@ -158,7 +158,7 @@ const makeInMemoryConfigDiscovery = (
|
|
|
158
158
|
for (const dir of walkUp(levelRoot, levelHome)) {
|
|
159
159
|
const filepath = join(dir, WORKFLOW_MODULE)
|
|
160
160
|
const source = workspace.atPath(filepath)
|
|
161
|
-
if (source !== undefined) return { filepath, source }
|
|
161
|
+
if (source !== undefined) return { filepath, source, read: workspace.atPath }
|
|
162
162
|
}
|
|
163
163
|
return undefined
|
|
164
164
|
},
|
|
@@ -170,7 +170,15 @@ const makeInMemoryConfigDiscovery = (
|
|
|
170
170
|
/** One scripted `bash` command's canned behavior, keyed by the RENDERED command string a scenario's `Given` step declares. */
|
|
171
171
|
export type ScriptedCommand =
|
|
172
172
|
| { readonly kind: "exit"; readonly status: number; readonly output: string }
|
|
173
|
-
| {
|
|
173
|
+
| {
|
|
174
|
+
readonly kind: "rewrite"
|
|
175
|
+
readonly file: string
|
|
176
|
+
readonly content: string
|
|
177
|
+
/** Adds `content` to the file's end instead of replacing it. */
|
|
178
|
+
readonly append?: boolean
|
|
179
|
+
}
|
|
180
|
+
/** Keyed by the binary's name: it is not on `$PATH`. */
|
|
181
|
+
| { readonly kind: "missing" }
|
|
174
182
|
|
|
175
183
|
export interface TestWorldOptions {
|
|
176
184
|
readonly env?: Readonly<Record<string, string | undefined>>
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { runCli } from "../cli/index.js"
|
|
|
4
4
|
import { EXIT_OK, type BeatDocument } from "../wire/index.js"
|
|
5
5
|
import { makeCapturingCliIo, type CapturedCliResult } from "./cliIo.js"
|
|
6
6
|
import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
7
|
+
import { applyFlowScript } from "./FlowScripts.js"
|
|
7
8
|
import { InMemRepo } from "./InMemRepo.js"
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -247,7 +248,9 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
247
248
|
commit: (message) => repo.commitAllWithPrefix(message),
|
|
248
249
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
249
250
|
runScript: (script) => {
|
|
250
|
-
|
|
251
|
+
const flow = applyFlowScript(repo, new Map(), script)
|
|
252
|
+
if (flow === undefined) for (const line of script.split("\n")) runLine(repo, line.trim())
|
|
253
|
+
else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
|
|
251
254
|
},
|
|
252
255
|
gtd,
|
|
253
256
|
start: (workflow, ...args) => begin(["--workflow", workflow, ...args]),
|