@pmelab/gtd 60.0.0 → 60.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -2
- package/dist/chunks/testkit-BhTey1Do.mjs +30517 -0
- package/dist/gtd.bundle.mjs +10545 -19230
- package/dist/gtd.core.mjs +593 -0
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/package.json +7 -7
- package/src/cli/Cli.test.ts +2 -2
- package/src/cli/Cli.ts +68 -51
- package/src/cli/Output.test.ts +1 -1
- package/src/cli/Output.ts +3 -2
- package/src/cli/Program.test.ts +14 -6
- package/src/cli/Program.ts +35 -10
- package/src/cli/ToolCommands.ts +2 -2
- package/src/edge/Edge.test.ts +8 -6
- package/src/edge/Edge.ts +3 -3
- package/src/flows/runtime.ts +1 -1
- package/src/install/Version.ts +9 -3
- package/src/install/index.ts +1 -1
- package/src/platform/CommandRunner.ts +13 -11
- package/src/platform/Commentary.ts +2 -2
- package/src/platform/Git.test.ts +4 -5
- package/src/platform/Git.ts +42 -38
- package/src/platform/Host.ts +1 -1
- package/src/platform/NodeServices.ts +11 -0
- package/src/platform/Workspace.test.ts +4 -5
- package/src/platform/Workspace.ts +3 -3
- package/src/platform/WorktreeState.test.ts +2 -3
- package/src/platform/WorktreeState.ts +1 -1
- package/src/platform/index.ts +2 -1
- package/src/step/planStart.ts +3 -3
- package/src/twins/EmittedScripts.test.ts +1 -1
- package/src/twins/GitTiers.ts +6 -7
- package/src/twins/Layers.test.ts +1 -1
- package/src/twins/Layers.ts +10 -3
- package/src/twins/cliIo.ts +9 -4
- package/src/workflow/discovery.test.ts +24 -7
- package/src/workflow/discovery.ts +5 -6
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +60 -3
- package/src/workflow/load.ts +316 -166
- package/src/workflow/pin.test.ts +159 -0
- package/src/workflow/pin.ts +72 -0
- package/src/workflow/vars.ts +3 -3
- package/dist/chunks/testkit-BvecVjQ0.mjs +0 -32355
package/src/cli/Program.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Effect, Option, Schema } from "effect"
|
|
2
2
|
import {
|
|
3
|
+
GtdError,
|
|
3
4
|
GtdUsageError,
|
|
4
5
|
Narrator,
|
|
5
6
|
GitService,
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
import {
|
|
16
17
|
WORKFLOW_MODULE,
|
|
17
18
|
ConfigDiscovery,
|
|
19
|
+
EngineCore,
|
|
20
|
+
pin,
|
|
18
21
|
refuseLegacyRc,
|
|
19
22
|
ConfigService,
|
|
20
23
|
formatDiagnostic,
|
|
@@ -88,7 +91,7 @@ import {
|
|
|
88
91
|
abandonNoopOutcome,
|
|
89
92
|
restoredOutcome,
|
|
90
93
|
} from "../emit/index.js"
|
|
91
|
-
import { renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
|
|
94
|
+
import { GTD_VERSION, renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
|
|
92
95
|
import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
|
|
93
96
|
import {
|
|
94
97
|
normalizeScriptNewline,
|
|
@@ -119,7 +122,7 @@ import { runHistoryMetricsCommand } from "./Metrics.js"
|
|
|
119
122
|
* SAME type structurally off `currentRest`'s own success type rather than
|
|
120
123
|
* importing the name.
|
|
121
124
|
*/
|
|
122
|
-
type Rest = Effect.
|
|
125
|
+
type Rest = Effect.Success<typeof currentRest>
|
|
123
126
|
|
|
124
127
|
export type Command =
|
|
125
128
|
| { readonly kind: "lsp" }
|
|
@@ -161,6 +164,7 @@ export type Command =
|
|
|
161
164
|
}
|
|
162
165
|
| { readonly kind: "uncheck"; readonly file: string }
|
|
163
166
|
| { readonly kind: "guide" }
|
|
167
|
+
| { readonly kind: "bundle" }
|
|
164
168
|
| { readonly kind: "summary" }
|
|
165
169
|
| { readonly kind: "base" }
|
|
166
170
|
| { readonly kind: "judge" }
|
|
@@ -219,15 +223,34 @@ const runGuideCommand = (
|
|
|
219
223
|
const briefing = renderBriefing()
|
|
220
224
|
out.write(briefing.endsWith("\n") ? briefing : briefing + "\n")
|
|
221
225
|
const host = yield* Host
|
|
222
|
-
const topLevel = yield* Effect.
|
|
223
|
-
if (topLevel._tag === "
|
|
224
|
-
if ((yield* host.realPath(topLevel.
|
|
226
|
+
const topLevel = yield* Effect.result((yield* GitService).topLevel())
|
|
227
|
+
if (topLevel._tag === "Failure") return
|
|
228
|
+
if ((yield* host.realPath(topLevel.success)) !== (yield* host.realPath(host.root))) return
|
|
225
229
|
if ((yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)) !== undefined) return
|
|
226
230
|
yield* (yield* Narrator).warn(
|
|
227
231
|
`gtd guide: no ${WORKFLOW_MODULE} governs here, and gtd ships no workflow — get one from ${WORKFLOWS_REPO} or write your own (step 2 of the briefing above)`,
|
|
228
232
|
)
|
|
229
233
|
})
|
|
230
234
|
|
|
235
|
+
/** Loads the workflow first, so a pin is never made of one gtd would refuse. */
|
|
236
|
+
const runBundleCommand = (
|
|
237
|
+
out: ArtifactOut,
|
|
238
|
+
): Effect.Effect<
|
|
239
|
+
void,
|
|
240
|
+
Error,
|
|
241
|
+
ConfigService | ConfigDiscovery | EngineCore | Host | Narrator | Workspace
|
|
242
|
+
> =>
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
yield* (yield* ConfigService).load
|
|
245
|
+
const host = yield* Host
|
|
246
|
+
const module = yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)
|
|
247
|
+
// Inside a pin, load read the pinned workflow, not a config on disk.
|
|
248
|
+
if (module === undefined) {
|
|
249
|
+
return yield* Effect.fail(new GtdError(`gtd bundle: no ${WORKFLOW_MODULE} here or above`))
|
|
250
|
+
}
|
|
251
|
+
out.write(yield* pin(module, host.root, GTD_VERSION))
|
|
252
|
+
})
|
|
253
|
+
|
|
231
254
|
/**
|
|
232
255
|
* `planLanding`'s result — a preview of what `gtd land` would do, plus the
|
|
233
256
|
* combined `script` the driver runs to do it. `script` is
|
|
@@ -457,7 +480,7 @@ const runJudgeRunCommand = (
|
|
|
457
480
|
})
|
|
458
481
|
|
|
459
482
|
/** A verdict's own `answer` shape covers every `judge:` primitive (`noul` → boolean, `choice` → string, `score` → number) without gtd itself interpreting which one applies — that's a driver/judge-model concern, not this decode's. */
|
|
460
|
-
const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
|
|
483
|
+
const VerdictAnswer = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
|
461
484
|
|
|
462
485
|
/**
|
|
463
486
|
* One `gtd judge answer` verdict entry per pending question, `{ id, answer,
|
|
@@ -470,14 +493,14 @@ const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
|
|
|
470
493
|
const verdictSchemaFor = (ids: readonly string[]) =>
|
|
471
494
|
Schema.Array(
|
|
472
495
|
Schema.Struct({
|
|
473
|
-
id: ids.length > 0 ? Schema.
|
|
496
|
+
id: ids.length > 0 ? Schema.Literals(ids) : Schema.Never,
|
|
474
497
|
answer: VerdictAnswer,
|
|
475
498
|
// Bounded [0, 1] — every OTHER bound in the routes: matching path fails
|
|
476
499
|
// CLOSED (a non-finite rendered minP/maxP never matches); an
|
|
477
500
|
// unvalidated p wouldn't: a driver bug or a miscalibrated model
|
|
478
501
|
// sending p > 1 would clear every minP a workflow declares,
|
|
479
502
|
// unconditionally forcing the judged route on a live gate.
|
|
480
|
-
p: Schema.Number.
|
|
503
|
+
p: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 })),
|
|
481
504
|
}),
|
|
482
505
|
)
|
|
483
506
|
|
|
@@ -509,7 +532,7 @@ const runJudgeAnswerCommand = (
|
|
|
509
532
|
try: () => JSON.parse(raw) as unknown,
|
|
510
533
|
catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
|
|
511
534
|
})
|
|
512
|
-
const verdict = yield* Schema.
|
|
535
|
+
const verdict = yield* Schema.decodeUnknownEffect(verdictSchemaFor(ids))(parsed).pipe(
|
|
513
536
|
Effect.mapError(
|
|
514
537
|
(e) =>
|
|
515
538
|
new SelectorUsageError(
|
|
@@ -1137,7 +1160,7 @@ const gatherBeatDocument = (
|
|
|
1137
1160
|
kind === "prompt" ? resolveSession(rendered.memory, rendered.memoryResumed) : undefined
|
|
1138
1161
|
const resolvedValidate =
|
|
1139
1162
|
kind === "prompt"
|
|
1140
|
-
? yield* resolveValidateScript(rest).pipe(Effect.
|
|
1163
|
+
? yield* resolveValidateScript(rest).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
1141
1164
|
: undefined
|
|
1142
1165
|
const validate =
|
|
1143
1166
|
resolvedValidate !== undefined && resolvedValidate.script.length > 0
|
|
@@ -1332,6 +1355,8 @@ const dispatchVoidCommand = (
|
|
|
1332
1355
|
return runUncheckCommand(command.file)
|
|
1333
1356
|
case "guide":
|
|
1334
1357
|
return runGuideCommand(out)
|
|
1358
|
+
case "bundle":
|
|
1359
|
+
return runBundleCommand(out)
|
|
1335
1360
|
case "summary":
|
|
1336
1361
|
return runSummaryCommand(out)
|
|
1337
1362
|
case "base":
|
package/src/cli/ToolCommands.ts
CHANGED
|
@@ -29,7 +29,7 @@ interface JudgeRequest {
|
|
|
29
29
|
* The subcommands backed by a driver or tool. Core may not import those
|
|
30
30
|
* units, so the composition root provides them here.
|
|
31
31
|
*/
|
|
32
|
-
export class ToolCommands extends Context.
|
|
32
|
+
export class ToolCommands extends Context.Service<
|
|
33
33
|
ToolCommands,
|
|
34
34
|
{
|
|
35
35
|
readonly ui: (
|
|
@@ -39,4 +39,4 @@ export class ToolCommands extends Context.Tag("ToolCommands")<
|
|
|
39
39
|
readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
|
|
40
40
|
readonly judge: (request: JudgeRequest) => Promise<string>
|
|
41
41
|
}
|
|
42
|
-
>() {}
|
|
42
|
+
>()("ToolCommands") {}
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -469,12 +469,14 @@ describe("history reads stay bounded by the episode, not the repository", () =>
|
|
|
469
469
|
const raceyGit: GitOperations = {
|
|
470
470
|
...base,
|
|
471
471
|
subjectHistory: (pageSize, skip, head) =>
|
|
472
|
-
Effect.tap(base.subjectHistory(pageSize, skip, head), () =>
|
|
473
|
-
|
|
474
|
-
moved
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
472
|
+
Effect.tap(base.subjectHistory(pageSize, skip, head), () =>
|
|
473
|
+
Effect.sync(() => {
|
|
474
|
+
if (!moved) {
|
|
475
|
+
moved = true
|
|
476
|
+
repo.hardResetTo(boundary)
|
|
477
|
+
}
|
|
478
|
+
}),
|
|
479
|
+
),
|
|
478
480
|
}
|
|
479
481
|
|
|
480
482
|
const run = await provideWithGit(currentRun, raceyGit, repo)
|
package/src/edge/Edge.ts
CHANGED
|
@@ -1319,7 +1319,7 @@ const decideLanding = (
|
|
|
1319
1319
|
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1320
1320
|
decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
|
|
1321
1321
|
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1322
|
-
Effect.
|
|
1322
|
+
Effect.catch(() => Effect.succeed(undefined)),
|
|
1323
1323
|
)
|
|
1324
1324
|
|
|
1325
1325
|
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
@@ -1432,8 +1432,8 @@ const configRefusal = (
|
|
|
1432
1432
|
)
|
|
1433
1433
|
|
|
1434
1434
|
const differingConfigPaths = (
|
|
1435
|
-
git: GitService["
|
|
1436
|
-
workspace: Workspace["
|
|
1435
|
+
git: GitService["Service"],
|
|
1436
|
+
workspace: Workspace["Service"],
|
|
1437
1437
|
root: string,
|
|
1438
1438
|
config: { readonly fingerprint: ConfigFingerprint },
|
|
1439
1439
|
run: ProcessRun,
|
package/src/flows/runtime.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The authoring surface of a gtd workflow. Every function here is a thin
|
|
2
2
|
// facade over the replay context the engine installs on `globalThis` — keyed by
|
|
3
3
|
// a registered symbol, not a module-local variable, so a copy of this file
|
|
4
|
-
//
|
|
4
|
+
// a user's `gtd.config.ts` brings along and the copy bundled into the
|
|
5
5
|
// gtd binary drive the same replay.
|
|
6
6
|
import type { WorkflowBuild } from "./factory.js"
|
|
7
7
|
|
package/src/install/Version.ts
CHANGED
|
@@ -22,6 +22,12 @@ const findPackageJson = (fromUrl: string): string => {
|
|
|
22
22
|
throw new Error(`package.json not found by walking up from ${fromUrl}`)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
/** The global a pin's workflow registers itself under, ahead of the engine in the same file. */
|
|
26
|
+
export const PIN_KEY = Symbol.for("@pmelab/gtd/pin")
|
|
27
|
+
|
|
28
|
+
// Inside a pin, `import.meta.url` is the pin's own file, in the user's
|
|
29
|
+
// repository: the pin registered the version it was built by before this runs.
|
|
30
|
+
const pinned = (globalThis as { [PIN_KEY]?: { readonly version: string } })[PIN_KEY]?.version
|
|
31
|
+
|
|
32
|
+
export const GTD_VERSION: string =
|
|
33
|
+
pinned ?? (_require(findPackageJson(import.meta.url)) as { version: string }).version
|
package/src/install/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { renderBriefing, WORKFLOWS_REPO } from "./Install.js"
|
|
2
|
-
export { GTD_VERSION } from "./Version.js"
|
|
2
|
+
export { GTD_VERSION, PIN_KEY } from "./Version.js"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Command, CommandExecutor } from "@effect/platform"
|
|
2
1
|
import { Context, Effect, Layer, Stream } from "effect"
|
|
2
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process"
|
|
3
3
|
import { Host } from "./Host.js"
|
|
4
4
|
|
|
5
5
|
/** One command run's outcome: its exit status (a signal death reported as `null`, matching `spawnSync`) and its combined output (stdout then stderr). */
|
|
@@ -23,13 +23,13 @@ interface CommandOutcome {
|
|
|
23
23
|
* stdout/stderr, none of which this port's single-shot "run to completion"
|
|
24
24
|
* shape supports.
|
|
25
25
|
*/
|
|
26
|
-
export class CommandRunner extends Context.
|
|
26
|
+
export class CommandRunner extends Context.Service<
|
|
27
27
|
CommandRunner,
|
|
28
28
|
{
|
|
29
29
|
/** A SPAWN failure (no `bash`, unreadable cwd) fails the Effect; a non-zero EXIT is a value — how a mode command reports "invalid"/"broken" for the caller to interpret. */
|
|
30
30
|
readonly bash: (command: string) => Effect.Effect<CommandOutcome, Error>
|
|
31
31
|
}
|
|
32
|
-
>() {
|
|
32
|
+
>()("CommandRunner") {
|
|
33
33
|
/** A test layer over a canned `bash` implementation — no subprocess. */
|
|
34
34
|
static readonly layer = (
|
|
35
35
|
bash: (command: string) => Effect.Effect<CommandOutcome, Error>,
|
|
@@ -39,17 +39,19 @@ export class CommandRunner extends Context.Tag("CommandRunner")<
|
|
|
39
39
|
CommandRunner,
|
|
40
40
|
Effect.gen(function* () {
|
|
41
41
|
const { root } = yield* Host
|
|
42
|
-
const
|
|
42
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
43
43
|
return {
|
|
44
44
|
bash: (command: string) =>
|
|
45
45
|
Effect.scoped(
|
|
46
46
|
Effect.gen(function* () {
|
|
47
|
-
const process = yield*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
const process = yield* spawner.spawn(
|
|
48
|
+
ChildProcess.make("bash", ["-c", command], {
|
|
49
|
+
cwd: root,
|
|
50
|
+
stdout: "pipe",
|
|
51
|
+
stderr: "pipe",
|
|
52
|
+
// Not detached, like `Git.ts`'s `run`: a background job the command starts survives it.
|
|
53
|
+
detached: false,
|
|
54
|
+
}),
|
|
53
55
|
)
|
|
54
56
|
const collect = (stream: typeof process.stdout) =>
|
|
55
57
|
stream.pipe(Stream.decodeText(), Stream.mkString)
|
|
@@ -60,7 +62,7 @@ export class CommandRunner extends Context.Tag("CommandRunner")<
|
|
|
60
62
|
// A signal death fails process.exitCode; map to null to match spawnSync's contract.
|
|
61
63
|
process.exitCode.pipe(
|
|
62
64
|
Effect.map((code): number | null => code),
|
|
63
|
-
Effect.
|
|
65
|
+
Effect.catch(() => Effect.succeed(null)),
|
|
64
66
|
),
|
|
65
67
|
],
|
|
66
68
|
{ concurrency: "unbounded" },
|
|
@@ -5,13 +5,13 @@ import { Context, Effect, Layer } from "effect"
|
|
|
5
5
|
* layer was built, never by the call site; `warn` is its ungated
|
|
6
6
|
* counterpart, always written regardless of verbosity.
|
|
7
7
|
*/
|
|
8
|
-
export class Narrator extends Context.
|
|
8
|
+
export class Narrator extends Context.Service<
|
|
9
9
|
Narrator,
|
|
10
10
|
{
|
|
11
11
|
readonly narrate: (line: string) => Effect.Effect<void>
|
|
12
12
|
readonly warn: (line: string) => Effect.Effect<void>
|
|
13
13
|
}
|
|
14
|
-
>() {
|
|
14
|
+
>()("Narrator") {
|
|
15
15
|
/**
|
|
16
16
|
* `write` is the raw stderr sink (`CliIo.stderr` in production, so the
|
|
17
17
|
* in-memory `CliIo` used by `@inmem` e2e scenarios captures narration into
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -2,11 +2,10 @@ import { execFileSync } from "node:child_process"
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
|
-
import { NodeContext } from "@effect/platform-node"
|
|
6
5
|
import { Cause, Effect, Exit, Option } from "effect"
|
|
7
6
|
import { describe, expect, it } from "vitest"
|
|
8
7
|
import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
|
|
9
|
-
import { Host } from "./index.js"
|
|
8
|
+
import { Host, NodeServices } from "./index.js"
|
|
10
9
|
import { gitTiers, runGitServiceContract, runGitScriptContract } from "../twins/index.js"
|
|
11
10
|
|
|
12
11
|
/**
|
|
@@ -78,7 +77,7 @@ describe("diffPaths [Live]", () => {
|
|
|
78
77
|
Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
|
|
79
78
|
Effect.provide(GitService.Live),
|
|
80
79
|
Effect.provide(Host.layer({ root: join(root, "sub"), home: root, env: {} })),
|
|
81
|
-
Effect.provide(
|
|
80
|
+
Effect.provide(NodeServices.layer),
|
|
82
81
|
),
|
|
83
82
|
)
|
|
84
83
|
expect([...paths].sort()).toEqual(["sub/a.txt", "top.txt"])
|
|
@@ -131,7 +130,7 @@ describe("diffPaths [Live]", () => {
|
|
|
131
130
|
Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
|
|
132
131
|
Effect.provide(GitService.Live),
|
|
133
132
|
Effect.provide(Host.layer({ root: repo, home: root, env: {} })),
|
|
134
|
-
Effect.provide(
|
|
133
|
+
Effect.provide(NodeServices.layer),
|
|
135
134
|
),
|
|
136
135
|
)
|
|
137
136
|
expect([...paths]).toEqual(["sub"])
|
|
@@ -155,7 +154,7 @@ describe("authorIdent [Live]", () => {
|
|
|
155
154
|
Effect.flatMap(GitService, (g) => g.authorIdent()).pipe(
|
|
156
155
|
Effect.provide(GitService.Live),
|
|
157
156
|
Effect.provide(Host.layer({ root, home: root, env: {} })),
|
|
158
|
-
Effect.provide(
|
|
157
|
+
Effect.provide(NodeServices.layer),
|
|
159
158
|
),
|
|
160
159
|
)
|
|
161
160
|
expect(Option.isNone(ident)).toBe(true)
|
package/src/platform/Git.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Command, CommandExecutor } from "@effect/platform"
|
|
2
1
|
import { Context, Effect, Layer, Option, Stream } from "effect"
|
|
2
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process"
|
|
3
3
|
import { Host } from "./Host.js"
|
|
4
4
|
|
|
5
5
|
export interface GitReaderOperations {
|
|
@@ -80,7 +80,7 @@ export interface GitReaderOperations {
|
|
|
80
80
|
skip: number,
|
|
81
81
|
head?: string,
|
|
82
82
|
) => Effect.Effect<ReadonlyArray<{ readonly hash: string; readonly subject: string }>, Error>
|
|
83
|
-
/** Fails when the path doesn't exist at `ref` — a caller expecting that (e.g. the review sign-off gate) handles it with an explicit `
|
|
83
|
+
/** Fails when the path doesn't exist at `ref` — a caller expecting that (e.g. the review sign-off gate) handles it with an explicit `catch`. */
|
|
84
84
|
readonly readFileAtRef: (ref: string, path: string) => Effect.Effect<string, Error>
|
|
85
85
|
/**
|
|
86
86
|
* Pending working-tree changes vs `base` (default `HEAD`), as
|
|
@@ -216,7 +216,7 @@ const blobsAtRef = (
|
|
|
216
216
|
paths: ReadonlyArray<string>,
|
|
217
217
|
): Effect.Effect<Map<string, string>, Error> =>
|
|
218
218
|
exec("git", "ls-tree", "-r", "-z", ref, "--", ...paths).pipe(
|
|
219
|
-
Effect.
|
|
219
|
+
Effect.catch(() => Effect.succeed("")),
|
|
220
220
|
Effect.map((out) => {
|
|
221
221
|
const blobs = new Map<string, string>()
|
|
222
222
|
for (const entry of splitNul(out)) {
|
|
@@ -256,7 +256,7 @@ const hashObjects = (
|
|
|
256
256
|
.map((oid, i) => [paths[i] ?? "", oid.trim()] as const),
|
|
257
257
|
),
|
|
258
258
|
),
|
|
259
|
-
Effect.
|
|
259
|
+
Effect.catch(() => Effect.succeed(new Map<string, string>())),
|
|
260
260
|
)
|
|
261
261
|
|
|
262
262
|
/**
|
|
@@ -287,25 +287,28 @@ const classifyUntracked = (
|
|
|
287
287
|
|
|
288
288
|
/**
|
|
289
289
|
* Run a command and return its stdout — FAILING on a non-zero exit code with
|
|
290
|
-
* the command line and stderr in the error message.
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* `catchAll`.
|
|
290
|
+
* the command line and stderr in the error message. Ignoring exit codes
|
|
291
|
+
* used to make gtd report success on rejected commits and lose files whose
|
|
292
|
+
* quoted paths broke a swallowed `git add`. Callers that expect a probe to
|
|
293
|
+
* fail handle it with an explicit `catch`.
|
|
295
294
|
*/
|
|
296
295
|
const run = (
|
|
297
296
|
root: string,
|
|
298
297
|
...args: [string, ...Array<string>]
|
|
299
|
-
): Effect.Effect<string, Error,
|
|
298
|
+
): Effect.Effect<string, Error, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
300
299
|
Effect.scoped(
|
|
301
300
|
Effect.gen(function* () {
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
301
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
302
|
+
const [command, ...rest] = args
|
|
303
|
+
const process = yield* spawner.spawn(
|
|
304
|
+
// Not detached: the spawner SIGTERMs a detached child's whole group once it
|
|
305
|
+
// exits, which would kill a git hook's background job.
|
|
306
|
+
ChildProcess.make(command, rest, {
|
|
307
|
+
cwd: root,
|
|
308
|
+
stdout: "pipe",
|
|
309
|
+
stderr: "pipe",
|
|
310
|
+
detached: false,
|
|
311
|
+
}),
|
|
309
312
|
)
|
|
310
313
|
const collect = (stream: typeof process.stdout) =>
|
|
311
314
|
stream.pipe(Stream.decodeText(), Stream.mkString)
|
|
@@ -353,11 +356,12 @@ export const resolvedRefOrCorrupted = (
|
|
|
353
356
|
? Effect.succeed(hash)
|
|
354
357
|
: Effect.fail(new InvalidRefError(`Invalid ref: ${ref}`, [`ref: ${ref}`]))
|
|
355
358
|
|
|
356
|
-
const makeGitImpl = (
|
|
359
|
+
const makeGitImpl = (
|
|
360
|
+
spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
|
|
361
|
+
root: string,
|
|
362
|
+
): GitOperations => {
|
|
357
363
|
const exec = (...args: [string, ...Array<string>]) =>
|
|
358
|
-
run(root, ...args).pipe(
|
|
359
|
-
Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, executor)),
|
|
360
|
-
)
|
|
364
|
+
run(root, ...args).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner))
|
|
361
365
|
|
|
362
366
|
return {
|
|
363
367
|
lastCommitSubject: (ref = "HEAD") =>
|
|
@@ -374,7 +378,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
374
378
|
exec("git", "symbolic-ref", "--quiet", "--short", "HEAD").pipe(
|
|
375
379
|
Effect.map((s) => s.trim()),
|
|
376
380
|
Effect.map((name) => Option.some(name)),
|
|
377
|
-
Effect.
|
|
381
|
+
Effect.catch(() => Effect.succeed(Option.none<string>())),
|
|
378
382
|
),
|
|
379
383
|
|
|
380
384
|
listRefs: (prefix: string) =>
|
|
@@ -399,7 +403,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
399
403
|
hasCommits: () =>
|
|
400
404
|
exec("git", "rev-parse", "--verify", "HEAD").pipe(
|
|
401
405
|
Effect.map(() => true),
|
|
402
|
-
Effect.
|
|
406
|
+
Effect.catch(() => Effect.succeed(false)),
|
|
403
407
|
),
|
|
404
408
|
|
|
405
409
|
readFileAtRef: (ref: string, path: string) => exec("git", "show", `${ref}:${path}`),
|
|
@@ -427,7 +431,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
427
431
|
? Option.none()
|
|
428
432
|
: Option.some({ name: match[1]!, email: match[2]! })
|
|
429
433
|
}),
|
|
430
|
-
Effect.
|
|
434
|
+
Effect.catch(() => Effect.succeed(Option.none())),
|
|
431
435
|
),
|
|
432
436
|
|
|
433
437
|
changedPaths: (base?: string) =>
|
|
@@ -436,7 +440,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
436
440
|
// Tolerates the empty-repo case (no HEAD), where the diff has no ref
|
|
437
441
|
// to compare against and reports no tracked changes.
|
|
438
442
|
const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(
|
|
439
|
-
Effect.
|
|
443
|
+
Effect.catch(() => Effect.succeed("")),
|
|
440
444
|
)
|
|
441
445
|
const trackedPaths = parseNameStatus(splitNul(nameStatusOut))
|
|
442
446
|
|
|
@@ -469,7 +473,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
469
473
|
|
|
470
474
|
stagedPaths: () =>
|
|
471
475
|
exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(
|
|
472
|
-
Effect.
|
|
476
|
+
Effect.catch(() => Effect.succeed("")),
|
|
473
477
|
Effect.map((out) => parseNameStatus(splitNul(out))),
|
|
474
478
|
),
|
|
475
479
|
|
|
@@ -482,26 +486,26 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
482
486
|
readRefOption: (ref: string) =>
|
|
483
487
|
exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(
|
|
484
488
|
Effect.map((s) => Option.some(s.trim())),
|
|
485
|
-
Effect.
|
|
489
|
+
Effect.catch(() => Effect.succeed(Option.none<string>())),
|
|
486
490
|
),
|
|
487
491
|
|
|
488
492
|
isAncestor: (a: string, b: string) =>
|
|
489
493
|
run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(
|
|
490
|
-
Effect.
|
|
494
|
+
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
|
|
491
495
|
Effect.map(() => true),
|
|
492
|
-
Effect.
|
|
496
|
+
Effect.catch(() => Effect.succeed(false)),
|
|
493
497
|
),
|
|
494
498
|
|
|
495
499
|
mergeBase: (a: string, b: string) =>
|
|
496
500
|
exec("git", "merge-base", a, b).pipe(
|
|
497
501
|
Effect.map((s) => Option.some(s.trim())),
|
|
498
|
-
Effect.
|
|
502
|
+
Effect.catch(() => Effect.succeed(Option.none<string>())),
|
|
499
503
|
),
|
|
500
504
|
|
|
501
505
|
defaultBranch: () =>
|
|
502
506
|
exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(
|
|
503
507
|
Effect.map((s) => s.trim()),
|
|
504
|
-
Effect.
|
|
508
|
+
Effect.catch(() => Effect.succeed("")),
|
|
505
509
|
Effect.map((name) => (name === "" ? "main" : name)),
|
|
506
510
|
),
|
|
507
511
|
|
|
@@ -546,7 +550,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
546
550
|
}),
|
|
547
551
|
),
|
|
548
552
|
// Empty repo (no HEAD) makes `git log` fail; treat as no commits.
|
|
549
|
-
Effect.
|
|
553
|
+
Effect.catch(() =>
|
|
550
554
|
Effect.succeed(
|
|
551
555
|
[] as ReadonlyArray<{
|
|
552
556
|
readonly hash: string
|
|
@@ -582,7 +586,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
582
586
|
),
|
|
583
587
|
// Empty repo (no HEAD), or an unresolvable `head`, makes `git log`
|
|
584
588
|
// fail; treat as no commits — the same fold `commitHistory` applies.
|
|
585
|
-
Effect.
|
|
589
|
+
Effect.catch(() =>
|
|
586
590
|
Effect.succeed([] as ReadonlyArray<{ readonly hash: string; readonly subject: string }>),
|
|
587
591
|
),
|
|
588
592
|
)
|
|
@@ -592,7 +596,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
592
596
|
Effect.gen(function* () {
|
|
593
597
|
yield* exec("git", "add", "-A")
|
|
594
598
|
yield* exec("git", "commit", "--allow-empty", "-m", prefix).pipe(
|
|
595
|
-
Effect.
|
|
599
|
+
Effect.catch((error) =>
|
|
596
600
|
// Hooks like lint-staged block empty commits even with --allow-empty.
|
|
597
601
|
// gtd's workflow commits have nothing for code-quality hooks to validate,
|
|
598
602
|
// so retry without the pre-commit hook when that guard fires.
|
|
@@ -608,7 +612,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
608
612
|
commitAsIs: (message: string) =>
|
|
609
613
|
exec("git", "commit", "--allow-empty", "-m", message)
|
|
610
614
|
.pipe(
|
|
611
|
-
Effect.
|
|
615
|
+
Effect.catch((error) =>
|
|
612
616
|
error.message.includes("empty git commit")
|
|
613
617
|
? exec("git", "commit", "--allow-empty", "--no-verify", "-m", message)
|
|
614
618
|
: Effect.fail(error),
|
|
@@ -634,11 +638,11 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
634
638
|
}
|
|
635
639
|
|
|
636
640
|
const makeLiveEffect = Effect.gen(function* () {
|
|
637
|
-
const
|
|
641
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
638
642
|
const { root } = yield* Host
|
|
639
|
-
return makeGitImpl(
|
|
643
|
+
return makeGitImpl(spawner, root)
|
|
640
644
|
})
|
|
641
645
|
|
|
642
|
-
export class GitService extends Context.
|
|
646
|
+
export class GitService extends Context.Service<GitService, GitOperations>()("GitService") {
|
|
643
647
|
static Live = Layer.effect(GitService, makeLiveEffect)
|
|
644
648
|
}
|
package/src/platform/Host.ts
CHANGED
|
@@ -36,7 +36,7 @@ const makeHostOps = (opts: HostOpsInput): HostOps => ({
|
|
|
36
36
|
((path: string) => Effect.try({ try: () => realpathSync(path), catch: toError })),
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
export class Host extends Context.
|
|
39
|
+
export class Host extends Context.Service<Host, HostOps>()("Host") {
|
|
40
40
|
static layer = (opts: HostOpsInput): Layer.Layer<Host> => Layer.succeed(Host, makeHostOps(opts))
|
|
41
41
|
static Live = Host.layer({ root: process.cwd(), home: homedir(), env: process.env })
|
|
42
42
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node-shared"
|
|
2
|
+
import { Layer } from "effect"
|
|
3
|
+
|
|
4
|
+
// Not `@effect/platform-node`'s own: that package requires a redis peer, which
|
|
5
|
+
// every install of gtd would then pull in.
|
|
6
|
+
export const layer = Layer.provideMerge(
|
|
7
|
+
NodeChildProcessSpawner.layer,
|
|
8
|
+
Layer.mergeAll(NodeFileSystem.layer, NodePath.layer),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export type NodeServices = Layer.Success<typeof layer>
|
|
@@ -13,8 +13,7 @@ import { tmpdir } from "node:os"
|
|
|
13
13
|
import { join } from "node:path"
|
|
14
14
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
15
15
|
import { Effect, Layer } from "effect"
|
|
16
|
-
import {
|
|
17
|
-
import { GitService, Host, Workspace, type WorkspaceOps } from "./index.js"
|
|
16
|
+
import { GitService, Host, Workspace, type WorkspaceOps, NodeServices } from "./index.js"
|
|
18
17
|
import { InMemRepo, makeInMemoryWorkspaceOps } from "../twins/index.js"
|
|
19
18
|
|
|
20
19
|
// `execFileSync` is a named export of the built-in `node:child_process` ESM
|
|
@@ -54,7 +53,7 @@ const makeLiveTier = (): WorkspaceTier => {
|
|
|
54
53
|
gitExecIn(root, "config", "commit.gpgsign", "false")
|
|
55
54
|
|
|
56
55
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
57
|
-
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer,
|
|
56
|
+
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
58
57
|
const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
|
|
59
58
|
|
|
60
59
|
return {
|
|
@@ -275,7 +274,7 @@ describe("Workspace [Live] worktreeSync", () => {
|
|
|
275
274
|
Host.layer({ root, home: root, env: {} }),
|
|
276
275
|
GitService.Live.pipe(
|
|
277
276
|
Layer.provide(
|
|
278
|
-
Layer.merge(Host.layer({ root, home: root, env: {} }),
|
|
277
|
+
Layer.merge(Host.layer({ root, home: root, env: {} }), NodeServices.layer),
|
|
279
278
|
),
|
|
280
279
|
),
|
|
281
280
|
),
|
|
@@ -361,7 +360,7 @@ const makeLiveEpisodeTier = () => {
|
|
|
361
360
|
git("config", "commit.gpgsign", "false")
|
|
362
361
|
|
|
363
362
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
364
|
-
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer,
|
|
363
|
+
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
365
364
|
const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
|
|
366
365
|
const ops = (): Promise<WorkspaceOps> =>
|
|
367
366
|
Effect.runPromise(
|
|
@@ -600,7 +600,7 @@ const makeWorkspaceOps = (
|
|
|
600
600
|
committed: (path, ref = "HEAD") =>
|
|
601
601
|
Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
|
|
602
602
|
Effect.flatMap((relPath) =>
|
|
603
|
-
git.readFileAtRef(ref, relPath).pipe(Effect.
|
|
603
|
+
git.readFileAtRef(ref, relPath).pipe(Effect.catch(() => Effect.succeed(undefined))),
|
|
604
604
|
),
|
|
605
605
|
),
|
|
606
606
|
readCommittedSync,
|
|
@@ -610,7 +610,7 @@ const makeWorkspaceOps = (
|
|
|
610
610
|
}
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
-
export class Workspace extends Context.
|
|
613
|
+
export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Workspace") {
|
|
614
614
|
static Live = Layer.effect(
|
|
615
615
|
Workspace,
|
|
616
616
|
Effect.gen(function* () {
|
|
@@ -624,7 +624,7 @@ export class Workspace extends Context.Tag("Workspace")<Workspace, WorkspaceOps>
|
|
|
624
624
|
// like a full-disk failure does.
|
|
625
625
|
const commonDir = yield* git
|
|
626
626
|
.gitCommonDir()
|
|
627
|
-
.pipe(Effect.
|
|
627
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
628
628
|
return makeWorkspaceOps(root, git, commonDir)
|
|
629
629
|
}),
|
|
630
630
|
)
|