@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -1
- package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
- package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
- package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +27 -2
- package/dist/gtd.bundle.mjs +218 -32
- package/dist/testkit.mjs +358 -37
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +140 -64
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +346 -107
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +49 -1
- package/src/cli/Cli.ts +25 -8
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +205 -1
- package/src/edge/Edge.ts +105 -12
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flags/index.test.ts +14 -1
- package/src/flags/index.ts +14 -3
- package/src/flows/helpers.ts +34 -2
- package/src/flows/runtime.ts +7 -0
- package/src/install/Install.ts +2 -1
- package/src/platform/Git.test.ts +25 -1
- package/src/platform/Git.ts +30 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Format.test.ts +16 -1
- package/src/replay/Replay.test.ts +91 -0
- package/src/replay/Replay.ts +22 -2
- package/src/replay/Trailers.test.ts +110 -1
- package/src/replay/Trailers.ts +37 -1
- package/src/replay/index.ts +2 -0
- package/src/replay/judgeDiffShape.test.ts +43 -0
- package/src/replay/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/planStart.ts +3 -2
- package/src/step/planStep.test.ts +32 -0
- package/src/step/planStep.ts +3 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
- package/src/testkit/EmittedScriptRecognizer.ts +12 -0
- package/src/testkit/FakeGitOperations.ts +5 -0
- package/src/testkit/GitTiers.ts +158 -0
- package/src/testkit/InMemRepo.ts +38 -3
- package/src/testkit/TestRepo.ts +3 -0
- package/src/testkit/steps.ts +18 -0
- package/src/wire/BeatDocument.test.ts +25 -0
- package/src/wire/BeatDocument.ts +4 -2
- package/src/wire/BeatPlain.test.ts +1 -0
- package/src/wire/BeatStatus.test.ts +2 -0
- package/src/wire/BeatStatus.ts +13 -2
- package/src/wire/WireSchema.test.ts +10 -0
- package/src/wire/types.ts +8 -0
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/index.ts +8 -1
- package/src/workflow/vars.test.ts +23 -1
- package/src/workflow/vars.ts +24 -0
- package/src/workflows/feature/feature.ts +9 -2
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/seam-pin.feature +52 -0
- package/src/workflows/feature/seam.feature +387 -0
- package/src/workflows/feature/seam.test.ts +88 -0
- package/src/workflows/feature/seam.ts +110 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/options.test.ts +12 -1
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/skills.test.ts +20 -2
- package/src/workflows/shared/skills.ts +6 -0
- package/src/workflows/shared/text.test.ts +59 -0
- package/src/workflows/shared/text.ts +86 -0
- package/src/workflows/shared/vars.ts +4 -0
- 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
|
+
})
|
package/src/step/LandStep.ts
CHANGED
|
@@ -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
|
|
package/src/step/planStart.ts
CHANGED
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
ConfigDiscovery,
|
|
5
5
|
ConfigService,
|
|
6
6
|
multilineSetting,
|
|
7
|
-
|
|
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 =
|
|
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
|
|
package/src/step/planStep.ts
CHANGED
|
@@ -87,7 +87,9 @@ export const planStep = (
|
|
|
87
87
|
write:
|
|
88
88
|
landing.keepTurn !== undefined
|
|
89
89
|
? { kind: "keepTurn", ref: landing.keepTurn, message }
|
|
90
|
-
:
|
|
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 => {
|
package/src/testkit/GitTiers.ts
CHANGED
|
@@ -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")
|
package/src/testkit/InMemRepo.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
//
|
|
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 = {
|
|
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, {
|
|
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())
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -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
|
}
|
package/src/testkit/steps.ts
CHANGED
|
@@ -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
|
})
|