@pmelab/gtd 60.0.0 → 61.0.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 +21 -2
- package/dist/chunks/testkit-C9sq0x07.mjs +30758 -0
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +12238 -20706
- package/dist/gtd.core.mjs +593 -0
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +7 -7
- package/src/cli/Cli.test.ts +2 -2
- package/src/cli/Cli.ts +68 -51
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Output.test.ts +1 -1
- package/src/cli/Output.ts +3 -2
- package/src/cli/Program.test.ts +44 -25
- package/src/cli/Program.ts +39 -11
- package/src/cli/Strays.ts +3 -2
- package/src/cli/ToolCommands.ts +2 -2
- package/src/edge/Edge.test.ts +86 -31
- package/src/edge/Edge.ts +164 -31
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/flows/runtime.ts +1 -1
- package/src/gherkin/messages.test.ts +1 -0
- package/src/gherkin/module-config.feature +1 -0
- package/src/gherkin/more-steps.feature +1 -0
- package/src/gherkin/testkit.feature +2 -1
- package/src/install/Install.ts +9 -2
- 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/replay/Format.test.ts +35 -8
- package/src/replay/Format.ts +7 -4
- package/src/replay/Replay.test.ts +54 -0
- package/src/replay/Replay.ts +61 -17
- package/src/replay/Trailers.test.ts +12 -12
- package/src/replay/Trailers.ts +35 -20
- package/src/replay/Tree.ts +11 -0
- package/src/replay/index.ts +3 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStart.ts +3 -3
- package/src/step/planStep.test.ts +4 -4
- package/src/step/planStep.ts +10 -8
- package/src/testkit/TestRepo.test.ts +4 -1
- package/src/testkit/TestRepo.ts +3 -1
- package/src/twins/EmittedScriptRecognizer.ts +32 -1
- package/src/twins/EmittedScripts.test.ts +1 -1
- package/src/twins/GitTiers.ts +29 -7
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/Layers.test.ts +1 -1
- package/src/twins/Layers.ts +10 -3
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/cliIo.ts +9 -4
- package/src/twins/index.ts +1 -0
- package/src/wire/BeatDocument.ts +26 -5
- package/src/wire/Demand.ts +16 -6
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -0
- 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/emit/GitScript.ts
CHANGED
|
@@ -12,12 +12,13 @@ export const pathspec = (paths: ReadonlyArray<string>): string => paths.map(shel
|
|
|
12
12
|
* output rather than the exit code, so a genuinely rejected commit (a lint
|
|
13
13
|
* error, a commit-msg hook) still fails.
|
|
14
14
|
*/
|
|
15
|
-
const commitAllowEmpty = (message: string): string => {
|
|
15
|
+
const commitAllowEmpty = (message: string, only?: string): string => {
|
|
16
16
|
const m = shellQuote(message)
|
|
17
|
+
const paths = only === undefined ? "" : ` -- ${shellQuote(only)}`
|
|
17
18
|
return [
|
|
18
|
-
`if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
|
|
19
|
+
`if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
|
|
19
20
|
` case "$out" in`,
|
|
20
|
-
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
|
|
21
|
+
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m}${paths} || exit 1 ;;`,
|
|
21
22
|
` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
|
|
22
23
|
` esac`,
|
|
23
24
|
`fi`,
|
|
@@ -31,6 +32,21 @@ export const CHANGED_SINCE_LAND =
|
|
|
31
32
|
export const commitAll = (message: string): string =>
|
|
32
33
|
`git add -A || exit 1\n${commitAllowEmpty(message)}`
|
|
33
34
|
|
|
35
|
+
/** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
|
|
36
|
+
export const commitPath = (path: string, message: string): string =>
|
|
37
|
+
`git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`
|
|
38
|
+
|
|
39
|
+
/** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
|
|
40
|
+
export const pinScript = (path: string): string => {
|
|
41
|
+
const p = shellQuote(path)
|
|
42
|
+
const slash = path.lastIndexOf("/")
|
|
43
|
+
const mkdir = slash === -1 ? "" : `mkdir -p ${shellQuote(path.slice(0, slash))}\n`
|
|
44
|
+
return `${mkdir}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
|
|
48
|
+
export const unpinScript = (path: string): string => `rm -f -- ${shellQuote(path)}`
|
|
49
|
+
|
|
34
50
|
/**
|
|
35
51
|
* An approval records exactly the judged tree: HEAD is pinned before the
|
|
36
52
|
* clean check and the commit is built over its tree with `commit-tree`, so no
|
|
@@ -151,6 +167,8 @@ const renderGitWrite = (write: GitWrite): string => {
|
|
|
151
167
|
switch (write.kind) {
|
|
152
168
|
case "commitAll":
|
|
153
169
|
return commitAll(write.message)
|
|
170
|
+
case "commitPath":
|
|
171
|
+
return commitPath(write.path, write.message)
|
|
154
172
|
case "commitClean":
|
|
155
173
|
return commitClean(write.message)
|
|
156
174
|
case "updateRef":
|
package/src/emit/index.ts
CHANGED
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
|
|
|
@@ -23,6 +23,7 @@ Feature: steps for checks, stalls, beat fields, commit bodies and the environmen
|
|
|
23
23
|
|
|
24
24
|
Scenario: the resting step's fields are read by name
|
|
25
25
|
When gtd starts workflow "tested"
|
|
26
|
+
And the process is driven
|
|
26
27
|
Then the process rests at "build.plan"
|
|
27
28
|
And the resting step's model is "smart"
|
|
28
29
|
And the resting step's idle is "false"
|
|
@@ -43,6 +43,7 @@ Feature: testkit drives any workflow against the real engine
|
|
|
43
43
|
And the commit subjects end with:
|
|
44
44
|
"""
|
|
45
45
|
gtd(human): plan
|
|
46
|
+
gtd(check): pin → plan
|
|
46
47
|
gtd(agent): plan → check
|
|
47
48
|
gtd(check): check → ready
|
|
48
49
|
gtd(judge): ready → ship
|
|
@@ -106,7 +107,7 @@ Feature: testkit drives any workflow against the real engine
|
|
|
106
107
|
When gtd starts workflow "tiny"
|
|
107
108
|
And the process is driven
|
|
108
109
|
Then the process rests at "plan"
|
|
109
|
-
And the last commit subject is "gtd(
|
|
110
|
+
And the last commit subject is "gtd(check): pin → plan"
|
|
110
111
|
|
|
111
112
|
Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
|
|
112
113
|
Given step "check" is prompted with "npm test"
|
package/src/install/Install.ts
CHANGED
|
@@ -240,7 +240,13 @@ The \`kind\` field (\`--json=kind\`) selects what to do:
|
|
|
240
240
|
Land it immediately, no display needed.
|
|
241
241
|
- \`script\` — run \`--json=content\` with \`sh -c\`, appending its output to
|
|
242
242
|
\`--json=log\`, and ignore its exit code — the outcome lives in the tree,
|
|
243
|
-
not in the exit code.
|
|
243
|
+
not in the exit code. Two script beats carry a \`pin\` mark: the **pin
|
|
244
|
+
beat** right after a process starts (\`pin: "add"\`, its script writes
|
|
245
|
+
\`gtd bundle\`'s output to \`.gtd/process.gtdpin\`) and the **unpin beat**
|
|
246
|
+
once the flow has ended (\`pin: "remove"\`, its script deletes that file).
|
|
247
|
+
Both run unattended, like any script beat. A driver may write or delete the
|
|
248
|
+
file its own way instead of running the script; either way it then lands,
|
|
249
|
+
and the unpin's landing is the commit that closes the process.
|
|
244
250
|
- \`prompt\` — send plain \`gtd next\`'s own output to your agent CLI over
|
|
245
251
|
stdin (it's the bare content at this kind, so no separate \`--json=content\`
|
|
246
252
|
read is needed), using the accompanying \`session.id\`/\`session.resume\`/
|
|
@@ -284,7 +290,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
|
|
|
284
290
|
\`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
|
|
285
291
|
document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
|
|
286
292
|
\`cost\`, \`costByModel\`. Always \`approvals\` (an array of
|
|
287
|
-
\`{step, name, email, commit}\`, \`[]\` when none).
|
|
293
|
+
\`{step, name, email, commit}\`, \`[]\` when none). Only at a pin or unpin
|
|
294
|
+
beat: \`pin\` (\`"add"\` or \`"remove"\`).
|
|
288
295
|
|
|
289
296
|
\`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
|
|
290
297
|
(true for a no-op at a \`script\` rest — stop immediately, nothing more to
|
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
|
)
|
|
@@ -12,10 +12,9 @@ import { mkdtempSync, realpathSync } from "node:fs"
|
|
|
12
12
|
import { execSync } from "node:child_process"
|
|
13
13
|
import { tmpdir } from "node:os"
|
|
14
14
|
import { Effect, Layer } from "effect"
|
|
15
|
-
import { NodeContext } from "@effect/platform-node"
|
|
16
15
|
import { describe, expect, it } from "vitest"
|
|
17
16
|
import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
|
|
18
|
-
import { GitService, Host, Workspace } from "./index.js"
|
|
17
|
+
import { GitService, Host, Workspace, NodeServices } from "./index.js"
|
|
19
18
|
import { InMemRepo, testLayers } from "../twins/index.js"
|
|
20
19
|
|
|
21
20
|
const provide = <A>(
|
|
@@ -112,7 +111,7 @@ describe("worktreeGitDir [real git]", () => {
|
|
|
112
111
|
|
|
113
112
|
const runIn = (root: string) => {
|
|
114
113
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
115
|
-
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer,
|
|
114
|
+
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
116
115
|
const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
|
|
117
116
|
return Effect.runPromise(
|
|
118
117
|
worktreeGitDir.pipe(Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer))),
|
|
@@ -17,7 +17,7 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
|
|
|
17
17
|
const workspace = yield* Workspace
|
|
18
18
|
const content = yield* workspace
|
|
19
19
|
.read(".git")
|
|
20
|
-
.pipe(Effect.
|
|
20
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
21
21
|
const pointer = content === undefined ? null : GITDIR_POINTER.exec(content)
|
|
22
22
|
if (pointer === null) return ".git"
|
|
23
23
|
const gitdir = pointer[1]!.trim()
|
package/src/platform/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* home, env, scratch). `GitService` moved in alongside `Workspace` since
|
|
5
5
|
* `committed` IS `git.readFileAtRef` — one module owns every read.
|
|
6
6
|
*
|
|
7
|
-
* Seam rule, stated once: a seam is a `Context.
|
|
7
|
+
* Seam rule, stated once: a seam is a `Context.Service` unless the consuming
|
|
8
8
|
* code is synchronous, non-Effect code that cannot `yield*` — replay's flow
|
|
9
9
|
* reads are that exception, and use the `*Sync` members.
|
|
10
10
|
*/
|
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
} from "./Git.js"
|
|
20
20
|
export { Narrator, GtdError, GtdUsageError, renderFailure } from "./Commentary.js"
|
|
21
21
|
export { CommandRunner } from "./CommandRunner.js"
|
|
22
|
+
export * as NodeServices from "./NodeServices.js"
|
|
22
23
|
export { loopLogPath } from "./WorktreeState.js"
|
|
23
24
|
export {
|
|
24
25
|
HISTORY_REF,
|