@pmelab/gtd 26.0.0 → 27.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.
Files changed (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -3
  3. package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
  4. package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
  5. package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
  6. package/dist/flows.mjs +15 -1
  7. package/dist/gtd.bundle.mjs +219 -34
  8. package/dist/testkit.mjs +447 -72
  9. package/dist/wire.mjs +2 -2
  10. package/dist/workflows/feature.mjs +1 -1
  11. package/dist/workflows/fix.mjs +1 -1
  12. package/dist/workflows/review.mjs +1 -1
  13. package/package.json +2 -2
  14. package/src/cli/Cli.test.ts +56 -4
  15. package/src/cli/Cli.ts +48 -6
  16. package/src/cli/History.test.ts +2 -2
  17. package/src/cli/Output.ts +3 -1
  18. package/src/cli/Program.test.ts +11 -12
  19. package/src/cli/Program.ts +28 -7
  20. package/src/cli/Strays.test.ts +88 -0
  21. package/src/cli/Strays.ts +56 -0
  22. package/src/edge/Edge.test.ts +113 -16
  23. package/src/edge/Edge.ts +22 -6
  24. package/src/emit/GitScript.test.ts +65 -2
  25. package/src/emit/GitScript.ts +34 -1
  26. package/src/emit/index.ts +1 -0
  27. package/src/flags/index.ts +0 -1
  28. package/src/flows/runtime.test.ts +33 -0
  29. package/src/flows/runtime.ts +31 -0
  30. package/src/platform/Git.test.ts +96 -1
  31. package/src/platform/Git.ts +24 -0
  32. package/src/platform/RetainedHistory.test.ts +9 -0
  33. package/src/platform/RetainedHistory.ts +3 -0
  34. package/src/platform/Workspace.test.ts +1 -1
  35. package/src/platform/index.ts +1 -0
  36. package/src/replay/RefName.test.ts +40 -0
  37. package/src/replay/RefName.ts +19 -0
  38. package/src/replay/Replay.test.ts +193 -2
  39. package/src/replay/Replay.ts +51 -6
  40. package/src/replay/Trailers.test.ts +26 -0
  41. package/src/replay/Trailers.ts +8 -0
  42. package/src/replay/index.ts +1 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/RepoSnapshot.ts +2 -0
  45. package/src/step/index.ts +1 -0
  46. package/src/step/planStart.test.ts +5 -5
  47. package/src/step/planStep.test.ts +53 -0
  48. package/src/step/planStep.ts +7 -1
  49. package/src/step/strayPaths.test.ts +17 -0
  50. package/src/step/strayPaths.ts +8 -0
  51. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  52. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  53. package/src/testkit/EmittedScripts.test.ts +182 -0
  54. package/src/testkit/FakeGitOperations.ts +10 -2
  55. package/src/testkit/GitTiers.ts +170 -25
  56. package/src/testkit/InMemRepo.ts +64 -12
  57. package/src/testkit/Layers.test.ts +34 -1
  58. package/src/testkit/Layers.ts +21 -1
  59. package/src/testkit/TestRepo.ts +3 -3
  60. package/src/wire/ExitCodes.test.ts +11 -3
  61. package/src/wire/ExitCodes.ts +3 -0
  62. package/src/wire/Glob.test.ts +35 -0
  63. package/src/wire/Glob.ts +1 -1
  64. package/src/workflow/Workflow.ts +1 -0
  65. package/src/workflow/discovery.test.ts +1 -1
  66. package/src/workflows/shared/text.fixture.ts +1 -0
@@ -130,3 +130,56 @@ describe("planStep — keepHistory", () => {
130
130
  ).toBe(false)
131
131
  })
132
132
  })
