@pmelab/gtd 27.3.0 → 27.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +22 -1
  3. package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
  4. package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
  5. package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
  6. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  7. package/dist/flows.mjs +27 -2
  8. package/dist/gtd.bundle.mjs +218 -32
  9. package/dist/testkit.mjs +358 -37
  10. package/dist/wire.mjs +1 -1
  11. package/dist/workflows/cleanup.mjs +2 -2
  12. package/dist/workflows/feature.mjs +140 -64
  13. package/dist/workflows/fix.mjs +2 -2
  14. package/dist/workflows/review.mjs +2 -2
  15. package/dist/workflows/rollout.mjs +1 -1
  16. package/dist/workflows.mjs +346 -107
  17. package/package.json +1 -1
  18. package/src/cli/Cli.test.ts +49 -1
  19. package/src/cli/Cli.ts +25 -8
  20. package/src/cli/History.ts +5 -0
  21. package/src/cli/Program.ts +1 -0
  22. package/src/edge/Edge.test.ts +205 -1
  23. package/src/edge/Edge.ts +105 -12
  24. package/src/emit/GitScript.test.ts +39 -3
  25. package/src/emit/GitScript.ts +25 -1
  26. package/src/emit/index.ts +2 -0
  27. package/src/flags/index.test.ts +14 -1
  28. package/src/flags/index.ts +14 -3
  29. package/src/flows/helpers.ts +34 -2
  30. package/src/flows/runtime.ts +7 -0
  31. package/src/install/Install.ts +2 -1
  32. package/src/platform/Git.test.ts +25 -1
  33. package/src/platform/Git.ts +30 -0
  34. package/src/platform/index.ts +1 -0
  35. package/src/replay/Format.test.ts +16 -1
  36. package/src/replay/Replay.test.ts +91 -0
  37. package/src/replay/Replay.ts +22 -2
  38. package/src/replay/Trailers.test.ts +110 -1
  39. package/src/replay/Trailers.ts +37 -1
  40. package/src/replay/index.ts +2 -0
  41. package/src/replay/judgeDiffShape.test.ts +43 -0
  42. package/src/replay/seamGate.test.ts +62 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/planStart.ts +3 -2
  45. package/src/step/planStep.test.ts +32 -0
  46. package/src/step/planStep.ts +3 -1
  47. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  48. package/src/testkit/EmittedScriptRecognizer.ts +12 -0
  49. package/src/testkit/FakeGitOperations.ts +5 -0
  50. package/src/testkit/GitTiers.ts +158 -0
  51. package/src/testkit/InMemRepo.ts +38 -3
  52. package/src/testkit/TestRepo.ts +3 -0
  53. package/src/testkit/steps.ts +18 -0
  54. package/src/wire/BeatDocument.test.ts +25 -0
  55. package/src/wire/BeatDocument.ts +4 -2
  56. package/src/wire/BeatPlain.test.ts +1 -0
  57. package/src/wire/BeatStatus.test.ts +2 -0
  58. package/src/wire/BeatStatus.ts +13 -2
  59. package/src/wire/WireSchema.test.ts +10 -0
  60. package/src/wire/types.ts +8 -0
  61. package/src/workflow/Workflow.ts +1 -0
  62. package/src/workflow/index.ts +8 -1
  63. package/src/workflow/vars.test.ts +23 -1
  64. package/src/workflow/vars.ts +24 -0
  65. package/src/workflows/feature/feature.ts +9 -2
  66. package/src/workflows/feature/index.ts +1 -0
  67. package/src/workflows/feature/seam-pin.feature +52 -0
  68. package/src/workflows/feature/seam.feature +387 -0
  69. package/src/workflows/feature/seam.test.ts +88 -0
  70. package/src/workflows/feature/seam.ts +110 -0
  71. package/src/workflows/index.ts +1 -0
  72. package/src/workflows/shared/options.test.ts +12 -1
  73. package/src/workflows/shared/options.ts +1 -0
  74. package/src/workflows/shared/skills.test.ts +20 -2
  75. package/src/workflows/shared/skills.ts +6 -0
  76. package/src/workflows/shared/text.test.ts +59 -0
  77. package/src/workflows/shared/text.ts +86 -0
  78. package/src/workflows/shared/vars.ts +4 -0
  79. package/src/workflows/shared/workflows.steps.ts +39 -1
