@pmelab/gtd 58.0.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/README.md +3 -2
- package/dist/chunks/{Schema-C6fLlQzx.mjs → Schema-B3o6eRNU.mjs} +637 -3957
- package/dist/chunks/{reserved-BzAFXuz_.mjs → reserved-lMYCaapY.mjs} +1 -1
- package/dist/chunks/{review-CVqRS0mc.mjs → review-Bfaror38.mjs} +2 -2
- package/dist/chunks/{tail-JQzB21Q3.mjs → tail-Bd7L940p.mjs} +1 -1
- package/dist/chunks/testkit-JDtpNKyi.mjs +10410 -0
- package/dist/gtd.bundle.mjs +8 -8
- package/dist/testkit/gherkin.mjs +283 -0
- package/dist/testkit.mjs +2 -15461
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/package.json +8 -1
- package/src/cli/Cli.test.ts +1 -1
- package/src/cli/History.test.ts +1 -1
- package/src/cli/Metrics.test.ts +1 -1
- package/src/cli/Program.test.ts +1 -1
- package/src/cli/Strays.test.ts +1 -1
- package/src/edge/Edge.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/emit/GitScript.ts +1 -1
- package/src/emit/OutcomeScript.ts +1 -1
- package/src/gherkin/git-control.feature +78 -0
- package/src/{testkit/steps.ts → gherkin/index.ts} +55 -4
- package/src/gherkin/module-config.feature +24 -0
- package/src/gherkin/tiny.fixture.ts +8 -0
- package/src/platform/Git.test.ts +2 -2
- package/src/platform/RetainedHistory.test.ts +2 -2
- package/src/platform/Workspace.test.ts +2 -2
- package/src/platform/WorktreeState.test.ts +1 -1
- package/src/step/planStart.test.ts +1 -1
- package/src/testkit/TestRepo.test.ts +118 -2
- package/src/testkit/TestRepo.ts +64 -10
- package/src/testkit/index.ts +16 -9
- package/src/{testkit → twins}/EmittedScriptRecognizer.ts +12 -14
- package/src/{testkit → twins}/FlowScripts.ts +9 -8
- package/src/{testkit → twins}/InMemRepo.ts +26 -1
- package/src/{testkit → twins}/Layers.ts +14 -3
- package/src/twins/index.ts +13 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflow/discovery.ts +3 -3
- package/src/workflow/load.ts +3 -3
- /package/src/{testkit → gherkin}/testkit.feature +0 -0
- /package/src/{testkit → twins}/EmittedScriptRecognizer.test.ts +0 -0
- /package/src/{testkit → twins}/EmittedScripts.test.ts +0 -0
- /package/src/{testkit → twins}/FakeGitOperations.ts +0 -0
- /package/src/{testkit → twins}/FlowScripts.test.ts +0 -0
- /package/src/{testkit → twins}/GitTiers.test.ts +0 -0
- /package/src/{testkit → twins}/GitTiers.ts +0 -0
- /package/src/{testkit → twins}/Layers.test.ts +0 -0
- /package/src/{testkit → twins}/StrictGitOperations.ts +0 -0
- /package/src/{testkit → twins}/cliIo.ts +0 -0
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -2,10 +2,14 @@ import { Effect } from "effect"
|
|
|
2
2
|
import { PassThrough } from "node:stream"
|
|
3
3
|
import { runCli } from "../cli/index.js"
|
|
4
4
|
import { EXIT_OK, type BeatDocument } from "../wire/index.js"
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import {
|
|
6
|
+
applyEmittedScript,
|
|
7
|
+
applyFlowScript,
|
|
8
|
+
InMemRepo,
|
|
9
|
+
makeCapturingCliIo,
|
|
10
|
+
type CapturedCliResult,
|
|
11
|
+
type CommandTable,
|
|
12
|
+
} from "../twins/index.js"
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* The repository's `gtd.config.ts`: its source, or the module itself. A module
|
|
@@ -15,8 +19,23 @@ import { InMemRepo } from "./InMemRepo.js"
|
|
|
15
19
|
*/
|
|
16
20
|
export type WorkflowModule = string | Readonly<Record<string, unknown>>
|
|
17
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
|
+
|
|
18
35
|
export interface TestRepoOptions {
|
|
19
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[]
|
|
20
39
|
/** Committed alongside the config, before any process starts. */
|
|
21
40
|
readonly files?: Readonly<Record<string, string>>
|
|
22
41
|
/** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
|
|
@@ -39,6 +58,15 @@ export interface DriveOptions {
|
|
|
39
58
|
}
|
|
40
59
|
|
|
41
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
|
|
42
70
|
read(path: string): string | undefined
|
|
43
71
|
write(path: string, content: string): void
|
|
44
72
|
remove(path: string): void
|
|
@@ -155,19 +183,21 @@ const LINES: ReadonlyArray<readonly [RegExp, (repo: InMemRepo, words: string[])
|
|
|
155
183
|
const knownLine = (line: string) => LINES.find(([pattern]) => pattern.test(line))
|
|
156
184
|
|
|
157
185
|
const cannotRun = (error: string): Error =>
|
|
158
|
-
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
|
+
)
|
|
159
189
|
|
|
160
190
|
// A script with a line `LINES` lacks goes to the contract-tested recognizers
|
|
161
191
|
// whole, minus the git builders: they would apply a commit/reset/update-ref in
|
|
162
192
|
// memory, so any git-mutating block is refused whatever its spelling.
|
|
163
|
-
const runLines = (repo: InMemRepo, script: string): void => {
|
|
193
|
+
const runLines = (repo: InMemRepo, commands: CommandTable, script: string): void => {
|
|
164
194
|
const lines = script.split("\n").map((line) => line.trim())
|
|
165
195
|
const known = lines.map(knownLine)
|
|
166
196
|
if (known.every(Boolean)) {
|
|
167
197
|
known.forEach((k, i) => k))
|
|
168
198
|
return
|
|
169
199
|
}
|
|
170
|
-
const applied = applyEmittedScript(repo,
|
|
200
|
+
const applied = applyEmittedScript(repo, commands, script, { git: false })
|
|
171
201
|
if (!applied.ok) throw cannotRun(applied.error ?? "")
|
|
172
202
|
}
|
|
173
203
|
|
|
@@ -186,6 +216,18 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
186
216
|
for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
|
|
187
217
|
repo.seedCommit("chore: initial commit")
|
|
188
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
|
+
|
|
189
231
|
// The module is registered only while gtd runs, so nothing outlives the
|
|
190
232
|
// call that loads it and no test can reach another's module.
|
|
191
233
|
const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
|
|
@@ -214,7 +256,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
214
256
|
|
|
215
257
|
const apply = (what: string, script: string): void => {
|
|
216
258
|
if (script.length === 0) return
|
|
217
|
-
const applied = applyEmittedScript(repo,
|
|
259
|
+
const applied = applyEmittedScript(repo, commands, script)
|
|
218
260
|
if (!applied.ok) throw new Error(`${what}: its script failed — ${applied.error ?? ""}`)
|
|
219
261
|
}
|
|
220
262
|
|
|
@@ -257,14 +299,26 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
257
299
|
}
|
|
258
300
|
|
|
259
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(),
|
|
260
314
|
read: (path) => repo.readFile(path),
|
|
261
315
|
write: (path, content) => repo.writeFile(path, content),
|
|
262
316
|
remove: (path) => repo.deleteFile(path),
|
|
263
317
|
commit: (message) => repo.seedCommit(message),
|
|
264
318
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
265
319
|
runScript: (script) => {
|
|
266
|
-
const flow = applyFlowScript(repo,
|
|
267
|
-
if (flow === undefined) runLines(repo, script)
|
|
320
|
+
const flow = applyFlowScript(repo, commands, script)
|
|
321
|
+
if (flow === undefined) runLines(repo, commands, script)
|
|
268
322
|
else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
|
|
269
323
|
},
|
|
270
324
|
gtd,
|
package/src/testkit/index.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
// The public testkit: runs any workflow against the real engine in an in-memory
|
|
2
|
+
// repository. It imports no test runner, so any runner can use it; the
|
|
3
|
+
// gherkin steps are the opt-in \`@pmelab/gtd/testkit/gherkin\`.
|
|
4
|
+
export {
|
|
5
|
+
runsItsScript,
|
|
6
|
+
testRepo,
|
|
7
|
+
type Actor,
|
|
8
|
+
type Actors,
|
|
9
|
+
type CannedCommand,
|
|
10
|
+
type DriveOptions,
|
|
11
|
+
type TestRepo,
|
|
12
|
+
type TestRepoOptions,
|
|
13
|
+
type WorkflowModule,
|
|
14
|
+
} from "./TestRepo.js"
|
|
15
|
+
export { InMemRepo, type CapturedCliResult } from "../twins/index.js"
|
|
16
|
+
export type { TestkitWorld } from "../gherkin/index.js"
|
|
@@ -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 => {
|
|
@@ -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
|
|
@@ -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()]
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// gtd itself spawns no subprocess at all any more (`gtd renders; the driver
|
|
5
5
|
// executes`) — a mode's `format:`/`validate:` command is emitted text, never
|
|
6
6
|
// run in-process. `ScriptedCommand` below still exists for the world's own
|
|
7
|
-
// fake-shell interpreter (`src/
|
|
7
|
+
// fake-shell interpreter (`src/twins/EmittedScriptRecognizer.ts`), which
|
|
8
8
|
// simulates a driver running gtd's emitted scripts against the in-memory
|
|
9
9
|
// worktree — a wholly separate mechanism from any gtd-internal layer.
|
|
10
10
|
|
|
@@ -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
|
|
@@ -193,7 +204,7 @@ export interface TestWorldOptions {
|
|
|
193
204
|
* Effect test has no `--verbose` flag of its own to gate on.
|
|
194
205
|
*/
|
|
195
206
|
readonly narrate?: (line: string) => void
|
|
196
|
-
/** Gates `narrate` above — defaults to `true` (fires whenever a sink is given) since these tests aren't exercising `Cli.ts`'s own `--verbose` gate. `src/
|
|
207
|
+
/** Gates `narrate` above — defaults to `true` (fires whenever a sink is given) since these tests aren't exercising `Cli.ts`'s own `--verbose` gate. `src/twins/cliIo.ts` overrides this with the real decoded flag. */
|
|
197
208
|
readonly verbose?: boolean
|
|
198
209
|
/** Real or fake driver and tool commands — each one left out fails when called. */
|
|
199
210
|
readonly tools?: Partial<ToolCommands["Type"]>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { InMemRepo } from "./InMemRepo.js"
|
|
2
|
+
export {
|
|
3
|
+
makeInMemoryWorkspaceOps,
|
|
4
|
+
testLayers,
|
|
5
|
+
type CommandTable,
|
|
6
|
+
type ScriptedCommand,
|
|
7
|
+
} from "./Layers.js"
|
|
8
|
+
export { makeCapturingCliIo, type CapturedCliResult } from "./cliIo.js"
|
|
9
|
+
export { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
10
|
+
export { applyFlowScript } from "./FlowScripts.js"
|
|
11
|
+
export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } from "./GitTiers.js"
|
|
12
|
+
export { strictGitOperations } from "./StrictGitOperations.js"
|
|
13
|
+
export { fakeGitOperations } from "./FakeGitOperations.js"
|
|
@@ -6,7 +6,7 @@ import { NodeContext } from "@effect/platform-node"
|
|
|
6
6
|
import { Effect, Layer } from "effect"
|
|
7
7
|
import { afterEach, describe, expect, it } from "vitest"
|
|
8
8
|
import { GitService, Host, Narrator, Workspace } from "../platform/index.js"
|
|
9
|
-
import { InMemRepo, testLayers } from "../
|
|
9
|
+
import { InMemRepo, testLayers } from "../twins/index.js"
|
|
10
10
|
import { ConfigDiscovery, ConfigService } from "./index.js"
|
|
11
11
|
|
|
12
12
|
// A config and the local modules it imports load the same from disk, where
|
|
@@ -38,7 +38,7 @@ export const WORKFLOW_MODULE = "gtd.config.ts"
|
|
|
38
38
|
* The file names gtd read repository settings from before `gtd.config.ts` took
|
|
39
39
|
* them over. Still looked for, only to refuse them: a leftover one would
|
|
40
40
|
* otherwise be silently ignored. Shared with the in-memory counterpart in
|
|
41
|
-
* `src/
|
|
41
|
+
* `src/twins/Layers.ts`, so both tiers refuse the same names.
|
|
42
42
|
*/
|
|
43
43
|
export const LEGACY_RC_FILES = [
|
|
44
44
|
".gtdrc",
|
|
@@ -71,7 +71,7 @@ const legacyRcFileAt = (path: string): LegacyRcFile | undefined => {
|
|
|
71
71
|
* the user's home dir (inclusive, when it is an ancestor) or after reaching the
|
|
72
72
|
* filesystem root — whichever comes first. Returned innermost→outermost.
|
|
73
73
|
* Exported (via the barrel) for the same reason `SEARCH_PLACES` is — one
|
|
74
|
-
* shared implementation, not a second copy in `src/
|
|
74
|
+
* shared implementation, not a second copy in `src/twins/Layers.ts` that
|
|
75
75
|
* could silently diverge.
|
|
76
76
|
*/
|
|
77
77
|
export const walkUp = (from: string, home: string): ReadonlyArray<string> => {
|
|
@@ -115,7 +115,7 @@ const workflowModule = (
|
|
|
115
115
|
* reads; discovery is effectful and environment-dependent: `.Live` reads
|
|
116
116
|
* REAL disk, so an `@inmem` scenario's fake
|
|
117
117
|
* `Workspace` — a pure in-memory `Map`, no real files anywhere on disk —
|
|
118
|
-
* cannot back it. `src/
|
|
118
|
+
* cannot back it. `src/twins/Layers.ts` provides the in-memory
|
|
119
119
|
* counterpart instead, mirroring how `Workspace`/`Host`/`GitService` already
|
|
120
120
|
* each have a production and a fake layer behind one Tag.
|
|
121
121
|
*/
|
package/src/workflow/load.ts
CHANGED
|
@@ -357,14 +357,14 @@ const shippedModules = (): Promise<ShippedModules> =>
|
|
|
357
357
|
return { modules, missing }
|
|
358
358
|
})())
|
|
359
359
|
|
|
360
|
-
/** The file each subpath a config may import serves: all but `flows`, the engine's own, and
|
|
360
|
+
/** The file each subpath a config may import serves: all but `flows`, the engine's own, and the testkit's. */
|
|
361
361
|
const subpathFiles = (condition: string): ReadonlyMap<string, string> => {
|
|
362
362
|
const { root, pkg } = ownPackage()
|
|
363
363
|
const files = new Map<string, string>()
|
|
364
364
|
for (const [subpath, target] of Object.entries(pkg.exports)) {
|
|
365
365
|
const file = typeof target === "string" ? target : target[condition]
|
|
366
|
-
//
|
|
367
|
-
if (subpath === "./flows" || subpath
|
|
366
|
+
// A config runs no tests, and the gherkin steps import the consumer's test runner.
|
|
367
|
+
if (subpath === "./flows" || subpath.startsWith("./testkit") || file === undefined) continue
|
|
368
368
|
files.set(`${PACKAGE}/${subpath.replace(/^\.\//, "")}`, join(root, file))
|
|
369
369
|
}
|
|
370
370
|
return files
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|