133
+
134
+ describe("planStep — keepTurn", () => {
135
+ const kept = "refs/gtd/kept/abc/building"
136
+
137
+ it("emits keepTurn in place of commitAll, carrying the step's own commit message", () => {
138
+ const s = snapshot({
139
+ state: "building",
140
+ stepDef: { actor: "agent", kind: "prompt" },
141
+ actor: "agent",
142
+ changes: [{ status: "A", path: "naive.txt" }],
143
+ landing: { ...commitTo("building", "checking", "agent"), keepTurn: kept },
144
+ })
145
+ const outcome = planStep(s)
146
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
147
+ expect(outcome.steps.map((step) => step.kind)).toEqual(["gitWrite", "outcome"])
148
+ expect(outcome.steps[0]).toEqual({
149
+ kind: "gitWrite",
150
+ write: {
151
+ kind: "keepTurn",
152
+ ref: kept,
153
+ message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format: 1",
154
+ },
155
+ })
156
+ })
157
+
158
+ it("a finishing keep landing points the history ref after the marker", () => {
159
+ const s = snapshot({
160
+ state: "building",
161
+ stepDef: { actor: "agent", kind: "prompt" },
162
+ actor: "agent",
163
+ landing: {
164
+ ...commitTo("building", "idle", "agent"),
165
+ keepTurn: kept,
166
+ keepHistory: "refs/gtd/history/abc",
167
+ },
168
+ })
169
+ const outcome = planStep(s)
170
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
171
+ expect(outcome.steps.map((step) => step.kind)).toEqual(["gitWrite", "gitWrite", "outcome"])
172
+ expect(outcome.steps[0]).toMatchObject({ write: { kind: "keepTurn" } })
173
+ expect(outcome.steps[1]).toEqual({
174
+ kind: "gitWrite",
175
+ write: { kind: "updateRef", ref: "refs/gtd/history/abc", target: "HEAD" },
176
+ })
177
+ })
178
+
179
+ it("without keepTurn the landing stays a commitAll", () => {
180
+ const s = snapshot({ state: "building", stepDef: {}, landing: commitTo("building", "done") })
181
+ const outcome = planStep(s)
182
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
183
+ expect(outcome.steps[0]).toMatchObject({ write: { kind: "commitAll" } })
184
+ })
185
+ })
@@ -82,7 +82,13 @@ export const planStep = (
82
82
  subject,
83
83
  steps: [
84
84
  ...uncheckStep(snapshot),
85
- { kind: "gitWrite", write: { kind: "commitAll", message } },
85
+ {
86
+ kind: "gitWrite",
87
+ write:
88
+ landing.keepTurn !== undefined
89
+ ? { kind: "keepTurn", ref: landing.keepTurn, message }
90
+ : { kind: "commitAll", message },
91
+ },
86
92
  ...(landing.keepHistory !== undefined
87
93
  ? [
88
94
  {
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { strayPaths } from "./index.js"
3
+
4
+ describe("strayPaths", () => {
5
+ it("returns the paths no glob matches, sorted", () => {
6
+ expect(strayPaths(["src/a.ts", "z.md", "a.md"], ["src/**"])).toEqual(["a.md", "z.md"])
7
+ })
8
+
9
+ it("an empty allowlist returns every touched path", () => {
10
+ expect(strayPaths(["b", "a"], [])).toEqual(["a", "b"])
11
+ })
12
+
13
+ it("an empty touched list returns []", () => {
14
+ expect(strayPaths([], ["src/**"])).toEqual([])
15
+ expect(strayPaths([], [])).toEqual([])
16
+ })
17
+ })
@@ -0,0 +1,8 @@
1
+ import { globMatches } from "../wire/index.js"
2
+
3
+ /** Globs use the `access.write` dialect (`globMatches`). */
4
+ export const strayPaths = (
5
+ touched: ReadonlyArray<string>,
6
+ allow: ReadonlyArray<string>,
7
+ ): ReadonlyArray<string> =>
8
+ touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort()
@@ -5,6 +5,7 @@ import {
5
5
  deleteRef,
6
6
  discardPending,
7
7
  hardResetTo,
8
+ keepTurn,
8
9
  mixedResetTo,
9
10
  softResetTo,
10
11
  updateRef,
@@ -40,7 +41,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
40
41
  twin.writeFile("a.txt", "1")
41
42
 
42
43
  const result = applyEmittedScript(repo, NO_COMMANDS, commitAll("gtd(agent): building"))
43
- twin.commitAllWithPrefix("gtd(agent): building")
44
+ twin.seedCommit("gtd(agent): building")
44
45
 
45
46
  expect(result).toMatchObject({ ok: true })
46
47
  expect(snapshot(repo)).toEqual(snapshot(twin))
@@ -84,15 +85,15 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
84
85
  it("softResetTo: moves HEAD only, leaving index and worktree untouched", () => {
85
86
  const repo = new InMemRepo()
86
87
  repo.writeFile("a.txt", "1")
87
- repo.commitAllWithPrefix("chore: first")
88
+ repo.seedCommit("chore: first")
88
89
  const first = repo.resolveRef("HEAD")!
89
90
  repo.writeFile("b.txt", "2")
90
- repo.commitAllWithPrefix("chore: second")
91
+ repo.seedCommit("chore: second")
91
92
  const twin = new InMemRepo()
92
93
  twin.writeFile("a.txt", "1")
93
- twin.commitAllWithPrefix("chore: first")
94
+ twin.seedCommit("chore: first")
94
95
  twin.writeFile("b.txt", "2")
95
- twin.commitAllWithPrefix("chore: second")
96
+ twin.seedCommit("chore: second")
96
97
 
97
98
  const result = applyEmittedScript(repo, NO_COMMANDS, softResetTo(first))
98
99
  twin.softResetTo(first)
@@ -105,15 +106,15 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
105
106
  it("mixedResetTo: moves HEAD and index, leaving the worktree untouched", () => {
106
107
  const repo = new InMemRepo()
107
108
  repo.writeFile("a.txt", "1")
108
- repo.commitAllWithPrefix("chore: first")
109
+ repo.seedCommit("chore: first")
109
110
  const first = repo.resolveRef("HEAD")!
110
111
  repo.writeFile("b.txt", "2")
111
- repo.commitAllWithPrefix("chore: second")
112
+ repo.seedCommit("chore: second")
112
113
  const twin = new InMemRepo()
113
114
  twin.writeFile("a.txt", "1")
114
- twin.commitAllWithPrefix("chore: first")
115
+ twin.seedCommit("chore: first")
115
116
  twin.writeFile("b.txt", "2")
116
- twin.commitAllWithPrefix("chore: second")
117
+ twin.seedCommit("chore: second")
117
118
 
118
119
  const result = applyEmittedScript(repo, NO_COMMANDS, mixedResetTo(first))
119
120
  twin.mixedResetTo(first)
@@ -125,15 +126,15 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
125
126
  it("hardResetTo: moves HEAD, index, AND worktree", () => {
126
127
  const repo = new InMemRepo()
127
128
  repo.writeFile("a.txt", "1")
128
- repo.commitAllWithPrefix("chore: first")
129
+ repo.seedCommit("chore: first")
129
130
  const first = repo.resolveRef("HEAD")!
130
131
  repo.writeFile("b.txt", "2")
131
- repo.commitAllWithPrefix("chore: second")
132
+ repo.seedCommit("chore: second")
132
133
  const twin = new InMemRepo()
133
134
  twin.writeFile("a.txt", "1")
134
- twin.commitAllWithPrefix("chore: first")
135
+ twin.seedCommit("chore: first")
135
136
  twin.writeFile("b.txt", "2")
136
- twin.commitAllWithPrefix("chore: second")
137
+ twin.seedCommit("chore: second")
137
138
 
138
139
  const result = applyEmittedScript(repo, NO_COMMANDS, hardResetTo(first))
139
140
  twin.hardResetTo(first)
@@ -146,12 +147,12 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
146
147
  it("discardPending: drops every pending change, tracked or untracked", () => {
147
148
  const repo = new InMemRepo()
148
149
  repo.writeFile("a.txt", "1")
149
- repo.commitAllWithPrefix("chore: first")
150
+ repo.seedCommit("chore: first")
150
151
  repo.writeFile("a.txt", "changed")
151
152
  repo.writeFile("untracked.txt", "new")
152
153
  const twin = new InMemRepo()
153
154
  twin.writeFile("a.txt", "1")
154
- twin.commitAllWithPrefix("chore: first")
155
+ twin.seedCommit("chore: first")
155
156
  twin.writeFile("a.txt", "changed")
156
157
  twin.writeFile("untracked.txt", "new")
157
158
 
@@ -167,11 +168,11 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
167
168
  it("updateRef: points a repo-local ref at a commit hash", () => {
168
169
  const repo = new InMemRepo()
169
170
  repo.writeFile("a.txt", "1")
170
- repo.commitAllWithPrefix("chore: first")
171
+ repo.seedCommit("chore: first")
171
172
  const hash = repo.resolveRef("HEAD")!
172
173
  const twin = new InMemRepo()
173
174
  twin.writeFile("a.txt", "1")
174
- twin.commitAllWithPrefix("chore: first")
175
+ twin.seedCommit("chore: first")
175
176
 
176
177
  const result = applyEmittedScript(repo, NO_COMMANDS, updateRef("refs/gtd/base", hash))
177
178
  twin.updateRef("refs/gtd/base", hash)
@@ -184,11 +185,11 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
184
185
  it("deleteRef: removes a repo-local ref (idempotently)", () => {
185
186
  const repo = new InMemRepo()
186
187
  repo.writeFile("a.txt", "1")
187
- repo.commitAllWithPrefix("chore: first")
188
+ repo.seedCommit("chore: first")
188
189
  repo.updateRef("refs/gtd/base", repo.resolveRef("HEAD")!)
189
190
  const twin = new InMemRepo()
190
191
  twin.writeFile("a.txt", "1")
191
- twin.commitAllWithPrefix("chore: first")
192
+ twin.seedCommit("chore: first")
192
193
  twin.updateRef("refs/gtd/base", twin.resolveRef("HEAD")!)
193
194
 
194
195
  const result = applyEmittedScript(repo, NO_COMMANDS, deleteRef("refs/gtd/base"))
@@ -405,7 +406,7 @@ describe("applyEmittedScript — a tripped precondition stops the script", () =>
405
406
  it("fails at the precondition and never applies a subsequent recognizable block", () => {
406
407
  const repo = new InMemRepo()
407
408
  repo.writeFile("a.txt", "1")
408
- repo.commitAllWithPrefix("chore: first")
409
+ repo.seedCommit("chore: first")
409
410
  const actualHead = repo.resolveRef("HEAD")!
410
411
  const wrongHash = "0".repeat(40)
411
412
  expect(wrongHash).not.toBe(actualHead)
@@ -426,7 +427,7 @@ describe("applyEmittedScript — a tripped precondition stops the script", () =>
426
427
  it("a satisfied precondition is a no-op and lets the rest of the script run", () => {
427
428
  const repo = new InMemRepo()
428
429
  repo.writeFile("a.txt", "1")
429
- repo.commitAllWithPrefix("chore: first")
430
+ repo.seedCommit("chore: first")
430
431
  const actualHead = repo.resolveRef("HEAD")!
431
432
 
432
433
  const script = [preconditionHeadEquals(actualHead), commitAll("gtd(agent): lands")].join("\n\n")
@@ -442,7 +443,7 @@ describe("applyEmittedScript — a realistic assembled multi-block script", () =
442
443
  it("parses set -eu and a satisfied precondition alongside real builder blocks", () => {
443
444
  const repo = new InMemRepo()
444
445
  repo.writeFile("a.txt", "1")
445
- repo.commitAllWithPrefix("chore: first")
446
+ repo.seedCommit("chore: first")
446
447
  const first = repo.resolveRef("HEAD")!
447
448
 
448
449
  const script = [
@@ -464,11 +465,11 @@ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script",
464
465
  it("an assembled commitAll applies exactly like commitAllWithPrefix", () => {
465
466
  const repo = new InMemRepo()
466
467
  repo.writeFile("base.txt", "0")
467
- repo.commitAllWithPrefix("chore: first")
468
+ repo.seedCommit("chore: first")
468
469
  repo.writeFile("a.txt", "1")
469
470
  const twin = new InMemRepo()
470
471
  twin.writeFile("base.txt", "0")
471
- twin.commitAllWithPrefix("chore: first")
472
+ twin.seedCommit("chore: first")
472
473
  twin.writeFile("a.txt", "1")
473
474
 
474
475
  const steps: readonly EmitStep[] = [
@@ -476,7 +477,7 @@ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script",
476
477
  ]
477
478
  const { required } = emitScripts(steps)
478
479
  const result = applyEmittedScript(repo, NO_COMMANDS, required)
479
- twin.commitAllWithPrefix("gtd(agent): building")
480
+ twin.seedCommit("gtd(agent): building")
480
481
 
481
482
  expect(result).toMatchObject({ ok: true })
482
483
  expect(snapshot(repo)).toEqual(snapshot(twin))
@@ -485,7 +486,7 @@ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script",
485
486
  it("a squash's four gitWrite steps (retain, soft-reset, commit-as-is, discard) apply in order", () => {
486
487
  const repo = new InMemRepo()
487
488
  repo.writeFile("a.txt", "1")
488
- repo.commitAllWithPrefix("gtd(agent): building")
489
+ repo.seedCommit("gtd(agent): building")
489
490
  const tip = repo.resolveRef("HEAD")!
490
491
  // Untracked, never staged — the index still matches the tip's own tree
491
492
  // exactly, mirroring the real flow where nothing `git add`s between the
@@ -537,11 +538,11 @@ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss
537
538
  it("a realistic assembled script — commitAll's gitWrite plus a trailing outcome step — applies exactly like commitAllWithPrefix, the outcome step contributing nothing", () => {
538
539
  const repo = new InMemRepo()
539
540
  repo.writeFile("base.txt", "0")
540
- repo.commitAllWithPrefix("chore: first")
541
+ repo.seedCommit("chore: first")
541
542
  repo.writeFile("a.txt", "1")
542
543
  const twin = new InMemRepo()
543
544
  twin.writeFile("base.txt", "0")
544
- twin.commitAllWithPrefix("chore: first")
545
+ twin.seedCommit("chore: first")
545
546
  twin.writeFile("a.txt", "1")
546
547
 
547
548
  const steps: readonly EmitStep[] = [
@@ -550,7 +551,7 @@ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss
550
551
  ]
551
552
  const { required } = emitScripts(steps)
552
553
  const result = applyEmittedScript(repo, NO_COMMANDS, required)
553
- twin.commitAllWithPrefix("gtd(agent): building")
554
+ twin.seedCommit("gtd(agent): building")
554
555
 
555
556
  expect(result).toMatchObject({ ok: true })
556
557
  expect(snapshot(repo)).toEqual(snapshot(twin))
@@ -636,3 +637,30 @@ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a
636
637
  expect(result.error).toContain("totally-unrecognized-required-command")
637
638
  })
638
639
  })
640
+
641
+ describe("applyEmittedScript — keepTurn", () => {
642
+ const MESSAGE = "gtd(agent): a → b\n\nGtd-Step: a#1\nGtd-Format: 1"
643
+ const REF = "refs/gtd/kept/entry/a"
644
+
645
+ const seeded = (): InMemRepo => {
646
+ const repo = new InMemRepo()
647
+ repo.writeFile("base.txt", "0")
648
+ repo.seedCommit("chore: first")
649
+ repo.writeFile("turn.txt", "1")
650
+ return repo
651
+ }
652
+
653
+ it("fails loudly on a near-miss and applies nothing", () => {
654
+ const repo = seeded()
655
+ const before = snapshot(repo)
656
+ const nearMiss = keepTurn(REF, MESSAGE).replace(
657
+ "git reset --hard HEAD",
658
+ "git reset --soft HEAD",
659
+ )
660
+ const result = applyEmittedScript(repo, NO_COMMANDS, nearMiss)
661
+ expect(result.ok).toBe(false)
662
+ expect(result.error).toContain("unrecognized script block")
663
+ expect(snapshot(repo)).toEqual(before)
664
+ expect(repo.resolveRef(REF)).toBeNull()
665
+ })
666
+ })
@@ -9,6 +9,7 @@ import {
9
9
  deleteRef,
10
10
  discardPending,
11
11
  hardResetTo,
12
+ keepTurn,
12
13
  mixedResetTo,
13
14
  shellQuote,
14
15
  softResetTo,
@@ -30,6 +31,7 @@ import {
30
31
  steeringFormatFor,
31
32
  type SteeringFormat,
32
33
  } from "../steering/index.js"
34
+ import { refNameFault } from "../replay/index.js"
33
35
  import { renderFormat } from "../wire/index.js"
34
36
  import { applyFlowScript, rewritten } from "./FlowScripts.js"
35
37
  import type { InMemRepo } from "./InMemRepo.js"
@@ -140,6 +142,20 @@ const conditionStatement = (block: string): string => {
140
142
  return end === -1 ? block.slice(start) : block.slice(start, end)
141
143
  }
142
144
 
145
+ /** `keepTurn`: an invalid ref name fails before anything moves, as `git update-ref` does. */
146
+ const recognizeKeepTurn = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
147
+ const [message, refDir] = extractQuotedTokens(block)
148
+ const ref = refDir?.slice(0, -1)
149
+ if (message === undefined || ref === undefined || keepTurn(ref, message) !== block) {
150
+ return undefined
151
+ }
152
+ if (refNameFault(ref) !== undefined) {
153
+ return { kind: "failed", error: `fatal: invalid ref format: ${ref}`, status: 128 }
154
+ }
155
+ const failure = repo.keepTurn(ref, message)
156
+ return failure === undefined ? { kind: "applied" } : { kind: "failed", error: failure, status: 1 }
157
+ }
158
+
143
159
  // fallow-ignore-next-line complexity
144
160
  const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
145
161
  if (block === discardPending()) {
@@ -156,11 +172,15 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
156
172
  // quoted argument.
157
173
  const conditionLine = conditionStatement(block)
158
174
 
175
+ if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block)
176
+
159
177
  if (block.startsWith("git add -A || exit 1\n")) {
160
178
  const [message] = extractQuotedTokens(conditionLine)
161
179
  if (message !== undefined && commitAll(message) === block) {
162
- repo.commitAllWithPrefix(message)
163
- return { kind: "applied" }
180
+ const failure = repo.commitAllWithPrefix(message)
181
+ return failure === undefined
182
+ ? { kind: "applied" }
183
+ : { kind: "failed", error: failure, status: 1 }
164
184
  }
165
185
  return undefined
166
186
  }
@@ -168,8 +188,10 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
168
188
  if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
169
189
  const [message] = extractQuotedTokens(conditionLine)
170
190
  if (message !== undefined && commitAsIs(message) === block) {
171
- repo.commitAsIs(message)
172
- return { kind: "applied" }
191
+ const failure = repo.commitAsIs(message)
192
+ return failure === undefined
193
+ ? { kind: "applied" }
194
+ : { kind: "failed", error: failure, status: 1 }
173
195
  }
174
196
  return undefined
175
197
  }
@@ -3,6 +3,8 @@ import {
3
3
  abandonedOutcome,
4
4
  binaryGuard,
5
5
  combinedScript,
6
+ commitAll,
7
+ commitAsIs,
6
8
  emitScripts,
7
9
  failurePromptWrapper,
8
10
  fileExistsGuard,
@@ -11,6 +13,9 @@ import {
11
13
  withFileVar,
12
14
  type EmitStep,
13
15
  } from "../emit/index.js"
16
+ import { Effect } from "effect"
17
+ import { ScriptSurface } from "../emit/index.js"
18
+ import { GitService } from "../platform/index.js"
14
19
  import { gitTiers, type GitTier, type ScriptedCommand } from "./index.js"
15
20
 
16
21
  // Every case runs through real `sh` — with this build's `gtd` on PATH — and
@@ -162,5 +167,182 @@ for (const makeTier of gitTiers) {
162
167
  expect(run(script(`gtd uncheck '${review}'`)).ok).toBe(true)
163
168
  expect(read(review)).toContain("- [ ] ./src/calc.ts#1-1")
164
169
  })
170
+
171
+ // The hook output carries the marker the emitted case retries on.
172
+ const RETRYABLE = "rejected: empty git commit"
173
+
174
+ describe("keepTurn", () => {
175
+ const PREFIX = "refs/gtd/kept/entry/build.naive"
176
+ const MESSAGE =
177
+ "gtd(agent): build.naive → build.next\n\nGtd-Step: build.naive#1\nGtd-Format: 1"
178
+ // As a landing renders it: under `set -eu`, which a failing update-ref relies on to stop.
179
+ const keepTurn = (ref: string, message: string) =>
180
+ ScriptSurface.render([{ kind: "gitWrite", write: { kind: "keepTurn", ref, message } }])
181
+ const git = <A>(f: (g: GitService["Type"]) => Effect.Effect<A, Error>) =>
182
+ t.provide(Effect.flatMap(GitService, f))
183
+ const keptTip = async () => {
184
+ const marker = await git((g) => g.lastCommitMessage())
185
+ return /Gtd-Kept: ([0-9a-f]+)/.exec(marker)?.[1] ?? ""
186
+ }
187
+
188
+ it("keeps the turn off the branch: HEAD's tree is the pre-turn tree, the marker carries the kept tip", async () => {
189
+ const before = t.observe.resolveRef("HEAD")
190
+ t.seed.writeFile("naive.txt", "naive\n")
191
+ expect(run(keepTurn(PREFIX, MESSAGE))).toMatchObject({ ok: true })
192
+
193
+ expect(read("naive.txt")).toBeUndefined()
194
+ expect(read("readme.txt")).toBe("hello")
195
+ expect(t.observe.statusPorcelain()).toBe("")
196
+ const kept = await keptTip()
197
+ expect(t.observe.resolveRef(`${PREFIX}/${kept}`)).toBe(kept)
198
+ expect(t.observe.resolveRef(`${kept}^`)).toBe(before)
199
+ expect(await git((g) => g.readFileAtRef(kept, "naive.txt"))).toBe("naive\n")
200
+ expect(t.observe.resolveRef("HEAD^")).toBe(before)
201
+ expect(t.observe.headTouchedPaths()).toEqual([])
202
+ const marker = await git((g) => g.lastCommitMessage())
203
+ expect(marker.trim()).toBe(`${MESSAGE}\nGtd-Kept: ${kept}`)
204
+ })
205
+
206
+ it("a re-keep files a second ref and leaves the first pass's", async () => {
207
+ t.seed.writeFile("naive.txt", "one\n")
208
+ expect(run(keepTurn(PREFIX, MESSAGE)).ok).toBe(true)
209
+ const first = await keptTip()
210
+ t.seed.writeFile("naive.txt", "two\n")
211
+ expect(run(keepTurn(PREFIX, MESSAGE)).ok).toBe(true)
212
+ const second = await keptTip()
213
+ expect(second).not.toBe(first)
214
+ expect(t.observe.refExists(`${PREFIX}/${first}`)).toBe(true)
215
+ expect(t.observe.refExists(`${PREFIX}/${second}`)).toBe(true)
216
+ })
217
+
218
+ it("an unwritable ref exits non-zero with HEAD and the working tree unchanged", () => {
219
+ const head = t.observe.resolveRef("HEAD")
220
+ t.seed.writeFile("naive.txt", "naive\n")
221
+ const result = run(keepTurn("refs/gtd/kept/entry/bad..name", MESSAGE))
222
+ expect(result.ok).toBe(false)
223
+ expect(result.status).not.toBe(0)
224
+ expect(t.observe.resolveRef("HEAD")).toBe(head)
225
+ expect(read("naive.txt")).toBe("naive\n")
226
+ })
227
+
228
+ it("a staging hook does not reach the marker: HEAD's tree stays the pre-turn tree", async () => {
229
+ const before = t.observe.resolveRef("HEAD")
230
+ t.seed.stagingHook("readme.txt", "hooked")
231
+ t.seed.writeFile("naive.txt", "naive\n")
232
+ expect(run(keepTurn(PREFIX, MESSAGE))).toMatchObject({ ok: true })
233
+
234
+ expect(t.observe.headTouchedPaths()).toEqual([])
235
+ expect(t.observe.resolveRef("HEAD^")).toBe(before)
236
+ expect(read("readme.txt")).toBe("hello")
237
+ expect(t.observe.statusPorcelain()).toBe("")
238
+ const kept = await keptTip()
239
+ expect(t.observe.resolveRef(`${PREFIX}/${kept}`)).toBe(kept)
240
+ expect(t.observe.resolveRef(`${kept}^`)).toBe(before)
241
+ expect(await git((g) => g.readFileAtRef(kept, "readme.txt"))).toBe("hello")
242
+ expect(await git((g) => g.readFileAtRef(kept, "naive.txt"))).toBe("naive\n")
243
+ })
244
+
245
+ for (const output of ["rejected", "lint error"]) {
246
+ it(`a rejecting hook ("${output}") does not stop the marker`, async () => {
247
+ const before = t.observe.resolveRef("HEAD")
248
+ t.seed.rejectingHook(output)
249
+ t.seed.writeFile("naive.txt", "naive\n")
250
+ expect(run(keepTurn(PREFIX, MESSAGE))).toMatchObject({ ok: true })
251
+
252
+ const kept = await keptTip()
253
+ expect(kept).not.toBe("")
254
+ expect(t.observe.resolveRef(`${PREFIX}/${kept}`)).toBe(kept)
255
+ expect(t.observe.resolveRef("HEAD^")).toBe(before)
256
+ expect(t.observe.headTouchedPaths()).toEqual([])
257
+ expect(t.observe.statusPorcelain()).toBe("")
258
+ expect(await git((g) => g.readFileAtRef(kept, "naive.txt"))).toBe("naive\n")
259
+ })
260
+ }
261
+
262
+ it("a failing signer alone puts the turn back in the working tree and drops its kept ref", async () => {
263
+ const head = t.observe.resolveRef("HEAD")
264
+ t.seed.failingSigner()
265
+ t.seed.writeFile("naive.txt", "naive\n")
266
+ const status = t.observe.statusPorcelain()
267
+ const result = run(keepTurn(PREFIX, MESSAGE))
268
+ expect(result.ok).toBe(false)
269
+ expect(result.status).toBe(1)
270
+ expect(result.output).toContain("failed to sign")
271
+ expect(t.observe.statusPorcelain()).toBe(status)
272
+ expect(t.observe.resolveRef("HEAD")).toBe(head)
273
+ expect(read("naive.txt")).toBe("naive\n")
274
+ expect(await git((g) => g.listRefs(`${PREFIX}/`))).toEqual([])
275
+ })
276
+ })
277
+
278
+ describe("commitAll", () => {
279
+ it("a staging hook's edit lands in the commit", () => {
280
+ t.seed.stagingHook("readme.txt", "hooked")
281
+ t.seed.writeFile("a.txt", "a\n")
282
+ expect(run(commitAll("gtd(agent): building")).ok).toBe(true)
283
+ expect(t.observe.headTouchedPaths().toSorted()).toEqual(["a.txt", "readme.txt"])
284
+ expect(read("readme.txt")).toBe("hooked")
285
+ })
286
+
287
+ it("hook output with shell metacharacters reaches stderr verbatim", () => {
288
+ const output = "bad $HOME $(echo x) `id`"
289
+ t.seed.rejectingHook(output)
290
+ t.seed.writeFile("a.txt", "a\n")
291
+ const result = run(commitAll("gtd(agent): building"))
292
+ expect(result.ok).toBe(false)
293
+ expect(result.output).toContain(output)
294
+ })
295
+
296
+ it("fails with status 1 and HEAD unchanged when the commit cannot be written even without hooks", () => {
297
+ const head = t.observe.resolveRef("HEAD")
298
+ t.seed.rejectingHook(RETRYABLE)
299
+ t.seed.failingSigner()
300
+ t.seed.writeFile("a.txt", "a\n")
301
+ const result = run(commitAll("gtd(agent): building"))
302
+ expect(result.ok).toBe(false)
303
+ expect(result.status).toBe(1)
304
+ expect(result.output).toContain("failed to sign")
305
+ expect(t.observe.resolveRef("HEAD")).toBe(head)
306
+ })
307
+
308
+ it("fails with status 1 when only the signer fails, no hook having triggered the retry", () => {
309
+ const head = t.observe.resolveRef("HEAD")
310
+ t.seed.failingSigner()
311
+ t.seed.writeFile("a.txt", "a\n")
312
+ const result = run(commitAll("gtd(agent): building"))
313
+ expect(result.ok).toBe(false)
314
+ expect(result.status).toBe(1)
315
+ expect(result.output).toContain("failed to sign")
316
+ expect(t.observe.resolveRef("HEAD")).toBe(head)
317
+ })
318
+ })
319
+
320
+ describe("commitAsIs", () => {
321
+ it("a non-retryable hook rejection fails with status 1 and leaves HEAD and the index unchanged", () => {
322
+ const head = t.observe.resolveRef("HEAD")
323
+ t.seed.writeFile("a.txt", "a\n")
324
+ t.seed.stageAll()
325
+ t.seed.rejectingHook("rejected")
326
+ const staged = t.observe.statusPorcelain()
327
+ const result = run(commitAsIs("gtd(agent): as-is"))
328
+ expect(result.ok).toBe(false)
329
+ expect(result.status).toBe(1)
330
+ expect(result.output).toContain("rejected")
331
+ expect(t.observe.resolveRef("HEAD")).toBe(head)
332
+ expect(t.observe.statusPorcelain()).toBe(staged)
333
+ })
334
+
335
+ it("a failing signer with no hook fails with status 1 and leaves HEAD unchanged", () => {
336
+ const head = t.observe.resolveRef("HEAD")
337
+ t.seed.writeFile("a.txt", "a\n")
338
+ t.seed.stageAll()
339
+ t.seed.failingSigner()
340
+ const result = run(commitAsIs("gtd(agent): as-is"))
341
+ expect(result.ok).toBe(false)
342
+ expect(result.status).toBe(1)
343
+ expect(result.output).toContain("failed to sign")
344
+ expect(t.observe.resolveRef("HEAD")).toBe(head)
345
+ })
346
+ })
165
347
  })
166
348
  }
@@ -40,6 +40,8 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
40
40
  },
41
41
 
42
42
  readRefOption: (ref: string) => {
43
+ // Live `rev-parse --verify` echoes a bare 40-hex id even when no such object exists.
44
+ if (/^[0-9a-f]{40}$/.test(ref)) return Effect.succeed(Option.some(ref))
43
45
  const hash = repo.resolveRef(ref)
44
46
  return Effect.succeed(hash !== null ? Option.some(hash) : Option.none<string>())
45
47
  },
@@ -74,12 +76,18 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
74
76
  Effect.succeed(repo.subjectHistory(pageSize, skip, head)),
75
77
 
76
78
  changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
79
+
80
+ diffPaths: (base: string, tip: string) => tryCatch(() => repo.diffPaths(base, tip)),
77
81
  })
78
82
 
83
+ const failOn = (output: string | undefined): void => {
84
+ if (output !== undefined) throw new Error(output)
85
+ }
86
+
79
87
  const makeGitWriterOps = (repo: InMemRepo): GitWriterOperations => ({
80
- commitAllWithPrefix: (prefix: string) => tryCatch(() => repo.commitAllWithPrefix(prefix)),
88
+ commitAllWithPrefix: (prefix: string) => tryCatch(() => failOn(repo.commitAllWithPrefix(prefix))),
81
89
  softResetTo: (ref: string) => tryCatch(() => repo.softResetTo(ref)),
82
- commitAsIs: (message: string) => tryCatch(() => repo.commitAsIs(message)),
90
+ commitAsIs: (message: string) => tryCatch(() => failOn(repo.commitAsIs(message))),
83
91
  discardPending: () => tryCatch(() => repo.discardPending()),
84
92
  updateRef: (ref: string, hash: string) => tryCatch(() => repo.updateRef(ref, hash)),
85
93
  deleteRef: (ref: string) => tryCatch(() => repo.deleteRef(ref)),