@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
|
@@ -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.
|
|
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.
|
|
88
|
+
repo.seedCommit("chore: first")
|
|
88
89
|
const first = repo.resolveRef("HEAD")!
|
|
89
90
|
repo.writeFile("b.txt", "2")
|
|
90
|
-
repo.
|
|
91
|
+
repo.seedCommit("chore: second")
|
|
91
92
|
const twin = new InMemRepo()
|
|
92
93
|
twin.writeFile("a.txt", "1")
|
|
93
|
-
twin.
|
|
94
|
+
twin.seedCommit("chore: first")
|
|
94
95
|
twin.writeFile("b.txt", "2")
|
|
95
|
-
twin.
|
|
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.
|
|
109
|
+
repo.seedCommit("chore: first")
|
|
109
110
|
const first = repo.resolveRef("HEAD")!
|
|
110
111
|
repo.writeFile("b.txt", "2")
|
|
111
|
-
repo.
|
|
112
|
+
repo.seedCommit("chore: second")
|
|
112
113
|
const twin = new InMemRepo()
|
|
113
114
|
twin.writeFile("a.txt", "1")
|
|
114
|
-
twin.
|
|
115
|
+
twin.seedCommit("chore: first")
|
|
115
116
|
twin.writeFile("b.txt", "2")
|
|
116
|
-
twin.
|
|
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.
|
|
129
|
+
repo.seedCommit("chore: first")
|
|
129
130
|
const first = repo.resolveRef("HEAD")!
|
|
130
131
|
repo.writeFile("b.txt", "2")
|
|
131
|
-
repo.
|
|
132
|
+
repo.seedCommit("chore: second")
|
|
132
133
|
const twin = new InMemRepo()
|
|
133
134
|
twin.writeFile("a.txt", "1")
|
|
134
|
-
twin.
|
|
135
|
+
twin.seedCommit("chore: first")
|
|
135
136
|
twin.writeFile("b.txt", "2")
|
|
136
|
-
twin.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
},
|
|
@@ -76,10 +78,14 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
|
|
|
76
78
|
changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
|
|
77
79
|
})
|
|
78
80
|
|
|
81
|
+
const failOn = (output: string | undefined): void => {
|
|
82
|
+
if (output !== undefined) throw new Error(output)
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
const makeGitWriterOps = (repo: InMemRepo): GitWriterOperations => ({
|
|
80
|
-
commitAllWithPrefix: (prefix: string) => tryCatch(() => repo.commitAllWithPrefix(prefix)),
|
|
86
|
+
commitAllWithPrefix: (prefix: string) => tryCatch(() => failOn(repo.commitAllWithPrefix(prefix))),
|
|
81
87
|
softResetTo: (ref: string) => tryCatch(() => repo.softResetTo(ref)),
|
|
82
|
-
commitAsIs: (message: string) => tryCatch(() => repo.commitAsIs(message)),
|
|
88
|
+
commitAsIs: (message: string) => tryCatch(() => failOn(repo.commitAsIs(message))),
|
|
83
89
|
discardPending: () => tryCatch(() => repo.discardPending()),
|
|
84
90
|
updateRef: (ref: string, hash: string) => tryCatch(() => repo.updateRef(ref, hash)),
|
|
85
91
|
deleteRef: (ref: string) => tryCatch(() => repo.deleteRef(ref)),
|