@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/chunks/{testkit-D1-auYau.mjs → testkit-JDtpNKyi.mjs} +57 -9
- package/dist/testkit/gherkin.mjs +30 -2
- package/dist/testkit.mjs +1 -1
- package/package.json +1 -1
- package/src/gherkin/git-control.feature +78 -0
- package/src/gherkin/index.ts +35 -1
- package/src/testkit/TestRepo.test.ts +118 -2
- package/src/testkit/TestRepo.ts +57 -6
- package/src/testkit/index.ts +1 -0
- package/src/twins/EmittedScriptRecognizer.ts +12 -14
- package/src/twins/FlowScripts.ts +9 -8
- package/src/twins/InMemRepo.ts +26 -1
- package/src/twins/Layers.ts +12 -1
- package/src/twins/index.ts +6 -1
|
@@ -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
|
-
|
|
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:
|
|
9532
|
-
if (scripted.kind === "exit")
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
package/dist/testkit/gherkin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as testRepo, t as runsItsScript } from "../chunks/testkit-
|
|
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-
|
|
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
|
@@ -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"
|
package/src/gherkin/index.ts
CHANGED
|
@@ -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({
|
|
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 {
|
|
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
|
|
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
|
})
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -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(
|
|
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))
|
|
171
198
|
return
|
|
172
199
|
}
|
|
173
|
-
const applied = applyEmittedScript(repo,
|
|
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,
|
|
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,
|
|
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,
|
package/src/testkit/index.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
751
|
+
commands: CommandTable,
|
|
754
752
|
script: string,
|
|
755
753
|
git: boolean,
|
|
756
754
|
): AppliedScriptResult => {
|
package/src/twins/FlowScripts.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
366
|
+
commands: CommandTable,
|
|
366
367
|
script: string,
|
|
367
368
|
): FlowScriptResult | undefined => {
|
|
368
369
|
if (!script.startsWith(`${SHEBANG}\n`)) return undefined
|
package/src/twins/InMemRepo.ts
CHANGED
|
@@ -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
|
-
|
|
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()]
|
package/src/twins/Layers.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
package/src/twins/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { InMemRepo } from "./InMemRepo.js"
|
|
2
|
-
export {
|
|
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"
|