@pmelab/gtd 58.1.0 → 58.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "58.1.0",
3
+ "version": "58.2.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
@@ -8662,7 +8662,9 @@ var InMemRepo = class InMemRepo {
8662
8662
  }
8663
8663
  const refHash = this.refs.get(ref);
8664
8664
  if (refHash !== void 0) return refHash;
8665
- return this.branches.get(ref) ?? null;
8665
+ const short = this.refs.get(`refs/tags/${ref}`) ?? this.refs.get(`refs/remotes/${ref}`);
8666
+ if (short !== void 0) return short;
8667
+ return this.branches.get(ref.replace(/^refs\/heads\//, "")) ?? null;
8666
8668
  }
8667
8669
  lastCommitSubject(ref = "HEAD") {
8668
8670
  const hash = this.resolveRef(ref);
@@ -8678,6 +8680,24 @@ var InMemRepo = class InMemRepo {
8678
8680
  branchName() {
8679
8681
  return this.currentBranch;
8680
8682
  }
8683
+ /** `git branch <name> <ref>`. */
8684
+ createBranch(name, ref) {
8685
+ if (this.branches.has(name)) throw new Error(`a branch named '${name}' already exists`);
8686
+ const hash = this.resolveRef(ref);
8687
+ if (hash === null) throw new Error(`not a valid object name: '${ref}'`);
8688
+ this.branches.set(name, hash);
8689
+ }
8690
+ /** `git checkout <name>`, refusing any uncommitted change rather than carrying it over. */
8691
+ checkoutBranch(name) {
8692
+ const hash = this.branches.get(name);
8693
+ if (hash === void 0) throw new Error(`pathspec '${name}' did not match any branch`);
8694
+ if (this.statusPorcelain().trim() !== "") throw new Error(`cannot check out '${name}': the working tree has uncommitted changes`);
8695
+ const tree = this.getCommit(hash)?.files ?? /* @__PURE__ */ new Map();
8696
+ this.head = hash;
8697
+ this.currentBranch = name;
8698
+ this.worktree = new Map(tree);
8699
+ this.index = new Map(tree);
8700
+ }
8681
8701
  /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
8682
8702
  listRefs(prefix) {
8683
8703
  return [...this.refs.entries()].filter(([ref]) => ref.startsWith(prefix)).map(([ref, hash]) => ({
@@ -9528,8 +9548,11 @@ const parseCheck = (script) => {
9528
9548
  };
9529
9549
  const runCommand = (repo, commands, command) => {
9530
9550
  const scripted = commands.get(command);
9531
- if (scripted === void 0) throw new Error(`testkit: cannot run \`${command}\` in memory — script it ("the shell command … exits …")`);
9532
- if (scripted.kind === "exit") return scripted;
9551
+ if (scripted === void 0) throw new Error(`testkit: no canned command matches \`${command}\` — add one to \`commands\``);
9552
+ if (scripted.kind === "exit") {
9553
+ scripted.effect?.();
9554
+ return scripted;
9555
+ }
9533
9556
  if (scripted.kind === "missing") return {
9534
9557
  status: 127,
9535
9558
  output: `sh: ${command}: not found\n`
@@ -10025,6 +10048,7 @@ const recognizeScriptedCommand = (repo, commands, block) => {
10025
10048
  error: `sh: ${block.split(" ")[0]}: not found`,
10026
10049
  status: 127
10027
10050
  };
10051
+ scripted.effect?.();
10028
10052
  if (scripted.status !== 0) return {
10029
10053
  kind: "failed",
10030
10054
  error: scripted.output,
@@ -10231,15 +10255,15 @@ const LINES = [
10231
10255
  }]
10232
10256
  ];
10233
10257
  const knownLine = (line) => LINES.find(([pattern]) => pattern.test(line));
10234
- const cannotRun = (error) => /* @__PURE__ */ new Error(`testkit: cannot run script in memory — give the step an actor instead\n${error}`);
10235
- const runLines = (repo, script) => {
10258
+ const cannotRun = (error) => /* @__PURE__ */ new Error(`testkit: cannot run script in memory — add a canned command for it, or give the step an actor\n${error}`);
10259
+ const runLines = (repo, commands, script) => {
10236
10260
  const lines = script.split("\n").map((line) => line.trim());
10237
10261
  const known = lines.map(knownLine);
10238
10262
  if (known.every(Boolean)) {
10239
10263
  known.forEach((k, i) => k[1](repo, words(lines[i])));
10240
10264
  return;
10241
10265
  }
10242
- const applied = applyEmittedScript(repo, /* @__PURE__ */ new Map(), script, { git: false });
10266
+ const applied = applyEmittedScript(repo, commands, script, { git: false });
10243
10267
  if (!applied.ok) throw cannotRun(applied.error ?? "");
10244
10268
  };
10245
10269
  const failed = (what, result) => /* @__PURE__ */ new Error(`${what} exited ${result.exitCode}\n${result.stderr}${result.stdout}`);
@@ -10251,6 +10275,18 @@ const testRepo = (options) => {
10251
10275
  repo.writeFile("gtd.config.ts", typeof workflow === "string" ? workflow : moduleSource(id, workflow));
10252
10276
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content);
10253
10277
  repo.seedCommit("chore: initial commit");
10278
+ const canned = [...options.commands ?? []];
10279
+ const commands = { get: (command) => {
10280
+ const hit = canned.find(({ run }) => typeof run === "string" ? run === command : run.test(command));
10281
+ if (hit === void 0) return void 0;
10282
+ const { exit = 0, output = "", effect } = hit;
10283
+ return {
10284
+ kind: "exit",
10285
+ status: exit,
10286
+ output,
10287
+ effect: () => effect?.(self)
10288
+ };
10289
+ } };
10254
10290
  const gtd = async (...args) => {
10255
10291
  const { io, result } = makeCapturingCliIo(repo, env);
10256
10292
  if (typeof workflow !== "string") registry().set(id, workflow);
@@ -10281,7 +10317,7 @@ const testRepo = (options) => {
10281
10317
  };
10282
10318
  const apply = (what, script) => {
10283
10319
  if (script.length === 0) return;
10284
- const applied = applyEmittedScript(repo, /* @__PURE__ */ new Map(), script);
10320
+ const applied = applyEmittedScript(repo, commands, script);
10285
10321
  if (!applied.ok) throw new Error(`${what}: its script failed — ${applied.error ?? ""}`);
10286
10322
  };
10287
10323
  /** Runs a write command, then the script its `--json=script` twin prints. */
@@ -10315,14 +10351,26 @@ const testRepo = (options) => {
10315
10351
  await runWrite(["judge", "answer"], verdict);
10316
10352
  };
10317
10353
  const self = {
10354
+ command: (command) => {
10355
+ canned.push(command);
10356
+ },
10357
+ branch: (name, ref = "HEAD") => repo.createBranch(name, ref),
10358
+ checkout: (name) => repo.checkoutBranch(name),
10359
+ ref: (name, ref = "HEAD") => {
10360
+ if (!name.startsWith("refs/")) throw new Error(`testkit: ref "${name}" must start with refs/`);
10361
+ const hash = repo.resolveRef(ref);
10362
+ if (hash === null) throw new Error(`testkit: ref "${ref}" resolves to no commit`);
10363
+ repo.updateRef(name, hash);
10364
+ },
10365
+ branchName: () => repo.branchName(),
10318
10366
  read: (path) => repo.readFile(path),
10319
10367
  write: (path, content) => repo.writeFile(path, content),
10320
10368
  remove: (path) => repo.deleteFile(path),
10321
10369
  commit: (message) => repo.seedCommit(message),
10322
10370
  show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
10323
10371
  runScript: (script) => {
10324
- const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
10325
- if (flow === void 0) runLines(repo, script);
10372
+ const flow = applyFlowScript(repo, commands, script);
10373
+ if (flow === void 0) runLines(repo, commands, script);
10326
10374
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`);
10327
10375
  },
10328
10376
  gtd,
@@ -1,4 +1,4 @@
1
- import { n as testRepo, t as runsItsScript } from "../chunks/testkit-D1-auYau.mjs";
1
+ import { n as testRepo, t as runsItsScript } from "../chunks/testkit-JDtpNKyi.mjs";
2
2
  import { resolve } from "node:path";
3
3
  import assert from "node:assert";
4
4
  import { pathToFileURL } from "node:url";
@@ -10,6 +10,7 @@ var TestkitWorld = class extends QuickPickleWorld {
10
10
  }
11
11
  gtdConfig = void 0;
12
12
  env = {};
13
+ commands = [];
13
14
  /** Per step, what its scripted actor does, in the order the scenario declared it. */
14
15
  actions = /* @__PURE__ */ new Map();
15
16
  verdicts = /* @__PURE__ */ new Map();
@@ -26,6 +27,10 @@ var TestkitWorld = class extends QuickPickleWorld {
26
27
  if (typeof this.gtdConfig !== "string") throw new Error("a gtd config from a module cannot be extended");
27
28
  this.gtdConfig += `\n${tail}`;
28
29
  }
30
+ command(command) {
31
+ if (this.created === void 0) this.commands.push(command);
32
+ else this.created.command(command);
33
+ }
29
34
  setEnv(name, value) {
30
35
  this.assertNotCreated(`the environment variable ${name}`);
31
36
  this.env[name] = value;
@@ -37,7 +42,8 @@ var TestkitWorld = class extends QuickPickleWorld {
37
42
  if (this.gtdConfig === void 0) throw new Error("no gtd config: add a `gtd config` step first");
38
43
  return this.created ??= testRepo({
39
44
  workflow: this.gtdConfig,
40
- env: this.env
45
+ env: this.env,
46
+ commands: this.commands
41
47
  });
42
48
  }
43
49
  act(step, action) {
@@ -79,6 +85,28 @@ Given("the gtd config ends with:", (world, source) => {
79
85
  Given("an environment variable {string} set to {string}", (world, name, value) => {
80
86
  world.setEnv(name, value);
81
87
  });
88
+ Given("the command {string} exits {int}", (world, run, exit) => {
89
+ world.command({
90
+ run,
91
+ exit
92
+ });
93
+ });
94
+ Given("the command {string} exits {int} with:", (world, run, exit, output) => {
95
+ world.command({
96
+ run,
97
+ exit,
98
+ output: withNewline(output)
99
+ });
100
+ });
101
+ Given("a branch {string}", (world, name) => {
102
+ world.repo().branch(name);
103
+ });
104
+ Given("{string} is checked out", (world, name) => {
105
+ world.repo().checkout(name);
106
+ });
107
+ Given("the ref {string} points at HEAD", (world, name) => {
108
+ world.repo().ref(name);
109
+ });
82
110
  Given("a file {string} with:", (world, path, content) => {
83
111
  world.repo().write(path, withNewline(content));
84
112
  });
package/dist/testkit.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as testRepo, r as InMemRepo, t as runsItsScript } from "./chunks/testkit-D1-auYau.mjs";
1
+ import { n as testRepo, r as InMemRepo, t as runsItsScript } from "./chunks/testkit-JDtpNKyi.mjs";
2
2
  export { InMemRepo, runsItsScript, testRepo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "58.1.0",
3
+ "version": "58.2.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -0,0 +1,78 @@
1
+ @inmem
2
+ Feature: a scenario controls the commands and the history a workflow sees
3
+
4
+ A step's script may run programs the in-memory repository has none of: a
5
+ canned command stands in for each, matched by the command as the script
6
+ spells it. Branches and refs are set up before the process starts, so a
7
+ workflow that reads another branch or a remote ref finds them.
8
+
9
+ Background:
10
+ Given a gtd config with:
11
+ """
12
+ import { check, human, run, showScript } from "@pmelab/gtd/flows"
13
+
14
+ export const tested = async () => {
15
+ const green = await check("test", "npm test", { report: ".gtd/FEEDBACK.md" })
16
+ await human(green ? "sign-off" : "red")
17
+ }
18
+
19
+ export const restored = async () => {
20
+ await run("restore", showScript("origin/main", "a.txt", "a.txt"))
21
+ await human("done")
22
+ }
23
+
24
+ export default tested
25
+ """
26
+ And a file "a.txt" with:
27
+ """
28
+ base
29
+ """
30
+ And the working tree is committed as "chore: base"
31
+
32
+ Scenario: a red canned command writes its output to the check's report
33
+ Given the command "npm test" exits 1 with:
34
+ """
35
+ 1 failing
36
+ """
37
+ And step "test" runs its script
38
+ When gtd starts workflow "tested"
39
+ And the process is driven
40
+ Then the process rests at "red"
41
+ And ".gtd/FEEDBACK.md" contains "1 failing"
42
+
43
+ Scenario: a green canned command passes the check
44
+ Given the command "npm test" exits 0
45
+ And step "test" runs its script
46
+ When gtd starts workflow "tested"
47
+ And the process is driven
48
+ Then the process rests at "sign-off"
49
+
50
+ Scenario: a command no canned command matches fails the drive, naming it
51
+ Given step "test" runs its script
52
+ When gtd starts workflow "tested"
53
+ Then driving the process is refused with "no canned command matches `npm test`"
54
+
55
+ Scenario: a remote ref is read by its short name
56
+ Given the ref "refs/remotes/origin/main" points at HEAD
57
+ And a file "a.txt" with:
58
+ """
59
+ local
60
+ """
61
+ And the working tree is committed as "chore: local"
62
+ And step "restore" runs its script
63
+ When gtd starts workflow "restored"
64
+ And the process is driven
65
+ Then the process rests at "done"
66
+ And "a.txt" contains "base"
67
+
68
+ Scenario: a branch is checked out and back
69
+ Given a branch "feature/x"
70
+ And "feature/x" is checked out
71
+ And a file "a.txt" with:
72
+ """
73
+ feature
74
+ """
75
+ And the working tree is committed as "feat: x"
76
+ And "main" is checked out
77
+ Then "a.txt" contains "base"
78
+ And "a.txt" does not contain "feature"
@@ -17,6 +17,7 @@ import {
17
17
  runsItsScript,
18
18
  testRepo,
19
19
  type Actor,
20
+ type CannedCommand,
20
21
  type TestRepo,
21
22
  type WorkflowModule,
22
23
  } from "../testkit/index.js"
@@ -31,6 +32,7 @@ export class TestkitWorld extends QuickPickleWorld {
31
32
 
32
33
  private gtdConfig: WorkflowModule | undefined = undefined
33
34
  private env: Record<string, string> = {}
35
+ private commands: CannedCommand[] = []
34
36
  /** Per step, what its scripted actor does, in the order the scenario declared it. */
35
37
  actions = new Map<string, Actor[]>()
36
38
  verdicts = new Map<string, string>()
@@ -54,6 +56,11 @@ export class TestkitWorld extends QuickPickleWorld {
54
56
  this.gtdConfig += `\n${tail}`
55
57
  }
56
58
 
59
+ command(command: CannedCommand): void {
60
+ if (this.created === undefined) this.commands.push(command)
61
+ else this.created.command(command)
62
+ }
63
+
57
64
  setEnv(name: string, value: string): void {
58
65
  this.assertNotCreated(`the environment variable ${name}`)
59
66
  this.env[name] = value
@@ -68,7 +75,11 @@ export class TestkitWorld extends QuickPickleWorld {
68
75
  repo(): TestRepo {
69
76
  if (this.gtdConfig === undefined)
70
77
  throw new Error("no gtd config: add a `gtd config` step first")
71
- return (this.created ??= testRepo({ workflow: this.gtdConfig, env: this.env }))
78
+ return (this.created ??= testRepo({
79
+ workflow: this.gtdConfig,
80
+ env: this.env,
81
+ commands: this.commands,
82
+ }))
72
83
  }
73
84
 
74
85
  act(step: string, action: Actor): void {
@@ -136,6 +147,29 @@ Given(
136
147
  },
137
148
  )
138
149
 
150
+ Given("the command {string} exits {int}", (world: TestkitWorld, run: string, exit: number) => {
151
+ world.command({ run, exit })
152
+ })
153
+
154
+ Given(
155
+ "the command {string} exits {int} with:",
156
+ (world: TestkitWorld, run: string, exit: number, output: string) => {
157
+ world.command({ run, exit, output: withNewline(output) })
158
+ },
159
+ )
160
+
161
+ Given("a branch {string}", (world: TestkitWorld, name: string) => {
162
+ world.repo().branch(name)
163
+ })
164
+
165
+ Given("{string} is checked out", (world: TestkitWorld, name: string) => {
166
+ world.repo().checkout(name)
167
+ })
168
+
169
+ Given("the ref {string} points at HEAD", (world: TestkitWorld, name: string) => {
170
+ world.repo().ref(name)
171
+ })
172
+
139
173
  Given("a file {string} with:", (world: TestkitWorld, path: string, content: string) => {
140
174
  world.repo().write(path, withNewline(content))
141
175
  })
@@ -12,7 +12,16 @@ import {
12
12
  softResetTo,
13
13
  updateRef,
14
14
  } from "../emit/index.js"
15
- import { agent, human, moveScript, read, removeScript, run } from "../flows/index.js"
15
+ import {
16
+ agent,
17
+ check,
18
+ human,
19
+ moveScript,
20
+ read,
21
+ removeScript,
22
+ run,
23
+ showScript,
24
+ } from "../flows/index.js"
16
25
  import { runsItsScript, testRepo } from "./TestRepo.js"
17
26
 
18
27
  const tiny = async (): Promise<void> => {
@@ -112,7 +121,7 @@ describe("testRepo", () => {
112
121
  it("carries the recognizer's error when a git-free fallback fails", () => {
113
122
  const repo = testRepo({ workflow: { default: tiny } })
114
123
  expect(() => repo.runScript("printf 'x' > 'y'\n")).toThrow(
115
- "testkit: cannot run script in memory — give the step an actor instead\nunrecognized script block: printf 'x' > 'y'",
124
+ "testkit: cannot run script in memory — add a canned command for it, or give the step an actor\nunrecognized script block: printf 'x' > 'y'",
116
125
  )
117
126
  })
118
127
 
@@ -194,4 +203,111 @@ describe("testRepo", () => {
194
203
  repo.drive({ spin: (_, r) => r.write("n", `${++n}\n`) }, { maxBeats: 5 }),
195
204
  ).rejects.toThrow(/did not rest within 5 beats/)
196
205
  })
206
+
207
+ describe("canned commands", () => {
208
+ const checked = async (): Promise<void> => {
209
+ const green = await check("test", "npm test", { report: ".gtd/FEEDBACK.md" })
210
+ await human(green ? "sign-off" : "red")
211
+ }
212
+
213
+ it("runs a check's command as canned, its output the report", async () => {
214
+ const repo = testRepo({
215
+ workflow: { default: checked },
216
+ commands: [{ run: "npm test", exit: 1, output: "1 failing" }],
217
+ })
218
+ repo.write("go", "\n")
219
+ const rest = await repo.drive({ test: runsItsScript })
220
+ expect(rest.state).toBe("red")
221
+ expect(repo.read(".gtd/FEEDBACK.md")).toContain("1 failing")
222
+ })
223
+
224
+ it("exits 0 by default, and matches by pattern", async () => {
225
+ const repo = testRepo({ workflow: { default: checked }, commands: [{ run: /^npm / }] })
226
+ repo.write("go", "\n")
227
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("sign-off")
228
+ })
229
+
230
+ it("lets a command change the tree, and takes the first match", async () => {
231
+ const repo = testRepo({
232
+ workflow: { default: checked },
233
+ commands: [
234
+ { run: "npm test", effect: (r) => r.write("ran", "first\n") },
235
+ { run: /npm/, exit: 1 },
236
+ ],
237
+ })
238
+ repo.write("go", "\n")
239
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("sign-off")
240
+ expect(repo.read("ran")).toBe("first\n")
241
+ })
242
+
243
+ it("takes a command added later", async () => {
244
+ const repo = testRepo({ workflow: { default: checked } })
245
+ repo.command({ run: "npm test", exit: 2 })
246
+ repo.write("go", "\n")
247
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("red")
248
+ })
249
+
250
+ it("runs a plain script line as canned", () => {
251
+ const repo = testRepo({
252
+ workflow: { default: tiny },
253
+ commands: [{ run: "make docs", effect: (r) => r.write("docs.html", "<p>\n") }],
254
+ })
255
+ repo.runScript("make docs\n")
256
+ expect(repo.read("docs.html")).toBe("<p>\n")
257
+ })
258
+
259
+ it("fails the drive on a command none matches, naming it", async () => {
260
+ const repo = testRepo({ workflow: { default: checked } })
261
+ repo.write("go", "\n")
262
+ await expect(repo.drive({ test: runsItsScript })).rejects.toThrow(
263
+ "no canned command matches `npm test`",
264
+ )
265
+ })
266
+ })
267
+
268
+ describe("history", () => {
269
+ it("creates and checks out a branch, refusing uncommitted changes", () => {
270
+ const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "main\n" } })
271
+ repo.branch("feature/x")
272
+ repo.checkout("feature/x")
273
+ expect(repo.branchName()).toBe("feature/x")
274
+ repo.write("a.txt", "feature\n")
275
+ expect(() => repo.checkout("main")).toThrow("uncommitted changes")
276
+ repo.commit("feat: x")
277
+ repo.checkout("main")
278
+ expect(repo.branchName()).toBe("main")
279
+ expect(repo.read("a.txt")).toBe("main\n")
280
+ expect(repo.show("feature/x", "a.txt")).toBe("feature\n")
281
+ })
282
+
283
+ it("branches at a given ref, and refuses an existing name or a missing ref", () => {
284
+ const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "one\n" } })
285
+ repo.write("a.txt", "two\n")
286
+ repo.commit("chore: two")
287
+ repo.branch("old", "HEAD~1")
288
+ expect(repo.show("old", "a.txt")).toBe("one\n")
289
+ expect(() => repo.branch("old")).toThrow("already exists")
290
+ expect(() => repo.branch("new", "nowhere")).toThrow("not a valid object name")
291
+ })
292
+
293
+ it("points a remote ref that a script reads by its short name", async () => {
294
+ const restore = async (): Promise<void> => {
295
+ await run("restore", showScript("origin/main", "a.txt", "a.txt"))
296
+ await human("done")
297
+ }
298
+ const repo = testRepo({ workflow: { default: restore }, files: { "a.txt": "base\n" } })
299
+ repo.ref("refs/remotes/origin/main")
300
+ repo.write("a.txt", "local\n")
301
+ repo.commit("chore: local")
302
+ repo.write("go", "\n")
303
+ await repo.drive({ restore: runsItsScript })
304
+ expect(repo.read("a.txt")).toBe("base\n")
305
+ })
306
+
307
+ it("refuses a ref outside refs/ or at nothing", () => {
308
+ const repo = testRepo({ workflow: { default: tiny } })
309
+ expect(() => repo.ref("origin/main")).toThrow("must start with refs/")
310
+ expect(() => repo.ref("refs/tags/v1", "nowhere")).toThrow("resolves to no commit")
311
+ })
312
+ })
197
313
  })
@@ -8,6 +8,7 @@ import {
8
8
  InMemRepo,
9
9
  makeCapturingCliIo,
10
10
  type CapturedCliResult,
11
+ type CommandTable,
11
12
  } from "../twins/index.js"
12
13
 
13
14
  /**
@@ -18,8 +19,23 @@ import {
18
19
  */
19
20
  export type WorkflowModule = string | Readonly<Record<string, unknown>>
20
21
 
22
+ /**
23
+ * What a command a step's script runs does in memory. `run` matches the command
24
+ * as the script spells it, exactly or by a pattern; the first match wins.
25
+ */
26
+ export interface CannedCommand {
27
+ readonly run: string | RegExp
28
+ /** Defaults to 0. */
29
+ readonly exit?: number
30
+ readonly output?: string
31
+ /** What the command does to the tree, e.g. a formatter's rewrite. */
32
+ readonly effect?: (repo: TestRepo) => void
33
+ }
34
+
21
35
  export interface TestRepoOptions {
22
36
  readonly workflow: WorkflowModule
37
+ /** The commands a step's script may run. A command none matches fails the script. */
38
+ readonly commands?: readonly CannedCommand[]
23
39
  /** Committed alongside the config, before any process starts. */
24
40
  readonly files?: Readonly<Record<string, string>>
25
41
  /** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
@@ -42,6 +58,15 @@ export interface DriveOptions {
42
58
  }
43
59
 
44
60
  export interface TestRepo {
61
+ /** Adds a command a step's script may run, after those `testRepo` was given. */
62
+ command(command: CannedCommand): void
63
+ /** Creates branch `name` at `ref` (HEAD by default). Outside a process only. */
64
+ branch(name: string, ref?: string): void
65
+ /** Checks branch `name` out; refuses with uncommitted changes. Outside a process only. */
66
+ checkout(name: string): void
67
+ /** Points ref `name` (`refs/remotes/origin/main`, `refs/tags/v1`) at `ref` (HEAD by default). Outside a process only. */
68
+ ref(name: string, ref?: string): void
69
+ branchName(): string
45
70
  read(path: string): string | undefined
46
71
  write(path: string, content: string): void
47
72
  remove(path: string): void
@@ -158,19 +183,21 @@ const LINES: ReadonlyArray<readonly [RegExp, (repo: InMemRepo, words: string[])
158
183
  const knownLine = (line: string) => LINES.find(([pattern]) => pattern.test(line))
159
184
 
160
185
  const cannotRun = (error: string): Error =>
161
- new Error(`testkit: cannot run script in memory — give the step an actor instead\n${error}`)
186
+ new Error(
187
+ `testkit: cannot run script in memory — add a canned command for it, or give the step an actor\n${error}`,
188
+ )
162
189
 
163
190
  // A script with a line `LINES` lacks goes to the contract-tested recognizers
164
191
  // whole, minus the git builders: they would apply a commit/reset/update-ref in
165
192
  // memory, so any git-mutating block is refused whatever its spelling.
166
- const runLines = (repo: InMemRepo, script: string): void => {
193
+ const runLines = (repo: InMemRepo, commands: CommandTable, script: string): void => {
167
194
  const lines = script.split("\n").map((line) => line.trim())
168
195
  const known = lines.map(knownLine)
169
196
  if (known.every(Boolean)) {
170
197
  known.forEach((k, i) => k![1](repo, words(lines[i]!)))
171
198
  return
172
199
  }
173
- const applied = applyEmittedScript(repo, new Map(), script, { git: false })
200
+ const applied = applyEmittedScript(repo, commands, script, { git: false })
174
201
  if (!applied.ok) throw cannotRun(applied.error ?? "")
175
202
  }
176
203
 
@@ -189,6 +216,18 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
189
216
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
190
217
  repo.seedCommit("chore: initial commit")
191
218
 
219
+ const canned = [...(options.commands ?? [])]
220
+ const commands: CommandTable = {
221
+ get: (command) => {
222
+ const hit = canned.find(({ run }) =>
223
+ typeof run === "string" ? run === command : run.test(command),
224
+ )
225
+ if (hit === undefined) return undefined
226
+ const { exit = 0, output = "", effect } = hit
227
+ return { kind: "exit", status: exit, output, effect: () => effect?.(self) }
228
+ },
229
+ }
230
+
192
231
  // The module is registered only while gtd runs, so nothing outlives the
193
232
  // call that loads it and no test can reach another's module.
194
233
  const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
@@ -217,7 +256,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
217
256
 
218
257
  const apply = (what: string, script: string): void => {
219
258
  if (script.length === 0) return
220
- const applied = applyEmittedScript(repo, new Map(), script)
259
+ const applied = applyEmittedScript(repo, commands, script)
221
260
  if (!applied.ok) throw new Error(`${what}: its script failed — ${applied.error ?? ""}`)
222
261
  }
223
262
 
@@ -260,14 +299,26 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
260
299
  }
261
300
 
262
301
  const self: TestRepo = {
302
+ command: (command) => {
303
+ canned.push(command)
304
+ },
305
+ branch: (name, ref = "HEAD") => repo.createBranch(name, ref),
306
+ checkout: (name) => repo.checkoutBranch(name),
307
+ ref: (name, ref = "HEAD") => {
308
+ if (!name.startsWith("refs/")) throw new Error(`testkit: ref "${name}" must start with refs/`)
309
+ const hash = repo.resolveRef(ref)
310
+ if (hash === null) throw new Error(`testkit: ref "${ref}" resolves to no commit`)
311
+ repo.updateRef(name, hash)
312
+ },
313
+ branchName: () => repo.branchName(),
263
314
  read: (path) => repo.readFile(path),
264
315
  write: (path, content) => repo.writeFile(path, content),
265
316
  remove: (path) => repo.deleteFile(path),
266
317
  commit: (message) => repo.seedCommit(message),
267
318
  show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
268
319
  runScript: (script) => {
269
- const flow = applyFlowScript(repo, new Map(), script)
270
- if (flow === undefined) runLines(repo, script)
320
+ const flow = applyFlowScript(repo, commands, script)
321
+ if (flow === undefined) runLines(repo, commands, script)
271
322
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
272
323
  },
273
324
  gtd,
@@ -6,6 +6,7 @@ export {
6
6
  testRepo,
7
7
  type Actor,
8
8
  type Actors,
9
+ type CannedCommand,
9
10
  type DriveOptions,
10
11
  type TestRepo,
11
12
  type TestRepoOptions,
@@ -36,7 +36,7 @@ import { refNameFault } from "../replay/index.js"
36
36
  import { renderFormat } from "../wire/index.js"
37
37
  import { applyFlowScript, rewritten } from "./FlowScripts.js"
38
38
  import type { InMemRepo } from "./InMemRepo.js"
39
- import type { ScriptedCommand } from "./Layers.js"
39
+ import type { CommandTable } from "./Layers.js"
40
40
 
41
41
  export interface AppliedScriptResult {
42
42
  readonly ok: boolean
@@ -266,10 +266,7 @@ const recognizeFileExistsGuard = (repo: InMemRepo, block: string): BlockOutcome
266
266
  /** `binaryGuard`. The in-memory tier has no `$PATH`: a binary is present unless declared `missing`. */
267
267
  const BINARY_GUARD_MESSAGE_RE = /^gtd: mode "([^"]+)": "(format|validate)" command not found: (.+)$/
268
268
 
269
- const recognizeBinaryGuard = (
270
- commands: ReadonlyMap<string, ScriptedCommand>,
271
- block: string,
272
- ): BlockOutcome | undefined => {
269
+ const recognizeBinaryGuard = (commands: CommandTable, block: string): BlockOutcome | undefined => {
273
270
  const [binary, , message] = extractQuotedTokens(block)
274
271
  if (binary === undefined || message === undefined) return undefined
275
272
  const match = BINARY_GUARD_MESSAGE_RE.exec(message)
@@ -330,7 +327,7 @@ const expandFileVar = (text: string): string | undefined => {
330
327
  */
331
328
  const recognizeModeCommand = (
332
329
  repo: InMemRepo,
333
- commands: ReadonlyMap<string, ScriptedCommand>,
330
+ commands: CommandTable,
334
331
  block: string,
335
332
  ): BlockOutcome | undefined => {
336
333
  const command = expandFileVar(block) ?? block
@@ -356,7 +353,7 @@ const FAILURE_PROMPT_FOOTER = ' "$gtd_validate_out"\n exit "$gtd_validate_statu
356
353
 
357
354
  const recognizeFailurePromptWrapper = (
358
355
  repo: InMemRepo,
359
- commands: ReadonlyMap<string, ScriptedCommand>,
356
+ commands: CommandTable,
360
357
  block: string,
361
358
  ): BlockOutcome | undefined => {
362
359
  if (!block.startsWith(FAILURE_PROMPT_HEADER) || !block.endsWith(FAILURE_PROMPT_FOOTER)) {
@@ -465,7 +462,7 @@ const parseModeContradictionCheck = (block: string): ParsedModeContradictionChec
465
462
  */
466
463
  const simulateModeContradictionCheck = (
467
464
  repo: InMemRepo,
468
- commands: ReadonlyMap<string, ScriptedCommand>,
465
+ commands: CommandTable,
469
466
  parsed: ParsedModeContradictionCheck,
470
467
  ): BlockOutcome => {
471
468
  const { mode, samplePath, sample, formatCommand, format } = parsed
@@ -502,7 +499,7 @@ const simulateModeContradictionCheck = (
502
499
  */
503
500
  const recognizeModeContradictionCheck = (
504
501
  repo: InMemRepo,
505
- commands: ReadonlyMap<string, ScriptedCommand>,
502
+ commands: CommandTable,
506
503
  block: string,
507
504
  ): BlockOutcome | undefined => {
508
505
  const parsed = parseModeContradictionCheck(block)
@@ -570,7 +567,7 @@ const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`
570
567
 
571
568
  const recognizePresentationSubshell = (
572
569
  repo: InMemRepo,
573
- commands: ReadonlyMap<string, ScriptedCommand>,
570
+ commands: CommandTable,
574
571
  block: string,
575
572
  git: boolean,
576
573
  ): BlockOutcome | undefined => {
@@ -595,7 +592,7 @@ const recognizePresentationSubshell = (
595
592
  /** Anything left over must be an EXACT hit in the scripted-command table — mirrors `ScriptedCommand`'s two `kind`s (`Layers.ts`). */
596
593
  const recognizeScriptedCommand = (
597
594
  repo: InMemRepo,
598
- commands: ReadonlyMap<string, ScriptedCommand>,
595
+ commands: CommandTable,
599
596
  block: string,
600
597
  ): BlockOutcome | undefined => {
601
598
  const scripted = commands.get(block)
@@ -607,6 +604,7 @@ const recognizeScriptedCommand = (
607
604
  if (scripted.kind === "missing") {
608
605
  return { kind: "failed", error: `sh: ${block.split(" ")[0]}: not found`, status: 127 }
609
606
  }
607
+ scripted.effect?.()
610
608
  if (scripted.status !== 0) {
611
609
  return { kind: "failed", error: scripted.output, status: scripted.status }
612
610
  }
@@ -693,7 +691,7 @@ const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | und
693
691
  /** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
694
692
  const recognizersFor = (
695
693
  repo: InMemRepo,
696
- commands: ReadonlyMap<string, ScriptedCommand>,
694
+ commands: CommandTable,
697
695
  git: boolean,
698
696
  ): ReadonlyArray<(block: string) => BlockOutcome | undefined> => [
699
697
  (block) => recognizeSetFlags(block),
@@ -735,7 +733,7 @@ const recognizeBlock = (
735
733
  */
736
734
  export const applyEmittedScript = (
737
735
  repo: InMemRepo,
738
- commands: ReadonlyMap<string, ScriptedCommand>,
736
+ commands: CommandTable,
739
737
  script: string,
740
738
  { git = true }: { readonly git?: boolean } = {},
741
739
  ): AppliedScriptResult => {
@@ -750,7 +748,7 @@ export const applyEmittedScript = (
750
748
 
751
749
  const applyBlocks = (
752
750
  repo: InMemRepo,
753
- commands: ReadonlyMap<string, ScriptedCommand>,
751
+ commands: CommandTable,
754
752
  script: string,
755
753
  git: boolean,
756
754
  ): AppliedScriptResult => {
@@ -15,7 +15,7 @@ import {
15
15
  writeScript,
16
16
  } from "../flows/index.js"
17
17
  import type { InMemRepo } from "./InMemRepo.js"
18
- import type { ScriptedCommand } from "./Layers.js"
18
+ import type { CommandTable, ScriptedCommand } from "./Layers.js"
19
19
 
20
20
  export interface FlowScriptResult {
21
21
  readonly ok: boolean
@@ -299,16 +299,17 @@ const parseCheck = (script: string): ParsedCheck | undefined => {
299
299
 
300
300
  const runCommand = (
301
301
  repo: InMemRepo,
302
- commands: ReadonlyMap<string, ScriptedCommand>,
302
+ commands: CommandTable,
303
303
  command: string,
304
304
  ): { readonly status: number; readonly output: string } => {
305
305
  const scripted = commands.get(command)
306
306
  if (scripted === undefined) {
307
- throw new Error(
308
- `testkit: cannot run \`${command}\` in memory — script it ("the shell command … exits …")`,
309
- )
307
+ throw new Error(`testkit: no canned command matches \`${command}\` — add one to \`commands\``)
308
+ }
309
+ if (scripted.kind === "exit") {
310
+ scripted.effect?.()
311
+ return scripted
310
312
  }
311
- if (scripted.kind === "exit") return scripted
312
313
  if (scripted.kind === "missing") return { status: 127, output: `sh: ${command}: not found\n` }
313
314
  repo.writeFile(scripted.file, rewritten(repo, scripted))
314
315
  return { status: 0, output: "" }
@@ -325,7 +326,7 @@ export const rewritten = (
325
326
 
326
327
  const recognizeCheck = (
327
328
  repo: InMemRepo,
328
- commands: ReadonlyMap<string, ScriptedCommand>,
329
+ commands: CommandTable,
329
330
  script: string,
330
331
  ): FlowScriptResult | undefined => {
331
332
  const parsed = parseCheck(script)
@@ -362,7 +363,7 @@ const recognizeCheck = (
362
363
  */
363
364
  export const applyFlowScript = (
364
365
  repo: InMemRepo,
365
- commands: ReadonlyMap<string, ScriptedCommand>,
366
+ commands: CommandTable,
366
367
  script: string,
367
368
  ): FlowScriptResult | undefined => {
368
369
  if (!script.startsWith(`${SHEBANG}\n`)) return undefined
@@ -134,7 +134,10 @@ export class InMemRepo {
134
134
  const refHash = this.refs.get(ref)
135
135
  if (refHash !== undefined) return refHash
136
136
 
137
- return this.branches.get(ref) ?? null
137
+ // git's own short names: `origin/main` for refs/remotes/origin/main, …
138
+ const short = this.refs.get(`refs/tags/${ref}`) ?? this.refs.get(`refs/remotes/${ref}`)
139
+ if (short !== undefined) return short
140
+ return this.branches.get(ref.replace(/^refs\/heads\//, "")) ?? null
138
141
  }
139
142
 
140
143
  lastCommitSubject(ref = "HEAD"): string | null {
@@ -154,6 +157,28 @@ export class InMemRepo {
154
157
  return this.currentBranch
155
158
  }
156
159
 
160
+ /** `git branch <name> <ref>`. */
161
+ createBranch(name: string, ref: string): void {
162
+ if (this.branches.has(name)) throw new Error(`a branch named '${name}' already exists`)
163
+ const hash = this.resolveRef(ref)
164
+ if (hash === null) throw new Error(`not a valid object name: '${ref}'`)
165
+ this.branches.set(name, hash)
166
+ }
167
+
168
+ /** `git checkout <name>`, refusing any uncommitted change rather than carrying it over. */
169
+ checkoutBranch(name: string): void {
170
+ const hash = this.branches.get(name)
171
+ if (hash === undefined) throw new Error(`pathspec '${name}' did not match any branch`)
172
+ if (this.statusPorcelain().trim() !== "") {
173
+ throw new Error(`cannot check out '${name}': the working tree has uncommitted changes`)
174
+ }
175
+ const tree = this.getCommit(hash)?.files ?? new Map<string, string>()
176
+ this.head = hash
177
+ this.currentBranch = name
178
+ this.worktree = new Map(tree)
179
+ this.index = new Map(tree)
180
+ }
181
+
157
182
  /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
158
183
  listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
159
184
  return [...this.refs.entries()]
@@ -169,7 +169,13 @@ const makeInMemoryConfigDiscovery = (
169
169
 
170
170
  /** One scripted `bash` command's canned behavior, keyed by the RENDERED command string a scenario's `Given` step declares. */
171
171
  export type ScriptedCommand =
172
- | { readonly kind: "exit"; readonly status: number; readonly output: string }
172
+ | {
173
+ readonly kind: "exit"
174
+ readonly status: number
175
+ readonly output: string
176
+ /** What the command does to the tree before it exits. */
177
+ readonly effect?: () => void
178
+ }
173
179
  | {
174
180
  readonly kind: "rewrite"
175
181
  readonly file: string
@@ -180,6 +186,11 @@ export type ScriptedCommand =
180
186
  /** Keyed by the binary's name: it is not on `$PATH`. */
181
187
  | { readonly kind: "missing" }
182
188
 
189
+ /** The scripted commands, by the exact command a script runs; a `Map` is one. */
190
+ export interface CommandTable {
191
+ get(command: string): ScriptedCommand | undefined
192
+ }
193
+
183
194
  export interface TestWorldOptions {
184
195
  readonly env?: Readonly<Record<string, string | undefined>>
185
196
  readonly root?: string
@@ -1,5 +1,10 @@
1
1
  export { InMemRepo } from "./InMemRepo.js"
2
- export { makeInMemoryWorkspaceOps, testLayers, type ScriptedCommand } from "./Layers.js"
2
+ export {
3
+ makeInMemoryWorkspaceOps,
4
+ testLayers,
5
+ type CommandTable,
6
+ type ScriptedCommand,
7
+ } from "./Layers.js"
3
8
  export { makeCapturingCliIo, type CapturedCliResult } from "./cliIo.js"
4
9
  export { applyEmittedScript } from "./EmittedScriptRecognizer.js"
5
10
  export { applyFlowScript } from "./FlowScripts.js"