@@ -50,6 +50,49 @@ const naive = [
50
50
  ]
51
51
 
52
52
  describe("judgeDiffShape", () => {
53
+ describe("exempt", () => {
54
+ const changes = [
55
+ change("src/foo*bar.ts", "added", undefined, "new"),
56
+ change("src/foobar.ts", "modified", "a", "b"),
57
+ ]
58
+
59
+ it("never lets an exempt path glob over a modified sibling", async () => {
60
+ install({ shape: { answer: "land", p: 0.9 } })
61
+ const result = await judgeDiffShape("shape", {
62
+ changes,
63
+ allow: [],
64
+ exempt: ["src/foo*bar.ts"],
65
+ })
66
+ expect(result.strays).toEqual(["src/foobar.ts"])
67
+ })
68
+
69
+ it("lists exempt paths as in-allowlist evidence and keeps allow to the globs", async () => {
70
+ const requests = install({ shape: { answer: "land", p: 0.9 } })
71
+ await judgeDiffShape("shape", { changes, allow: ["docs/**"], exempt: ["src/foo*bar.ts"] })
72
+ const request = requests[0]
73
+ if (request?.kind !== "judge") throw new Error("expected a judge step")
74
+ expect(request.evidence.allow).toBe(JSON.stringify(["docs/**"], null, 2))
75
+ expect(request.evidence.inAllowlist).toBe(JSON.stringify(["src/foo*bar.ts"], null, 2))
76
+ })
77
+
78
+ it("lands with no judge step when every stray is exempt", async () => {
79
+ const requests = install("none")
80
+ const result = await judgeDiffShape("shape", {
81
+ changes: [changes[0]!],
82
+ allow: [],
83
+ exempt: ["src/foo*bar.ts"],
84
+ })
85
+ expect(result.choice).toBe("land")
86
+ expect(requests).toEqual([])
87
+ })
88
+
89
+ it("changes nothing when omitted", async () => {
90
+ install({ shape: { answer: "land", p: 0.9 } })
91
+ const result = await judgeDiffShape("shape", { changes, allow: [] })
92
+ expect(result.strays).toEqual(["src/foo*bar.ts", "src/foobar.ts"])
93
+ })
94
+ })
95
+
53
96
  it("answers land with no judge step when nothing strays", async () => {
54
97
  const requests = install("none")
55
98
  const result = await judgeDiffShape("shape", {
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { human, seamGate, type DiffShape, type DiffShapeResult, type Flow } from "../flows/index.js"
3
+ import { replay, treeFromRecord } from "./index.js"
4
+
5
+ // Lives in replay, which drives a flow to its rest (flows tests may not import replay).
6
+ const shape = (
7
+ choice: DiffShape,
8
+ strays: string[] = [],
9
+ unreadable: string[] = [],
10
+ ): DiffShapeResult => ({ choice, strays, unreadable, judgment: undefined })
11
+
12
+ const restOf = async (result: DiffShapeResult) => {
13
+ const flow: Flow = async () => {
14
+ await seamGate("seam", result)
15
+ await human("next")
16
+ }
17
+ const outcome = await replay({
18
+ flow,
19
+ episode: { base: { hash: "0".repeat(40), tree: treeFromRecord({}) }, commits: [] },
20
+ vars: {},
21
+ env: {},
22
+ start: "0".repeat(40),
23
+ budgetBytes: 1024,
24
+ })
25
+ if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${outcome.kind}`)
26
+ return outcome.rest
27
+ }
28
+
29
+ describe("seamGate", () => {
30
+ for (const choice of ["land", "reject"] as const) {
31
+ it(`reaches no step on ${choice}: the flow's next step is the rest`, async () => {
32
+ const rest = await restOf(shape(choice, ["a.ts"]))
33
+ expect(rest.name).toBe("next")
34
+ })
35
+ }
36
+
37
+ it("rests at an approve human step named after the caller on refactor-first", async () => {
38
+ const rest = await restOf(shape("refactor-first", ["a.ts"]))
39
+ expect(rest.name).toBe("seam")
40
+ expect(rest.request).toMatchObject({ kind: "human", options: { approve: true } })
41
+ })
42
+
43
+ it("lists every stray in order, marks only the unreadable, names gtd land and gtd abandon", async () => {
44
+ const rest = await restOf(shape("refactor-first", ["a.ts", "b/c.ts", "z.bin"], ["b/c.ts"]))
45
+ if (rest.request.kind !== "human") throw new Error("expected a human step")
46
+ const message = rest.request.options.message ?? ""
47
+ const lines = message.split("\n")
48
+ const strays = lines.filter((line) => line.startsWith("- "))
49
+ expect(strays).toEqual(["- a.ts", "- b/c.ts (unreadable — not judged)", "- z.bin"])
50
+ expect(message).toMatch(/`gtd land`[^\n]*approve/)
51
+ expect(message).toContain("`gtd abandon`")
52
+ })
53
+
54
+ it("quotes a stray path with a control character", async () => {
55
+ const rest = await restOf(shape("refactor-first", ["a\nb", "c\u001b[2K"]))
56
+ if (rest.request.kind !== "human") throw new Error("expected a human step")
57
+ const strays = (rest.request.options.message ?? "")
58
+ .split("\n")
59
+ .filter((line) => line.startsWith("- "))
60
+ expect(strays).toEqual(['- "a\\nb"', '- "c\\u001b[2K"'])
61
+ })
62
+ })
@@ -8,6 +8,7 @@ import type { StateName } from "../workflow/index.js"
8
8
  */
9
9
  export type GitWrite =
10
10
  | { readonly kind: "commitAll"; readonly message: string }
11
+ | { readonly kind: "commitClean"; readonly message: string }
11
12
  | { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
12
13
  | { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
13
14
 
@@ -4,7 +4,8 @@ import {
4
4
  ConfigDiscovery,
5
5
  ConfigService,
6
6
  multilineSetting,
7
- resolveVars,
7
+ resolveStartVars,
8
+ StartFlags,
8
9
  type ConfigFingerprint,
9
10
  type ResolvedWorkflow,
10
11
  } from "../workflow/index.js"
@@ -109,7 +110,7 @@ export const planStart = (
109
110
  if (overrideRefusal !== undefined) return overrideRefusal
110
111
 
111
112
  const baseOf = workflow.def.base
112
- const vars = resolveVars(workflow.vars, varOverrides, (yield* Host).env)
113
+ const vars = resolveStartVars(workflow.vars, yield* StartFlags, varOverrides, (yield* Host).env)
113
114
  const multiline = multilineSetting(vars)
114
115
  if (multiline !== undefined) {
115
116
  return refusal(
@@ -131,6 +131,38 @@ describe("planStep — keepHistory", () => {
131
131
  })
132
132
  })
133
133
 
134
+ describe("planStep — approval landing", () => {
135
+ const approved = { step: { name: "gate", occurrence: 1 }, name: "Ada", email: "ada@x.io" }
136
+
137
+ it("commits with commitClean when the landing records an approval", () => {
138
+ const base = commitTo("gate", "again")
139
+ const s = snapshot({
140
+ state: "gate",
141
+ stepDef: {},
142
+ landing: { ...base, spec: { ...base.spec, approved } },
143
+ })
144
+ const outcome = planStep(s)
145
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
146
+ expect(outcome.steps[0]).toMatchObject({ write: { kind: "commitClean" } })
147
+ })
148
+
149
+ it("a finishing approval still points the history ref after the commitClean write", () => {
150
+ const base = commitTo("gate", "idle")
151
+ const s = snapshot({
152
+ state: "gate",
153
+ stepDef: {},
154
+ landing: { ...base, spec: { ...base.spec, approved }, keepHistory: "refs/gtd/history/abc" },
155
+ })
156
+ const outcome = planStep(s)
157
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
158
+ expect(outcome.steps.map((x) => (x.kind === "gitWrite" ? x.write.kind : x.kind))).toEqual([
159
+ "commitClean",
160
+ "updateRef",
161
+ "outcome",
162
+ ])
163
+ })
164
+ })
165
+
134
166
  describe("planStep — keepTurn", () => {
135
167
  const kept = "refs/gtd/kept/abc/building"
136
168
 
@@ -87,7 +87,9 @@ export const planStep = (
87
87
  write:
88
88
  landing.keepTurn !== undefined
89
89
  ? { kind: "keepTurn", ref: landing.keepTurn, message }
90
- : { kind: "commitAll", message },
90
+ : landing.spec.approved !== undefined
91
+ ? { kind: "commitClean", message }
92
+ : { kind: "commitAll", message },
91
93
  },
92
94
  ...(landing.keepHistory !== undefined
93
95
  ? [
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
2
2
  import {
3
3
  commitAll,
4
4
  commitAsIs,
5
+ commitClean,
5
6
  deleteRef,
6
7
  discardPending,
7
8
  hardResetTo,
@@ -664,3 +665,29 @@ describe("applyEmittedScript — keepTurn", () => {
664
665
  expect(repo.resolveRef(REF)).toBeNull()
665
666
  })
666
667
  })
668
+
669
+ describe("applyEmittedScript — commitClean", () => {
670
+ const MESSAGE = 'gtd(human): gate → again\n\nGtd-Approved: {"step":"gate#1"}'
671
+
672
+ it("applies the rendered block as an empty commit over HEAD", () => {
673
+ const repo = new InMemRepo()
674
+ repo.writeFile("base.txt", "0")
675
+ repo.seedCommit("chore: first")
676
+ const result = applyEmittedScript(repo, NO_COMMANDS, commitClean(MESSAGE))
677
+ expect(result).toMatchObject({ ok: true })
678
+ expect(repo.lastCommitMessage()).toBe(MESSAGE)
679
+ expect(repo.statusPorcelain()).toBe("")
680
+ })
681
+
682
+ it("fails loudly on a one-character near-miss and applies nothing", () => {
683
+ const repo = new InMemRepo()
684
+ repo.writeFile("base.txt", "0")
685
+ repo.seedCommit("chore: first")
686
+ const before = snapshot(repo)
687
+ const nearMiss = commitClean(MESSAGE).replace("update-ref -m", "update-ref -n")
688
+ const result = applyEmittedScript(repo, NO_COMMANDS, nearMiss)
689
+ expect(result.ok).toBe(false)
690
+ expect(result.error).toContain("unrecognized script block")
691
+ expect(snapshot(repo)).toEqual(before)
692
+ })
693
+ })
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  commitAll,
8
8
  commitAsIs,
9
+ commitClean,
9
10
  deleteRef,
10
11
  discardPending,
11
12
  hardResetTo,
@@ -185,6 +186,17 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
185
186
  return undefined
186
187
  }
187
188
 
189
+ if (block.startsWith("gtd_head=$(git rev-parse --verify HEAD) || exit 1\nif [ -n ")) {
190
+ const [message] = extractQuotedTokens(block.slice(block.indexOf('"$gtd_head" -m ')))
191
+ if (message !== undefined && commitClean(message) === block) {
192
+ const failure = repo.commitClean(message)
193
+ return failure === undefined
194
+ ? { kind: "applied" }
195
+ : { kind: "failed", error: failure, status: 1 }
196
+ }
197
+ return undefined
198
+ }
199
+
188
200
  if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
189
201
  const [message] = extractQuotedTokens(conditionLine)
190
202
  if (message !== undefined && commitAsIs(message) === block) {
@@ -77,7 +77,12 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
77
77
 
78
78
  changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
79
79
 
80
+ stagedPaths: () => Effect.succeed(repo.stagedPaths()),
81
+
80
82
  diffPaths: (base: string, tip: string) => tryCatch(() => repo.diffPaths(base, tip)),
83
+
84
+ // The identity `GitTiers`' live tier configures; the fake commits as no one in particular.
85
+ authorIdent: () => Effect.succeed(Option.some({ name: "Test", email: "test@test.com" })),
81
86
  })
82
87
 
83
88
  const failOn = (output: string | undefined): void => {
@@ -23,6 +23,7 @@ import { Narrator, GitService, Host, type GitOperations } from "../platform/inde
23
23
  import {
24
24
  commitAll,
25
25
  commitAsIs,
26
+ commitClean,
26
27
  softResetTo,
27
28
  mixedResetTo,
28
29
  hardResetTo,
@@ -81,6 +82,8 @@ export interface GitTierSeed {
81
82
  readonly stagingHook: (path: string, content: string) => void
82
83
  /** Every commit `git commit` signs, or `git commit-tree -S` writes, fails; plain `git commit-tree` is unaffected. */
83
84
  readonly failingSigner: () => void
85
+ /** `commit.gpgSign=true` with a signer that succeeds; `git commit` and `commit-tree -S` both sign. */
86
+ readonly workingSigner: () => void
84
87
  }
85
88
 
86
89
  export interface GitTierObserve {
@@ -94,6 +97,8 @@ export interface GitTierObserve {
94
97
  readonly headSubject: () => string
95
98
  /** Paths HEAD's own commit touched, against its parent — not the whole tree. */
96
99
  readonly headTouchedPaths: () => ReadonlyArray<string>
100
+ /** True when HEAD's commit object carries a signature (`gpgsig` header). */
101
+ readonly headSigned: () => boolean
97
102
  }
98
103
 
99
104
  export interface GitTier {
@@ -233,6 +238,18 @@ const makeLiveTier = (initialCommit = true): GitTier => {
233
238
  gitExec("config", "gpg.format", "openpgp")
234
239
  gitExec("config", "gpg.program", "false")
235
240
  },
241
+ workingSigner: () => {
242
+ const signer = join(root, ".git/gtd-test-signer.sh")
243
+ // git checks `[GNUPG:] SIG_CREATED ` on stderr (--status-fd=2) and takes stdout as the signature.
244
+ writeFileSync(
245
+ signer,
246
+ `#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '-----BEGIN PGP SIGNATURE-----' '' 'ZHVtbXk=' '-----END PGP SIGNATURE-----'\nprintf '\\n[GNUPG:] SIG_CREATED ' >&2\n`,
247
+ { mode: 0o755 },
248
+ )
249
+ gitExec("config", "commit.gpgsign", "true")
250
+ gitExec("config", "gpg.format", "openpgp")
251
+ gitExec("config", "gpg.program", signer)
252
+ },
236
253
  },
237
254
  observe: {
238
255
  resolveRef: (ref) => gitExec("rev-parse", ref),
@@ -268,6 +285,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
268
285
  gitExec("show", "--name-only", "--pretty=format:", "HEAD")
269
286
  .split("\n")
270
287
  .filter((line) => line.length > 0),
288
+ headSigned: () => gitExec("cat-file", "commit", "HEAD").includes("\ngpgsig "),
271
289
  },
272
290
  runScript: (script) => {
273
291
  // One stream, as a driver's terminal shows it.
@@ -345,6 +363,9 @@ const makeInMemTier = (initialCommit = true): GitTier => {
345
363
  failingSigner: () => {
346
364
  repo.signingFails = true
347
365
  },
366
+ workingSigner: () => {
367
+ repo.signingEnabled = true
368
+ },
348
369
  },
349
370
  observe: {
350
371
  resolveRef: (ref) => {
@@ -369,6 +390,7 @@ const makeInMemTier = (initialCommit = true): GitTier => {
369
390
  .filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p))
370
391
  .sort()
371
392
  },
393
+ headSigned: () => repo.headSigned(),
372
394
  },
373
395
  runScript: (script, commands = new Map()) => {
374
396
  const { ok, status, output, error } = applyEmittedScript(repo, commands, script)
@@ -409,7 +431,9 @@ export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new
409
431
  "subjectHistory",
410
432
  "readFileAtRef",
411
433
  "changedPaths",
434
+ "stagedPaths",
412
435
  "diffPaths",
436
+ "authorIdent",
413
437
  "commitAllWithPrefix",
414
438
  "softResetTo",
415
439
  "commitAsIs",
@@ -481,7 +505,9 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
481
505
  g.readFileAtRef("HEAD", "second.txt"),
482
506
  g.changedPaths(),
483
507
  g.changedPaths("HEAD~1"),
508
+ g.stagedPaths(),
484
509
  g.diffPaths("HEAD~1", "HEAD"),
510
+ g.authorIdent(),
485
511
  ]),
486
512
  )
487
513
  expect(await t.observe.snapshot()).toBe(before)
@@ -1071,6 +1097,14 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
1071
1097
  expect(await runGit(t, (g) => g.changedPaths())).toEqual([])
1072
1098
  })
1073
1099
 
1100
+ it("ignores an edit staged and then reverted in the worktree", async () => {
1101
+ t.seed.commit("chore: seed", { "a.txt": "1" })
1102
+ t.seed.writeFile("a.txt", "2")
1103
+ t.seed.stageAll()
1104
+ t.seed.writeFile("a.txt", "1")
1105
+ expect(await runGit(t, (g) => g.changedPaths())).toEqual([])
1106
+ })
1107
+
1074
1108
  for (const { label, path } of PATHOLOGICAL_PATHS) {
1075
1109
  it(`reports an untracked path containing ${label}, verbatim`, async () => {
1076
1110
  t.seed.writeFile(path, "content")
@@ -1172,6 +1206,43 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
1172
1206
  })
1173
1207
  })
1174
1208
 
1209
+ describe("stagedPaths", () => {
1210
+ it("returns [] when the index matches HEAD, whatever the worktree holds", async () => {
1211
+ t.seed.commit("chore: seed", { "a.txt": "1" })
1212
+ t.seed.writeFile("a.txt", "2")
1213
+ t.seed.writeFile("u.txt", "u")
1214
+ expect(await runGit(t, (g) => g.stagedPaths())).toEqual([])
1215
+ })
1216
+
1217
+ it("reports an edit staged and then reverted in the worktree", async () => {
1218
+ t.seed.commit("chore: seed", { "a.txt": "1" })
1219
+ t.seed.writeFile("a.txt", "2")
1220
+ t.seed.stageAll()
1221
+ t.seed.writeFile("a.txt", "1")
1222
+ expect(await runGit(t, (g) => g.stagedPaths())).toEqual([{ path: "a.txt", status: "M" }])
1223
+ })
1224
+
1225
+ it("reports staged additions and deletions", async () => {
1226
+ t.seed.commit("chore: seed", { "gone.txt": "g" })
1227
+ t.seed.deleteFile("gone.txt")
1228
+ t.seed.writeFile("new.txt", "n")
1229
+ t.seed.stageAll()
1230
+ const staged = await runGit(t, (g) => g.stagedPaths())
1231
+ expect([...staged].sort((a, b) => a.path.localeCompare(b.path))).toEqual([
1232
+ { path: "gone.txt", status: "D" },
1233
+ { path: "new.txt", status: "A" },
1234
+ ])
1235
+ })
1236
+ })
1237
+
1238
+ describe("authorIdent", () => {
1239
+ it("is the configured name and email", async () => {
1240
+ expect(await runGit(t, (g) => g.authorIdent())).toEqual(
1241
+ Option.some({ name: "Test", email: "test@test.com" }),
1242
+ )
1243
+ })
1244
+ })
1245
+
1175
1246
  describe("topLevel", () => {
1176
1247
  it("resolves to this tier's own root", async () => {
1177
1248
  expect(await runGit(t, (g) => g.topLevel())).toBe(t.root)
@@ -1286,6 +1357,93 @@ export const runGitScriptContract = (makeTier: () => GitTier): void => {
1286
1357
  })
1287
1358
  })
1288
1359
 
1360
+ describe("commitClean", () => {
1361
+ it("on a clean tree commits an empty commit with the message", () => {
1362
+ const headBefore = t.observe.resolveRef("HEAD")
1363
+ run(t, commitClean("gtd(human): gate → again"))
1364
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
1365
+ expect(t.observe.headSubject()).toBe("gtd(human): gate → again")
1366
+ expect(t.observe.headTouchedPaths()).toEqual([])
1367
+ })
1368
+
1369
+ it("a staging hook never runs: nothing touched, nothing staged, file absent", () => {
1370
+ const headBefore = t.observe.resolveRef("HEAD")
1371
+ t.seed.stagingHook("hooked.txt", "hooked")
1372
+ run(t, commitClean("gtd(human): gate → again"))
1373
+ expect(t.observe.headTouchedPaths()).toEqual([])
1374
+ expect(t.observe.resolveRef("HEAD^")).toBe(headBefore)
1375
+ expect(t.observe.statusPorcelain().trim()).toBe("")
1376
+ expect(t.observe.existsPath("hooked.txt")).toBe(false)
1377
+ })
1378
+
1379
+ it("a rejecting hook is never consulted", () => {
1380
+ t.seed.rejectingHook("rejected")
1381
+ run(t, commitClean("gtd(human): gate → again"))
1382
+ expect(t.observe.headSubject()).toBe("gtd(human): gate → again")
1383
+ })
1384
+
1385
+ it("a working signer signs the approval", () => {
1386
+ t.seed.workingSigner()
1387
+ run(t, commitClean("gtd(human): gate → again"))
1388
+ expect(t.observe.headSigned()).toBe(true)
1389
+ })
1390
+
1391
+ it("no signing config: the approval does not try to sign", () => {
1392
+ run(t, commitClean("gtd(human): gate → again"))
1393
+ expect(t.observe.headSigned()).toBe(false)
1394
+ })
1395
+
1396
+ it("a failing signer fails the approval: HEAD and status unchanged", () => {
1397
+ t.seed.failingSigner()
1398
+ const headBefore = t.observe.resolveRef("HEAD")
1399
+ const result = t.runScript(commitClean("gtd(human): gate → again"))
1400
+ expect(result.ok).toBe(false)
1401
+ expect(result.status).toBe(1)
1402
+ expect(result.output).toContain("failed to sign")
1403
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
1404
+ expect(t.observe.statusPorcelain().trim()).toBe("")
1405
+ })
1406
+
1407
+ const refused = (name: string, dirty: () => void, pending: string): void => {
1408
+ it(`${name}: fails, HEAD unchanged, edit still pending`, () => {
1409
+ dirty()
1410
+ const headBefore = t.observe.resolveRef("HEAD")
1411
+ const result = t.runScript(commitClean("gtd(human): gate → again"))
1412
+ expect(result.ok).toBe(false)
1413
+ expect(result.output).toContain("changed since `gtd land`")
1414
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
1415
+ expect(t.observe.statusPorcelain()).toContain(pending)
1416
+ })
1417
+ }
1418
+ refused(
1419
+ "a modified tracked file",
1420
+ () => {
1421
+ t.seed.commit("feat: base", { "a.txt": "1" })
1422
+ t.seed.writeFile("a.txt", "2")
1423
+ },
1424
+ "a.txt",
1425
+ )
1426
+ refused(
1427
+ "a staged change",
1428
+ () => {
1429
+ t.seed.writeFile("s.txt", "s")
1430
+ t.seed.stageAll()
1431
+ },
1432
+ "s.txt",
1433
+ )
1434
+ refused("an untracked file", () => t.seed.writeFile("u.txt", "u"), "u.txt")
1435
+ refused(
1436
+ "an edit staged then reverted in the worktree",
1437
+ () => {
1438
+ t.seed.commit("feat: base", { "a.txt": "1" })
1439
+ t.seed.writeFile("a.txt", "2")
1440
+ t.seed.stageAll()
1441
+ t.seed.writeFile("a.txt", "1")
1442
+ },
1443
+ "a.txt",
1444
+ )
1445
+ })
1446
+
1289
1447
  describe("commitAsIs", () => {
1290
1448
  it("does NOT stage — a worktree write after staging is excluded (the squash mechanic)", () => {
1291
1449
  t.seed.writeFile("a.txt", "a")
@@ -1,5 +1,6 @@
1
- // Pure TypeScript, no Effect, no real filesystem/git.
1
+ // No real filesystem/git.
2
2
  import { createHash } from "node:crypto"
3
+ import { CHANGED_SINCE_LAND } from "../emit/index.js"
3
4
 
4
5
  /** 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
6
  export const TEST_DOUBLE_SENTINEL = "gtd:test-double:never-ship"
@@ -11,6 +12,7 @@ interface Commit {
11
12
  parent: string | null
12
13
  /** Creation order — the twin's stand-in for a committer date. */
13
14
  seq: number
15
+ readonly signed: boolean
14
16
  }
15
17
 
16
18
  function sha1(input: string): string {
@@ -270,6 +272,11 @@ export class InMemRepo {
270
272
  return diffTrees(baseTree, worktreeTree)
271
273
  }
272
274
 
275
+ /** The index vs HEAD — the port's `stagedPaths`. */
276
+ stagedPaths(): Array<{ path: string; status: string }> {
277
+ return diffTrees(this.treeAt(undefined), this.index)
278
+ }
279
+
273
280
  diffPaths(base: string, tip: string): string[] {
274
281
  for (const ref of [base, tip]) {
275
282
  if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`)
@@ -346,6 +353,7 @@ export class InMemRepo {
346
353
  /** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
347
354
  hookRejection: string | undefined = undefined
348
355
  signingFails = false
356
+ signingEnabled = false
349
357
  /** Seeded: a pre-commit hook writing and staging this file. */
350
358
  hookStages: { readonly path: string; readonly content: string } | undefined = undefined
351
359
 
@@ -353,7 +361,14 @@ export class InMemRepo {
353
361
  const tree = new Map(this.index)
354
362
  const parent = this.head
355
363
  const hash = makeHash(message, parent, tree)
356
- const commit: Commit = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
364
+ const commit: Commit = {
365
+ hash,
366
+ message,
367
+ files: new Map(tree),
368
+ parent,
369
+ seq: this.nextSeq++,
370
+ signed: this.signingEnabled,
371
+ }
357
372
  this.commits.set(hash, commit)
358
373
  this.head = hash
359
374
  this.branches.set(this.currentBranch, hash)
@@ -378,6 +393,19 @@ export class InMemRepo {
378
393
  return undefined
379
394
  }
380
395
 
396
+ /** Twin of `GitScript.commitClean`: no hook runs, HEAD's tree is recorded; `undefined` on success, else the stderr text. */
397
+ commitClean(message: string): string | undefined {
398
+ if (this.head === null) return "fatal: Needed a single revision"
399
+ if (this.statusPorcelain().trim() !== "") return CHANGED_SINCE_LAND
400
+ if (this.signingFails) return "error: gpg failed to sign the data"
401
+ this.writeCommit(message)
402
+ return undefined
403
+ }
404
+
405
+ headSigned(): boolean {
406
+ return this.headCommit()?.signed === true
407
+ }
408
+
381
409
  /** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
382
410
  seedCommit(message: string): void {
383
411
  if (this.hookRejection !== undefined || this.hookStages !== undefined || this.signingFails) {
@@ -393,7 +421,14 @@ export class InMemRepo {
393
421
  const tree = new Map(this.index)
394
422
  const parent = this.head
395
423
  const hash = makeHash(message, parent, tree)
396
- this.commits.set(hash, { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ })
424
+ this.commits.set(hash, {
425
+ hash,
426
+ message,
427
+ files: new Map(tree),
428
+ parent,
429
+ seq: this.nextSeq++,
430
+ signed: false,
431
+ })
397
432
  const ref = `${refPrefix}/${hash}`
398
433
  this.refs.set(ref, hash)
399
434
  this.index = new Map(this.headTree())
@@ -70,6 +70,8 @@ export interface TestRepo {
70
70
  drive(actors?: Actors, options?: DriveOptions): Promise<BeatDocument>
71
71
  /** Oldest first. */
72
72
  subjects(): readonly string[]
73
+ /** Every commit's full message, subject and trailers, oldest first. */
74
+ messages(): readonly string[]
73
75
  }
74
76
 
75
77
  const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules")
@@ -272,6 +274,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
272
274
  throw new Error(`the process did not rest within ${maxBeats} beats`)
273
275
  },
274
276
  subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
277
+ messages: () => repo.commitHistory().map(({ message }) => message),
275
278
  }
276
279
  return self
277
280
  }
@@ -36,6 +36,12 @@ export class TestkitWorld extends QuickPickleWorld {
36
36
  this.gtdConfig = config
37
37
  }
38
38
 
39
+ extendConfig(tail: string): void {
40
+ this.assertNotCreated("a gtd config")
41
+ if (this.gtdConfig === undefined) throw new Error("no gtd config to extend")
42
+ this.gtdConfig += `\n${tail}`
43
+ }
44
+
39
45
  setEnv(name: string, value: string): void {
40
46
  this.assertNotCreated(`the environment variable ${name}`)
41
47
  this.env[name] = value
@@ -79,6 +85,10 @@ Given("a gtd config with:", (world: TestkitWorld, source: string) => {
79
85
  world.configure(withNewline(source))
80
86
  })
81
87
 
88
+ Given("the gtd config ends with:", (world: TestkitWorld, source: string) => {
89
+ world.extendConfig(withNewline(source))
90
+ })
91
+
82
92
  Given(
83
93
  "an environment variable {string} set to {string}",
84
94
  (world: TestkitWorld, name: string, value: string) => {
@@ -180,6 +190,14 @@ Then("the resting step's content contains {string}", async (world: TestkitWorld,
180
190
  assert.ok(content.includes(text), content)
181
191
  })
182
192
 
193
+ Then(
194
+ "the resting step's content does not contain {string}",
195
+ async (world: TestkitWorld, text: string) => {
196
+ const { content } = await world.repo().next()
197
+ assert.ok(!content.includes(text), content)
198
+ },
199
+ )
200
+
183
201
  Then("the last commit subject is {string}", (world: TestkitWorld, subject: string) => {
184
202
  assert.strictEqual(world.repo().subjects().at(-1), subject)
185
203
  })