@pmelab/gtd 61.0.0 → 62.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 +15 -4
- package/dist/chunks/{testkit-C9sq0x07.mjs → testkit-CPzWnl3P.mjs} +76 -63
- package/dist/gtd.bundle.mjs +282 -80
- package/dist/gtd.core.mjs +121 -118
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +2 -0
- package/src/cli/Cli.ts +18 -1
- package/src/cli/Launch.test.ts +52 -0
- package/src/cli/Launch.ts +144 -0
- package/src/cli/Program.test.ts +4 -3
- package/src/cli/Program.ts +41 -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/emit/GitScript.ts +7 -0
- package/src/emit/index.ts +1 -0
- 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/EmittedScriptRecognizer.ts +3 -2
- 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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
3
|
+
import { registerHooks } from "node:module"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { pathToFileURL } from "node:url"
|
|
6
|
+
|
|
7
|
+
/** A process's pin as the launcher found it: the file's bytes and where its engine runs from. */
|
|
8
|
+
export interface FoundPin {
|
|
9
|
+
readonly path: string
|
|
10
|
+
readonly bytes: Uint8Array
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the launcher looks for a pin: the worktree's copy, else HEAD's — so
|
|
15
|
+
* the landing after an unpin beat deleted the file still runs on the pin.
|
|
16
|
+
*/
|
|
17
|
+
export type PinFile = () => FoundPin | undefined
|
|
18
|
+
|
|
19
|
+
// `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
|
|
20
|
+
// a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
|
|
21
|
+
// read counts as no pin, so the launcher refuses it rather than crashing.
|
|
22
|
+
export const livePinFile =
|
|
23
|
+
(cwd: string, path: string): PinFile =>
|
|
24
|
+
() => {
|
|
25
|
+
const file = join(cwd, path)
|
|
26
|
+
if (existsSync(file)) {
|
|
27
|
+
try {
|
|
28
|
+
return { path: file, bytes: readFileSync(file) }
|
|
29
|
+
} catch {
|
|
30
|
+
return { path: file, bytes: new Uint8Array() }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const bytes = execFileSync("git", ["show", `HEAD:${path}`], {
|
|
35
|
+
cwd,
|
|
36
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
+
})
|
|
38
|
+
return { path: file, bytes }
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs a pin's module as this process's main program. Node refuses an unknown
|
|
46
|
+
* extension, so a load hook serves the unpacked source under the pin's own
|
|
47
|
+
* URL — `import.meta.url` stays a file in the repository.
|
|
48
|
+
*/
|
|
49
|
+
export const runPinModule = async (path: string, source: string): Promise<void> => {
|
|
50
|
+
const url = pathToFileURL(path).href
|
|
51
|
+
// A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
|
|
52
|
+
registerHooks({
|
|
53
|
+
resolve: (specifier, context, nextResolve) =>
|
|
54
|
+
specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
|
|
55
|
+
load: (requested, context, nextLoad) =>
|
|
56
|
+
requested === url
|
|
57
|
+
? { format: "module", source, shortCircuit: true }
|
|
58
|
+
: nextLoad(requested, context),
|
|
59
|
+
})
|
|
60
|
+
await import(url)
|
|
61
|
+
}
|
package/src/platform/index.ts
CHANGED
|
@@ -60,43 +60,6 @@ describe("the commit-message codec", () => {
|
|
|
60
60
|
expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
it("writes Gtd-Config trailers sorted by path, after Gtd-Var, and reads paths with spaces back", () => {
|
|
64
|
-
const message = formatCommitMessage({
|
|
65
|
-
actor: "human",
|
|
66
|
-
to: "x",
|
|
67
|
-
vars: { a: "1" },
|
|
68
|
-
config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
|
|
69
|
-
})
|
|
70
|
-
expect(message).toBe(
|
|
71
|
-
"gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 2",
|
|
72
|
-
)
|
|
73
|
-
expect(parseCommitMessage(message).config).toEqual({
|
|
74
|
-
"gtd.config.ts": "deadbeefdeadbeef",
|
|
75
|
-
"lib/my flow.ts": "0123456789abcdef",
|
|
76
|
-
})
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it("round-trips a Gtd-Config path that git or the trailer syntax would alter", () => {
|
|
80
|
-
const config = {
|
|
81
|
-
" lead.ts": "0123456789abcdef",
|
|
82
|
-
"trail.ts ": "0123456789abcdef",
|
|
83
|
-
'"q.ts': "0123456789abcdef",
|
|
84
|
-
}
|
|
85
|
-
const message = formatCommitMessage({ actor: "human", to: "x", config })
|
|
86
|
-
expect(message).toContain('Gtd-Config: 0123456789abcdef " lead.ts"')
|
|
87
|
-
expect(parseCommitMessage(message).config).toEqual(config)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it("ignores a Gtd-Config trailer without a 16-digit hex hash, without a path, or with a control character in its path", () => {
|
|
91
|
-
const read = (value: string) =>
|
|
92
|
-
parseCommitMessage(`gtd(human): x\n\nGtd-Config: ${value}`).config
|
|
93
|
-
expect(read("zzzzzzzzzzzzzzzz gtd.config.ts")).toEqual({})
|
|
94
|
-
expect(read("deadbeef gtd.config.ts")).toEqual({})
|
|
95
|
-
expect(read("deadbeefdeadbeef")).toEqual({})
|
|
96
|
-
expect(read("deadbeefdeadbeef a\x1b]0;x\x07.ts")).toEqual({})
|
|
97
|
-
expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
|
|
98
|
-
})
|
|
99
|
-
|
|
100
63
|
it("round-trips Gtd-Branch and Gtd-Version, after Gtd-Judge and before Gtd-Format", () => {
|
|
101
64
|
const message = formatCommitMessage({
|
|
102
65
|
actor: "agent",
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -83,8 +83,6 @@ export interface CommitMessage {
|
|
|
83
83
|
/** `Gtd-Mark`, in order; a malformed or control-character line is skipped. */
|
|
84
84
|
readonly marks: readonly Mark[]
|
|
85
85
|
readonly vars: Readonly<Record<string, string>>
|
|
86
|
-
/** Written only on a process's first commit. */
|
|
87
|
-
readonly config: Readonly<Record<string, string>>
|
|
88
86
|
readonly reviewBase: string | undefined
|
|
89
87
|
readonly cost: readonly CostEntry[]
|
|
90
88
|
/** Written only on a process's finishing landing. */
|
|
@@ -120,20 +118,10 @@ const parseJson = (raw: string): unknown => {
|
|
|
120
118
|
const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm
|
|
121
119
|
const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/
|
|
122
120
|
const VAR_RE = /^([^=\s]+)=(.*)$/
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
// JSON string. A control character is refused on read: history written by
|
|
126
|
-
// someone else must never put escape sequences on a terminal.
|
|
121
|
+
// A control character is refused on read: history written by someone else
|
|
122
|
+
// must never put escape sequences on a terminal.
|
|
127
123
|
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
128
124
|
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/
|
|
129
|
-
const encodePath = (path: string): string =>
|
|
130
|
-
path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
|
|
131
|
-
? JSON.stringify(path)
|
|
132
|
-
: path
|
|
133
|
-
const decodePath = (raw: string): string | undefined => {
|
|
134
|
-
const path = raw.startsWith('"') ? parseJson(raw) : raw
|
|
135
|
-
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
|
|
136
|
-
}
|
|
137
125
|
const decodeApproval = (raw: string): Approval | undefined => {
|
|
138
126
|
const parsed = parseJson(raw)
|
|
139
127
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
|
|
@@ -168,7 +156,6 @@ interface Trailers {
|
|
|
168
156
|
readonly judge: JudgeVerdict[]
|
|
169
157
|
readonly marks: Mark[]
|
|
170
158
|
readonly vars: Record<string, string>
|
|
171
|
-
readonly config: Record<string, string>
|
|
172
159
|
readonly cost: CostEntry[]
|
|
173
160
|
format: number | undefined
|
|
174
161
|
}
|
|
@@ -193,11 +180,6 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
193
180
|
const kv = VAR_RE.exec(value)
|
|
194
181
|
if (kv !== null) into.vars[kv[1]!] = kv[2]!
|
|
195
182
|
},
|
|
196
|
-
"Gtd-Config": (value, into) => {
|
|
197
|
-
const entry = CONFIG_RE.exec(value)
|
|
198
|
-
const path = entry === null ? undefined : decodePath(entry[2]!)
|
|
199
|
-
if (path !== undefined) into.config[path] = entry![1]!
|
|
200
|
-
},
|
|
201
183
|
"Gtd-Review-Base": (value, into) => {
|
|
202
184
|
into.reviewBase ??= value.split(/\s/)[0]
|
|
203
185
|
},
|
|
@@ -242,7 +224,6 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
242
224
|
judge: [],
|
|
243
225
|
marks: [],
|
|
244
226
|
vars: {},
|
|
245
|
-
config: {},
|
|
246
227
|
cost: [],
|
|
247
228
|
format: undefined,
|
|
248
229
|
}
|
|
@@ -262,7 +243,6 @@ export interface CommitSpec {
|
|
|
262
243
|
readonly workflow?: string
|
|
263
244
|
readonly reviewBase?: string
|
|
264
245
|
readonly vars?: Readonly<Record<string, string>>
|
|
265
|
-
readonly config?: Readonly<Record<string, string>>
|
|
266
246
|
readonly cost?: { readonly cost: number; readonly model?: string }
|
|
267
247
|
readonly judge?: readonly JudgeVerdict[]
|
|
268
248
|
/** Written after `Gtd-Judge`, before `Gtd-Approved`. */
|
|
@@ -290,7 +270,6 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
|
290
270
|
...trailer("Workflow", spec.workflow),
|
|
291
271
|
...trailer("Review-Base", spec.reviewBase),
|
|
292
272
|
...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
|
|
293
|
-
...sortedEntries(spec.config).map(([path, hash]) => `Gtd-Config: ${hash} ${encodePath(path)}`),
|
|
294
273
|
...trailer("Cost", costValue(spec.cost)),
|
|
295
274
|
...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
|
|
296
275
|
...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
|
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 } },
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
modeContradictionSkipNotice,
|
|
27
27
|
OUTCOME_MARKER,
|
|
28
28
|
pinScript,
|
|
29
|
+
repinScript,
|
|
29
30
|
unpinScript,
|
|
30
31
|
} from "../emit/index.js"
|
|
31
32
|
import {
|
|
@@ -272,9 +273,9 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
272
273
|
/** What the in-memory `gtd bundle` prints: a pin header, and no engine behind it. */
|
|
273
274
|
const IN_MEMORY_PIN = "gtdpin 1 gtd in-memory\n"
|
|
274
275
|
|
|
275
|
-
/** A pin or unpin beat's script. */
|
|
276
|
+
/** A pin or unpin beat's script, or `gtd repin`'s. */
|
|
276
277
|
const recognizePinScripts = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
277
|
-
if (block === pinScript(PIN_PATH)) {
|
|
278
|
+
if (block === pinScript(PIN_PATH) || block === repinScript(PIN_PATH)) {
|
|
278
279
|
repo.writeFile(PIN_PATH, IN_MEMORY_PIN)
|
|
279
280
|
return { kind: "applied" }
|
|
280
281
|
}
|
|
@@ -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
|
}
|