@pmelab/gtd 25.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 +7 -0
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +161 -20
- package/dist/testkit.mjs +311 -57
- package/package.json +2 -1
- package/src/cli/Cli.test.ts +28 -3
- package/src/cli/Cli.ts +12 -1
- 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.test.ts +67 -0
- package/src/flags/index.ts +38 -0
- 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/workflow/vars.test.ts +16 -0
- package/src/workflow/vars.ts +4 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
|
@@ -147,3 +147,29 @@ describe("the commit-message codec", () => {
|
|
|
147
147
|
expect(parseSubject("gtd(agent): fix")).toEqual({ actor: "agent", to: "fix" })
|
|
148
148
|
})
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
describe("Gtd-Kept", () => {
|
|
152
|
+
const hash = "0123456789abcdef0123456789abcdef01234567"
|
|
153
|
+
|
|
154
|
+
it("parses a 40-hex hash", () => {
|
|
155
|
+
expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Step: a#1\nGtd-Kept: ${hash}`).kept).toBe(
|
|
156
|
+
hash,
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("parses a 64-hex (sha256) hash", () => {
|
|
161
|
+
const sha256 = hash.repeat(2).slice(0, 64)
|
|
162
|
+
expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${sha256}`).kept).toBe(sha256)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("skips a non-hash value", () => {
|
|
166
|
+
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Kept: not-a-hash").kept).toBeUndefined()
|
|
167
|
+
expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${hash}0`).kept).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("reads undefined when absent, and leaves Gtd-Format at 1", () => {
|
|
171
|
+
const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
|
|
172
|
+
expect(parsed.kept).toBeUndefined()
|
|
173
|
+
expect(parsed.format).toBe(1)
|
|
174
|
+
})
|
|
175
|
+
})
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -75,6 +75,8 @@ export interface CommitMessage {
|
|
|
75
75
|
/** Written only on a process's finishing landing. */
|
|
76
76
|
readonly branch: string | undefined
|
|
77
77
|
readonly version: string | undefined
|
|
78
|
+
/** The off-branch tip a keep step set aside; a non-hash value is skipped. */
|
|
79
|
+
readonly kept: string | undefined
|
|
78
80
|
readonly format: number
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -114,6 +116,7 @@ const decodePath = (raw: string): string | undefined => {
|
|
|
114
116
|
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
|
|
115
117
|
}
|
|
116
118
|
const FORMAT_RE = /^[1-9][0-9]*$/
|
|
119
|
+
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
117
120
|
|
|
118
121
|
interface Trailers {
|
|
119
122
|
step: StepId | undefined
|
|
@@ -121,6 +124,7 @@ interface Trailers {
|
|
|
121
124
|
reviewBase: string | undefined
|
|
122
125
|
branch: string | undefined
|
|
123
126
|
version: string | undefined
|
|
127
|
+
kept: string | undefined
|
|
124
128
|
readonly judge: JudgeVerdict[]
|
|
125
129
|
readonly vars: Record<string, string>
|
|
126
130
|
readonly config: Record<string, string>
|
|
@@ -157,6 +161,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
157
161
|
"Gtd-Version": (value, into) => {
|
|
158
162
|
if (value !== "") into.version ??= value
|
|
159
163
|
},
|
|
164
|
+
"Gtd-Kept": (value, into) => {
|
|
165
|
+
if (HASH_RE.test(value)) into.kept ??= value
|
|
166
|
+
},
|
|
160
167
|
"Gtd-Format": (value, into) => {
|
|
161
168
|
if (FORMAT_RE.test(value)) into.format ??= Number(value)
|
|
162
169
|
},
|
|
@@ -175,6 +182,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
175
182
|
reviewBase: undefined,
|
|
176
183
|
branch: undefined,
|
|
177
184
|
version: undefined,
|
|
185
|
+
kept: undefined,
|
|
178
186
|
judge: [],
|
|
179
187
|
vars: {},
|
|
180
188
|
config: {},
|
package/src/replay/index.ts
CHANGED
package/src/step/LandStep.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { StateName } from "../workflow/index.js"
|
|
|
9
9
|
export type GitWrite =
|
|
10
10
|
| { readonly kind: "commitAll"; readonly message: string }
|
|
11
11
|
| { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
|
|
12
|
+
| { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
|
|
12
13
|
|
|
13
14
|
/** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
|
|
14
15
|
export type Outcome =
|
package/src/step/RepoSnapshot.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type Landing =
|
|
|
17
17
|
readonly spec: CommitSpec
|
|
18
18
|
/** The full history ref a finishing landing points at its own commit. */
|
|
19
19
|
readonly keepHistory?: string
|
|
20
|
+
/** The kept ref this landing's turn goes to; the branch gets a marker commit instead. */
|
|
21
|
+
readonly keepTurn?: string
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -27,7 +27,7 @@ export const base = (workflow, vars) => (workflow === "reviewcheck" ? (vars.base
|
|
|
27
27
|
const repoAt = (): InMemRepo => {
|
|
28
28
|
const repo = new InMemRepo()
|
|
29
29
|
repo.writeFile("gtd.config.ts", WORKFLOW)
|
|
30
|
-
repo.
|
|
30
|
+
repo.seedCommit("chore: add custom workflow")
|
|
31
31
|
return repo
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -104,7 +104,7 @@ describe("planStart", () => {
|
|
|
104
104
|
const fork = repo.resolveRef("HEAD")!
|
|
105
105
|
repo.updateRef("main", fork)
|
|
106
106
|
repo.writeFile("a.txt", "a")
|
|
107
|
-
repo.
|
|
107
|
+
repo.seedCommit("feat: ahead of main")
|
|
108
108
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
|
|
109
109
|
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
110
110
|
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
@@ -117,11 +117,11 @@ describe("planStart", () => {
|
|
|
117
117
|
const repo = repoAt()
|
|
118
118
|
const fork = repo.resolveRef("HEAD")!
|
|
119
119
|
repo.writeFile("other.txt", "o")
|
|
120
|
-
repo.
|
|
120
|
+
repo.seedCommit("feat: other branch")
|
|
121
121
|
repo.updateRef("other", repo.resolveRef("HEAD")!)
|
|
122
122
|
repo.hardResetTo(fork)
|
|
123
123
|
repo.writeFile("mine.txt", "m")
|
|
124
|
-
repo.
|
|
124
|
+
repo.seedCommit("feat: my branch")
|
|
125
125
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
|
|
126
126
|
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
127
127
|
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
@@ -143,7 +143,7 @@ describe("planStart", () => {
|
|
|
143
143
|
repo.updateRef("old-root", repo.resolveRef("HEAD")!)
|
|
144
144
|
repo.mixedResetTo("4b825dc642cb6eb9a060e54bf8d69288fbee4904")
|
|
145
145
|
repo.writeFile("gtd.config.ts", WORKFLOW)
|
|
146
|
-
repo.
|
|
146
|
+
repo.seedCommit("chore: a second root")
|
|
147
147
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "old-root" } })
|
|
148
148
|
expect(plan).toEqual({
|
|
149
149
|
kind: "refusal",
|
|
@@ -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
|
+
})
|
package/src/step/planStep.ts
CHANGED
|
@@ -82,7 +82,13 @@ export const planStep = (
|
|
|
82
82
|
subject,
|
|
83
83
|
steps: [
|
|
84
84
|
...uncheckStep(snapshot),
|
|
85
|
-
{
|
|
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
|
{
|
|
@@ -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
|
}
|