@pmelab/gtd 61.0.0 → 62.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 +9 -0
- package/dist/chunks/{testkit-C9sq0x07.mjs → testkit-XQeYCNMn.mjs} +20 -61
- package/dist/gtd.bundle.mjs +227 -80
- package/dist/gtd.core.mjs +110 -110
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.ts +6 -1
- package/src/cli/Launch.test.ts +52 -0
- package/src/cli/Launch.ts +143 -0
- package/src/cli/Program.test.ts +1 -2
- package/src/cli/Program.ts +0 -1
- package/src/cli/index.ts +1 -0
- package/src/edge/Edge.test.ts +3 -3
- package/src/edge/Edge.ts +17 -71
- package/src/platform/PinFile.ts +61 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Trailers.test.ts +0 -37
- package/src/replay/Trailers.ts +2 -23
- package/src/step/planStart.test.ts +0 -1
- package/src/step/planStart.ts +1 -5
- package/src/twins/PinFile.test.ts +107 -0
- package/src/twins/PinFile.ts +12 -0
- package/src/twins/index.ts +1 -0
- package/src/workflow/fingerprint.ts +2 -2
- package/src/workflow/index.ts +2 -2
- package/src/workflow/load.ts +14 -1
- package/src/workflow/pin.ts +15 -7
package/src/step/planStart.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
ownFlagRefusal,
|
|
8
8
|
resolveStartVars,
|
|
9
9
|
StartFlags,
|
|
10
|
-
type ConfigFingerprint,
|
|
11
10
|
type ResolvedWorkflow,
|
|
12
11
|
} from "../workflow/index.js"
|
|
13
12
|
import { formatCommitMessage, formatSubject } from "../replay/index.js"
|
|
@@ -70,8 +69,7 @@ const checkOverrides = (
|
|
|
70
69
|
* `gtd start <name>`: start a brand NEW process on a workflow, writing an
|
|
71
70
|
* opening commit that names the workflow's first step and carries a
|
|
72
71
|
* `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
|
|
73
|
-
* a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base
|
|
74
|
-
* and a `Gtd-Config:` trailer per config file.
|
|
72
|
+
* and a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base.
|
|
75
73
|
* All validation is a REFUSAL, not an Effect failure.
|
|
76
74
|
*/
|
|
77
75
|
export const planStart = (
|
|
@@ -83,7 +81,6 @@ export const planStart = (
|
|
|
83
81
|
readonly first: { readonly firstStep: string } | { readonly refusal: string }
|
|
84
82
|
readonly commandLabel: string
|
|
85
83
|
readonly vars: Record<string, string>
|
|
86
|
-
readonly config: ConfigFingerprint
|
|
87
84
|
},
|
|
88
85
|
): Effect.Effect<
|
|
89
86
|
StartOutcome,
|
|
@@ -136,7 +133,6 @@ export const planStart = (
|
|
|
136
133
|
workflow: name,
|
|
137
134
|
...(base !== undefined ? { reviewBase: base } : {}),
|
|
138
135
|
vars,
|
|
139
|
-
config: start.config,
|
|
140
136
|
})
|
|
141
137
|
const steps: readonly LandStep[] = [
|
|
142
138
|
{ kind: "gitWrite", write: { kind: "commitAll", message } },
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
6
|
+
import { livePinFile, type PinFile } from "../platform/index.js"
|
|
7
|
+
import { InMemRepo } from "./index.js"
|
|
8
|
+
import { inMemPinFile } from "./PinFile.js"
|
|
9
|
+
|
|
10
|
+
const PIN = ".gtd/process.gtdpin"
|
|
11
|
+
|
|
12
|
+
interface Tier {
|
|
13
|
+
readonly root: string
|
|
14
|
+
/** The pin a launcher starting at the root finds. */
|
|
15
|
+
readonly pinFile: () => PinFile
|
|
16
|
+
readonly write: (path: string, content: string) => void
|
|
17
|
+
readonly remove: (path: string) => void
|
|
18
|
+
readonly commit: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const liveTier = (): Tier => {
|
|
22
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-pinfile-")))
|
|
23
|
+
cleanups.push(() => rmSync(root, { recursive: true, force: true }))
|
|
24
|
+
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "ignore" })
|
|
25
|
+
git("init", "-q")
|
|
26
|
+
return {
|
|
27
|
+
root,
|
|
28
|
+
pinFile: () => livePinFile(root, PIN),
|
|
29
|
+
write: (path, content) => {
|
|
30
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
31
|
+
writeFileSync(join(root, path), content)
|
|
32
|
+
},
|
|
33
|
+
remove: (path) => rmSync(join(root, path)),
|
|
34
|
+
commit: () => {
|
|
35
|
+
git("add", "-A")
|
|
36
|
+
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "c")
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const inMemTier = (): Tier => {
|
|
42
|
+
const repo = new InMemRepo()
|
|
43
|
+
const root = "/repo"
|
|
44
|
+
return {
|
|
45
|
+
root,
|
|
46
|
+
pinFile: () => inMemPinFile(repo, root, PIN),
|
|
47
|
+
write: (path, content) => repo.writeFile(path, content),
|
|
48
|
+
remove: (path) => repo.deleteFile(path),
|
|
49
|
+
commit: () => repo.seedCommit("c"),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cleanups: (() => void)[] = []
|
|
54
|
+
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
|
55
|
+
|
|
56
|
+
const text = (pinFile: PinFile) => {
|
|
57
|
+
const found = pinFile()
|
|
58
|
+
return found && { path: found.path, content: Buffer.from(found.bytes).toString() }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe.each([
|
|
62
|
+
["live", liveTier],
|
|
63
|
+
["in-memory", inMemTier],
|
|
64
|
+
])("PinFile contract: %s", (_, make) => {
|
|
65
|
+
it("finds nothing without a pin", () => {
|
|
66
|
+
const tier = make()
|
|
67
|
+
tier.write("README.md", "x")
|
|
68
|
+
tier.commit()
|
|
69
|
+
expect(text(tier.pinFile())).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("finds the worktree's pin, committed or not", () => {
|
|
73
|
+
const tier = make()
|
|
74
|
+
tier.write(PIN, "pinned")
|
|
75
|
+
expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "pinned" })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("prefers the worktree's pin over HEAD's", () => {
|
|
79
|
+
const tier = make()
|
|
80
|
+
tier.write(PIN, "committed")
|
|
81
|
+
tier.commit()
|
|
82
|
+
tier.write(PIN, "rewritten")
|
|
83
|
+
expect(text(tier.pinFile())?.content).toBe("rewritten")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("falls back to HEAD's pin once the worktree's is deleted", () => {
|
|
87
|
+
const tier = make()
|
|
88
|
+
tier.write(PIN, "committed")
|
|
89
|
+
tier.commit()
|
|
90
|
+
tier.remove(PIN)
|
|
91
|
+
expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "committed" })
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("livePinFile", () => {
|
|
96
|
+
it("reads an unreadable pin as no pin's bytes", () => {
|
|
97
|
+
const tier = liveTier()
|
|
98
|
+
tier.write(`${PIN}/inside`, "x")
|
|
99
|
+
expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "" })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("finds nothing outside a repository", () => {
|
|
103
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "gtd-pinfile-")))
|
|
104
|
+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }))
|
|
105
|
+
expect(livePinFile(dir, PIN)()).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import type { PinFile } from "../platform/index.js"
|
|
3
|
+
import type { InMemRepo } from "./InMemRepo.js"
|
|
4
|
+
|
|
5
|
+
export const inMemPinFile =
|
|
6
|
+
(repo: InMemRepo, root: string, path: string): PinFile =>
|
|
7
|
+
() => {
|
|
8
|
+
const content = repo.readFile(path) ?? repo.fileAtRef("HEAD", path) ?? undefined
|
|
9
|
+
return content === undefined
|
|
10
|
+
? undefined
|
|
11
|
+
: { path: join(root, path), bytes: Buffer.from(content) }
|
|
12
|
+
}
|
package/src/twins/index.ts
CHANGED
|
@@ -12,3 +12,4 @@ export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } f
|
|
|
12
12
|
export { strictGitOperations } from "./StrictGitOperations.js"
|
|
13
13
|
export { fakeGitOperations } from "./FakeGitOperations.js"
|
|
14
14
|
export { takePinBeat } from "./PinBeats.js"
|
|
15
|
+
export { inMemPinFile } from "./PinFile.js"
|
|
@@ -7,12 +7,12 @@ export type ConfigFingerprint = Readonly<Record<string, string>>
|
|
|
7
7
|
|
|
8
8
|
const HASH_LENGTH = 16
|
|
9
9
|
|
|
10
|
-
// A path
|
|
10
|
+
// A path with a control character is never recorded, so it can never
|
|
11
11
|
// reach a terminal from history.
|
|
12
12
|
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
13
13
|
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
const contentHash = (content: string | Buffer): string =>
|
|
16
16
|
createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH)
|
|
17
17
|
|
|
18
18
|
const realOr = (path: string): string => {
|
package/src/workflow/index.ts
CHANGED
|
@@ -33,5 +33,5 @@ export {
|
|
|
33
33
|
type StateMode,
|
|
34
34
|
type StateName,
|
|
35
35
|
} from "./Workflow.js"
|
|
36
|
-
export {
|
|
37
|
-
export { EngineCore, pin, readPin } from "./pin.js"
|
|
36
|
+
export type { ConfigFingerprint } from "./fingerprint.js"
|
|
37
|
+
export { EngineCore, pin, pinHeader, PIN_FORMAT, readPin } from "./pin.js"
|
package/src/workflow/load.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
|
|
|
17
17
|
import { compileConfig, type CompiledConfig } from "./compile.js"
|
|
18
18
|
import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
|
|
19
19
|
import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
|
|
20
|
-
import { configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
|
|
20
|
+
import { changedConfigFiles, configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
|
|
21
21
|
import { PIN_KEY } from "../install/index.js"
|
|
22
22
|
import {
|
|
23
23
|
dedupeDiagnostics,
|
|
@@ -50,6 +50,8 @@ export interface ConfigOperations {
|
|
|
50
50
|
readonly warnings: readonly Diagnostic[]
|
|
51
51
|
/** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
|
|
52
52
|
readonly fingerprint: ConfigFingerprint
|
|
53
|
+
/** Inside a pin, the files it was bundled from that differ on disk now. */
|
|
54
|
+
readonly drift: readonly string[]
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// Everything lives in `message` (not `GtdError.detail`) — `renderFailure`
|
|
@@ -204,6 +206,17 @@ export const load: Effect.Effect<
|
|
|
204
206
|
fingerprint:
|
|
205
207
|
pinned?.fingerprint ??
|
|
206
208
|
configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
|
|
209
|
+
drift:
|
|
210
|
+
pinned === undefined
|
|
211
|
+
? []
|
|
212
|
+
: changedConfigFiles(
|
|
213
|
+
pinned.fingerprint,
|
|
214
|
+
configFingerprint(
|
|
215
|
+
Object.keys(pinned.fingerprint).map((file) => resolvePath(host.root, file)),
|
|
216
|
+
host.root,
|
|
217
|
+
readFromDisk,
|
|
218
|
+
),
|
|
219
|
+
),
|
|
207
220
|
}
|
|
208
221
|
})
|
|
209
222
|
|
package/src/workflow/pin.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { ownPackage, pinnedWorkflowSource } from "./load.js"
|
|
|
7
7
|
import type { WorkflowModule } from "./discovery.js"
|
|
8
8
|
|
|
9
9
|
/** Bumped when a launcher could no longer run an older pin the same way. */
|
|
10
|
-
const PIN_FORMAT = 1
|
|
10
|
+
export const PIN_FORMAT = 1
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The engine a pin carries, as ES module source. Live, the `gtd.core.mjs` the
|
|
@@ -57,16 +57,24 @@ export const pin = (
|
|
|
57
57
|
])
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
/** A pin's header line, read without unpacking it; undefined when the bytes are no pin. */
|
|
61
|
+
export const pinHeader = (
|
|
62
|
+
bytes: Uint8Array,
|
|
63
|
+
): { readonly format: number; readonly version: string } | undefined => {
|
|
64
|
+
const end = bytes.indexOf(0x0a)
|
|
65
|
+
const header =
|
|
66
|
+
end < 0 ? null : /^gtdpin (\d+) gtd (\S+)$/.exec(Buffer.from(bytes.subarray(0, end)).toString())
|
|
67
|
+
return header === null ? undefined : { format: Number(header[1]), version: header[2]! }
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/** A pin's header and module source. */
|
|
61
71
|
export const readPin = (
|
|
62
72
|
bytes: Uint8Array,
|
|
63
73
|
): { readonly format: number; readonly version: string; readonly source: string } => {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
if (end < 0 || header === null) throw new Error("not a gtd pin")
|
|
74
|
+
const header = pinHeader(bytes)
|
|
75
|
+
if (header === undefined) throw new Error("not a gtd pin")
|
|
67
76
|
return {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
source: gunzipSync(bytes.subarray(end + 1)).toString("utf8"),
|
|
77
|
+
...header,
|
|
78
|
+
source: gunzipSync(bytes.subarray(bytes.indexOf(0x0a) + 1)).toString("utf8"),
|
|
71
79
|
}
|
|
72
80
|
}
|