@pmelab/gtd 26.0.0 → 26.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -0
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +134 -18
- package/dist/testkit.mjs +284 -55
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +3 -3
- package/src/cli/History.test.ts +2 -2
- package/src/cli/Program.test.ts +10 -12
- package/src/edge/Edge.test.ts +113 -16
- package/src/edge/Edge.ts +22 -6
- package/src/emit/GitScript.test.ts +65 -2
- package/src/emit/GitScript.ts +34 -1
- package/src/emit/index.ts +1 -0
- package/src/flags/index.ts +0 -1
- package/src/flows/runtime.test.ts +33 -0
- package/src/flows/runtime.ts +31 -0
- package/src/platform/RetainedHistory.test.ts +9 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/Workspace.test.ts +1 -1
- package/src/platform/index.ts +1 -0
- package/src/replay/RefName.test.ts +40 -0
- package/src/replay/RefName.ts +19 -0
- package/src/replay/Replay.test.ts +193 -2
- package/src/replay/Replay.ts +51 -6
- package/src/replay/Trailers.test.ts +26 -0
- package/src/replay/Trailers.ts +8 -0
- package/src/replay/index.ts +1 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +5 -5
- package/src/step/planStep.test.ts +53 -0
- package/src/step/planStep.ts +7 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
- package/src/testkit/EmittedScriptRecognizer.ts +26 -4
- package/src/testkit/EmittedScripts.test.ts +182 -0
- package/src/testkit/FakeGitOperations.ts +8 -2
- package/src/testkit/GitTiers.ts +103 -24
- package/src/testkit/InMemRepo.ts +57 -12
- package/src/testkit/TestRepo.ts +2 -2
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
package/src/testkit/GitTiers.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
updateRef,
|
|
31
31
|
fetchHistoryRefs,
|
|
32
32
|
deleteRef,
|
|
33
|
+
shellQuote,
|
|
33
34
|
} from "../emit/index.js"
|
|
34
35
|
import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
35
36
|
import { InMemRepo } from "./InMemRepo.js"
|
|
@@ -38,8 +39,6 @@ import { gitTestLayer, type ScriptedCommand } from "./Layers.js"
|
|
|
38
39
|
export interface GitTierCapabilities {
|
|
39
40
|
/** 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
41
|
readonly onDiskContent: boolean
|
|
41
|
-
/** Install a real `.git/hooks/pre-commit` and prove the `--no-verify` retry — only the Live tier has a hooks directory. */
|
|
42
|
-
readonly commitHooks: boolean
|
|
43
42
|
/** `git worktree add` — sibling linked worktrees sharing one `.git`; the fake models a single worktree only. */
|
|
44
43
|
readonly linkedWorktrees: boolean
|
|
45
44
|
/** A cwd→home directory chain for config discovery — both tiers walk it through `Workspace`/`Host` now, so this is `true` for either. */
|
|
@@ -48,6 +47,26 @@ export interface GitTierCapabilities {
|
|
|
48
47
|
readonly remotes: boolean
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
// Quoted heredoc: `$`, `$(…)` and backticks in `output` reach stderr verbatim.
|
|
51
|
+
const writeRejectingHook = (hooksDir: string, output: string): void => {
|
|
52
|
+
mkdirSync(hooksDir, { recursive: true })
|
|
53
|
+
writeFileSync(
|
|
54
|
+
join(hooksDir, "pre-commit"),
|
|
55
|
+
`#!/bin/sh\ncat >&2 <<'GTD_HOOK_EOF'\n${output}\nGTD_HOOK_EOF\nexit 1\n`,
|
|
56
|
+
{ mode: 0o755 },
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// `printf '%s'` keeps `content` byte-exact.
|
|
61
|
+
const writeStagingHook = (hooksDir: string, path: string, content: string): void => {
|
|
62
|
+
mkdirSync(hooksDir, { recursive: true })
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(hooksDir, "pre-commit"),
|
|
65
|
+
`#!/bin/sh\nprintf '%s' ${shellQuote(content)} > ${shellQuote(path)} && git add ${shellQuote(path)}\nexit 0\n`,
|
|
66
|
+
{ mode: 0o755 },
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export interface GitTierSeed {
|
|
52
71
|
readonly commit: (message: string, files?: Record<string, string>) => void
|
|
53
72
|
readonly writeFile: (path: string, content: string) => void
|
|
@@ -56,6 +75,12 @@ export interface GitTierSeed {
|
|
|
56
75
|
readonly stageAll: () => void
|
|
57
76
|
readonly updateRef: (ref: string, hash: string) => void
|
|
58
77
|
readonly mixedReset: (ref: string) => void
|
|
78
|
+
/** Every hooked commit fails printing `output`; `--no-verify` skips it. */
|
|
79
|
+
readonly rejectingHook: (output: string) => void
|
|
80
|
+
/** Every hooked commit first writes `content` to `path` and stages it; `--no-verify` and `commit-tree` skip it. */
|
|
81
|
+
readonly stagingHook: (path: string, content: string) => void
|
|
82
|
+
/** Every commit `git commit` signs, or `git commit-tree -S` writes, fails; plain `git commit-tree` is unaffected. */
|
|
83
|
+
readonly failingSigner: () => void
|
|
59
84
|
}
|
|
60
85
|
|
|
61
86
|
export interface GitTierObserve {
|
|
@@ -157,7 +182,6 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
157
182
|
root,
|
|
158
183
|
capabilities: {
|
|
159
184
|
onDiskContent: true,
|
|
160
|
-
commitHooks: true,
|
|
161
185
|
linkedWorktrees: true,
|
|
162
186
|
directoryChainConfig: true,
|
|
163
187
|
remotes: true,
|
|
@@ -193,6 +217,22 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
193
217
|
stageAll: () => gitExec("add", "-A"),
|
|
194
218
|
updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
|
|
195
219
|
mixedReset: (ref) => gitExec("reset", "--mixed", ref),
|
|
220
|
+
rejectingHook: (output) => {
|
|
221
|
+
const hooks = join(root, ".git/hooks")
|
|
222
|
+
gitExec("config", "core.hooksPath", hooks)
|
|
223
|
+
writeRejectingHook(hooks, output)
|
|
224
|
+
},
|
|
225
|
+
stagingHook: (path, content) => {
|
|
226
|
+
const hooks = join(root, ".git/hooks")
|
|
227
|
+
gitExec("config", "core.hooksPath", hooks)
|
|
228
|
+
writeStagingHook(hooks, path, content)
|
|
229
|
+
},
|
|
230
|
+
failingSigner: () => {
|
|
231
|
+
gitExec("config", "commit.gpgsign", "true")
|
|
232
|
+
// A global `gpg.format ssh` would otherwise sign and the case pass vacuously.
|
|
233
|
+
gitExec("config", "gpg.format", "openpgp")
|
|
234
|
+
gitExec("config", "gpg.program", "false")
|
|
235
|
+
},
|
|
196
236
|
},
|
|
197
237
|
observe: {
|
|
198
238
|
resolveRef: (ref) => gitExec("rev-parse", ref),
|
|
@@ -258,7 +298,7 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
258
298
|
const repo = new InMemRepo()
|
|
259
299
|
if (initialCommit) {
|
|
260
300
|
repo.writeFile("readme.txt", "hello")
|
|
261
|
-
repo.
|
|
301
|
+
repo.seedCommit("init: first commit")
|
|
262
302
|
}
|
|
263
303
|
const gitLayer = gitTestLayer(repo, IN_MEM_ROOT)
|
|
264
304
|
|
|
@@ -275,7 +315,6 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
275
315
|
root: IN_MEM_ROOT,
|
|
276
316
|
capabilities: {
|
|
277
317
|
onDiskContent: false,
|
|
278
|
-
commitHooks: false,
|
|
279
318
|
linkedWorktrees: false,
|
|
280
319
|
directoryChainConfig: true,
|
|
281
320
|
remotes: false,
|
|
@@ -286,17 +325,26 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
286
325
|
seed: {
|
|
287
326
|
commit: (message, files = { "file.txt": message }) => {
|
|
288
327
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
289
|
-
repo.
|
|
328
|
+
repo.seedCommit(message)
|
|
290
329
|
},
|
|
291
330
|
writeFile: (path, content) => repo.writeFile(path, content),
|
|
292
331
|
deleteFile: (path) => repo.deleteFile(path),
|
|
293
332
|
commitDeletion: (path, message) => {
|
|
294
333
|
repo.deleteFile(path)
|
|
295
|
-
repo.
|
|
334
|
+
repo.seedCommit(message)
|
|
296
335
|
},
|
|
297
336
|
stageAll: () => repo.stageAll(),
|
|
298
337
|
updateRef: (ref, hash) => repo.updateRef(ref, hash),
|
|
299
338
|
mixedReset: (ref) => repo.mixedResetTo(ref),
|
|
339
|
+
rejectingHook: (output) => {
|
|
340
|
+
repo.hookRejection = output
|
|
341
|
+
},
|
|
342
|
+
stagingHook: (path, content) => {
|
|
343
|
+
repo.hookStages = { path, content }
|
|
344
|
+
},
|
|
345
|
+
failingSigner: () => {
|
|
346
|
+
repo.signingFails = true
|
|
347
|
+
},
|
|
300
348
|
},
|
|
301
349
|
observe: {
|
|
302
350
|
resolveRef: (ref) => {
|
|
@@ -727,23 +775,6 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
727
775
|
const history = await runGit(t, (g) => g.commitHistory())
|
|
728
776
|
expect(history[history.length - 1]?.message).toBe("gtd: grilled")
|
|
729
777
|
})
|
|
730
|
-
|
|
731
|
-
it.skipIf(!capabilities.commitHooks)(
|
|
732
|
-
"retries with --no-verify when a hook blocks the empty commit",
|
|
733
|
-
async () => {
|
|
734
|
-
const hookPath = join(t.root, ".git/hooks/pre-commit")
|
|
735
|
-
writeFileSync(
|
|
736
|
-
hookPath,
|
|
737
|
-
`#!/bin/sh\necho "lint-staged prevented an empty git commit." >&2\nexit 1\n`,
|
|
738
|
-
)
|
|
739
|
-
execSync(`chmod +x "${hookPath}"`)
|
|
740
|
-
const headBefore = t.observe.resolveRef("HEAD")
|
|
741
|
-
await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"))
|
|
742
|
-
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
743
|
-
const history = await runGit(t, (g) => g.commitHistory())
|
|
744
|
-
expect(history[history.length - 1]?.message).toBe("gtd: grilled")
|
|
745
|
-
},
|
|
746
|
-
)
|
|
747
778
|
})
|
|
748
779
|
|
|
749
780
|
describe("commitAsIs", () => {
|
|
@@ -768,6 +799,35 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
768
799
|
})
|
|
769
800
|
})
|
|
770
801
|
|
|
802
|
+
describe.each([
|
|
803
|
+
["commitAllWithPrefix", (g: GitOperations) => g.commitAllWithPrefix("gtd: building")],
|
|
804
|
+
["commitAsIs", (g: GitOperations) => g.commitAsIs("gtd: building")],
|
|
805
|
+
] as const)("%s seeded commit failures", (_name, commit) => {
|
|
806
|
+
it("retries with --no-verify when a hook blocks the empty commit", async () => {
|
|
807
|
+
t.seed.rejectingHook("lint-staged prevented an empty git commit.")
|
|
808
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
809
|
+
await runGit(t, commit)
|
|
810
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
811
|
+
const history = await runGit(t, (g) => g.commitHistory())
|
|
812
|
+
expect(history[history.length - 1]?.message).toBe("gtd: building")
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
it("fails on a hook rejection that is not the empty-commit one, leaving HEAD unchanged", async () => {
|
|
816
|
+
t.seed.rejectingHook("lint error")
|
|
817
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
818
|
+
expect(Exit.isFailure(await runGitExit(t, commit))).toBe(true)
|
|
819
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
it("fails both attempts when the signer cannot write, leaving HEAD unchanged", async () => {
|
|
823
|
+
t.seed.rejectingHook("rejected: empty git commit")
|
|
824
|
+
t.seed.failingSigner()
|
|
825
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
826
|
+
expect(Exit.isFailure(await runGitExit(t, commit))).toBe(true)
|
|
827
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
|
|
828
|
+
})
|
|
829
|
+
})
|
|
830
|
+
|
|
771
831
|
describe("softResetTo", () => {
|
|
772
832
|
it("moves HEAD back but keeps worktree changes from second commit", async () => {
|
|
773
833
|
const firstHash = t.observe.resolveRef("HEAD")
|
|
@@ -824,6 +884,25 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
824
884
|
expect(resolved._tag).toBe("None")
|
|
825
885
|
})
|
|
826
886
|
|
|
887
|
+
it("readRefOption of <hash>^{commit} is Some for an existing commit", async () => {
|
|
888
|
+
const head = t.observe.resolveRef("HEAD")
|
|
889
|
+
const resolved = await runGit(t, (g) => g.readRefOption(`${head}^{commit}`))
|
|
890
|
+
expect(resolved._tag).toBe("Some")
|
|
891
|
+
if (resolved._tag === "Some") expect(resolved.value).toBe(head)
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
it("readRefOption of <hash>^{commit} is None for a 40-hex hash with no object", async () => {
|
|
895
|
+
const missing = "0123456789abcdef0123456789abcdef01234567"
|
|
896
|
+
const resolved = await runGit(t, (g) => g.readRefOption(`${missing}^{commit}`))
|
|
897
|
+
expect(resolved._tag).toBe("None")
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
it("readRefOption of a bare 40-hex hash with no object is Some", async () => {
|
|
901
|
+
const missing = "0123456789abcdef0123456789abcdef01234567"
|
|
902
|
+
const resolved = await runGit(t, (g) => g.readRefOption(missing))
|
|
903
|
+
expect(resolved._tag).toBe("Some")
|
|
904
|
+
})
|
|
905
|
+
|
|
827
906
|
it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
|
|
828
907
|
const head = t.observe.resolveRef("HEAD")
|
|
829
908
|
await runGit(t, (g) => g.updateRef("refs/gtd/marker", head))
|
package/src/testkit/InMemRepo.ts
CHANGED
|
@@ -111,6 +111,7 @@ export class InMemRepo {
|
|
|
111
111
|
|
|
112
112
|
// fallow-ignore-next-line complexity
|
|
113
113
|
resolveRef(ref: string): string | null {
|
|
114
|
+
if (ref.endsWith("^{commit}")) return this.resolveRef(ref.slice(0, -"^{commit}".length))
|
|
114
115
|
if (/^[0-9a-f]{40}$/.test(ref)) {
|
|
115
116
|
return this.commits.has(ref) ? ref : null
|
|
116
117
|
}
|
|
@@ -335,8 +336,13 @@ export class InMemRepo {
|
|
|
335
336
|
this.index = new Map(this.worktree)
|
|
336
337
|
}
|
|
337
338
|
|
|
338
|
-
/**
|
|
339
|
-
|
|
339
|
+
/** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
|
|
340
|
+
hookRejection: string | undefined = undefined
|
|
341
|
+
signingFails = false
|
|
342
|
+
/** Seeded: a pre-commit hook writing and staging this file. */
|
|
343
|
+
hookStages: { readonly path: string; readonly content: string } | undefined = undefined
|
|
344
|
+
|
|
345
|
+
private writeCommit(message: string): void {
|
|
340
346
|
const tree = new Map(this.index)
|
|
341
347
|
const parent = this.head
|
|
342
348
|
const hash = makeHash(message, parent, tree)
|
|
@@ -346,24 +352,63 @@ export class InMemRepo {
|
|
|
346
352
|
this.branches.set(this.currentBranch, hash)
|
|
347
353
|
}
|
|
348
354
|
|
|
349
|
-
/**
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Twin of the emitted `git commit --allow-empty` if/case retry: a hook
|
|
357
|
+
* rejection printing "empty git commit" retries without hooks, any other
|
|
358
|
+
* rejection fails; a failing signer fails either attempt. Commits whatever is
|
|
359
|
+
* staged verbatim, like `git commit` after a soft reset.
|
|
360
|
+
*/
|
|
361
|
+
commitAsIs(message: string): string | undefined {
|
|
362
|
+
if (this.hookRejection !== undefined && !this.hookRejection.includes("empty git commit")) {
|
|
363
|
+
return this.hookRejection
|
|
364
|
+
}
|
|
365
|
+
if (this.signingFails) return "error: gpg failed to sign the data"
|
|
366
|
+
if (this.hookStages !== undefined) {
|
|
367
|
+
this.worktree.set(this.hookStages.path, this.hookStages.content)
|
|
368
|
+
this.index.set(this.hookStages.path, this.hookStages.content)
|
|
369
|
+
}
|
|
370
|
+
this.writeCommit(message)
|
|
371
|
+
return undefined
|
|
353
372
|
}
|
|
354
373
|
|
|
355
|
-
|
|
374
|
+
/** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
|
|
375
|
+
seedCommit(message: string): void {
|
|
376
|
+
if (this.hookRejection !== undefined || this.hookStages !== undefined || this.signingFails) {
|
|
377
|
+
throw new Error("seedCommit after a seeded commit failure")
|
|
378
|
+
}
|
|
356
379
|
this.index = new Map(this.worktree)
|
|
380
|
+
this.writeCommit(message)
|
|
381
|
+
}
|
|
357
382
|
|
|
383
|
+
/** Twin of `GitScript.keepTurn`: no hook runs; `signingFails` fails the marker. */
|
|
384
|
+
keepTurn(refPrefix: string, message: string): string | undefined {
|
|
385
|
+
this.stageAll()
|
|
358
386
|
const tree = new Map(this.index)
|
|
359
|
-
const message = prefix
|
|
360
387
|
const parent = this.head
|
|
361
388
|
const hash = makeHash(message, parent, tree)
|
|
389
|
+
this.commits.set(hash, { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ })
|
|
390
|
+
const ref = `${refPrefix}/${hash}`
|
|
391
|
+
this.refs.set(ref, hash)
|
|
392
|
+
this.index = new Map(this.headTree())
|
|
393
|
+
if (this.signingFails) {
|
|
394
|
+
this.refs.delete(ref)
|
|
395
|
+
return "error: gpg failed to sign the data"
|
|
396
|
+
}
|
|
397
|
+
this.writeCommit(`${message}\nGtd-Kept: ${hash}`)
|
|
398
|
+
this.index = new Map(tree)
|
|
399
|
+
this.resetHard()
|
|
400
|
+
return undefined
|
|
401
|
+
}
|
|
362
402
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
this.
|
|
366
|
-
this.
|
|
403
|
+
/** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
|
|
404
|
+
discardPending(): void {
|
|
405
|
+
this.index = new Map(this.worktree)
|
|
406
|
+
this.resetHard()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
commitAllWithPrefix(prefix: string): string | undefined {
|
|
410
|
+
this.index = new Map(this.worktree)
|
|
411
|
+
return this.commitAsIs(prefix)
|
|
367
412
|
}
|
|
368
413
|
|
|
369
414
|
// Git's empty-tree object SHA: used as squash base when there's no parent commit.
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -169,7 +169,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
169
169
|
typeof workflow === "string" ? workflow : moduleSource(id, workflow),
|
|
170
170
|
)
|
|
171
171
|
for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
|
|
172
|
-
repo.
|
|
172
|
+
repo.seedCommit("chore: initial commit")
|
|
173
173
|
|
|
174
174
|
// The module is registered only while gtd runs, so nothing outlives the
|
|
175
175
|
// call that loads it and no test can reach another's module.
|
|
@@ -245,7 +245,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
245
245
|
read: (path) => repo.readFile(path),
|
|
246
246
|
write: (path, content) => repo.writeFile(path, content),
|
|
247
247
|
remove: (path) => repo.deleteFile(path),
|
|
248
|
-
commit: (message) => repo.
|
|
248
|
+
commit: (message) => repo.seedCommit(message),
|
|
249
249
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
250
250
|
runScript: (script) => {
|
|
251
251
|
const flow = applyFlowScript(repo, new Map(), script)
|
package/src/workflow/Workflow.ts
CHANGED
|
@@ -83,7 +83,7 @@ const inMemory = (): Tier => ({
|
|
|
83
83
|
load: (files) => {
|
|
84
84
|
const repo = new InMemRepo()
|
|
85
85
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
86
|
-
repo.
|
|
86
|
+
repo.seedCommit("chore: init")
|
|
87
87
|
return loadWith(testLayers(repo))
|
|
88
88
|
},
|
|
89
89
|
dispose: () => {},
|