@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.
@@ -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
+ }
@@ -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 the trailer cannot carry printably is never recorded, so it can never
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
- export const contentHash = (content: string | Buffer): string =>
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 => {
@@ -33,5 +33,5 @@ export {
33
33
  type StateMode,
34
34
  type StateName,
35
35
  } from "./Workflow.js"
36
- export { changedConfigFiles, contentHash, type ConfigFingerprint } from "./fingerprint.js"
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"
@@ -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
 
@@ -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 end = bytes.indexOf(0x0a)
65
- const header = /^gtdpin (\d+) gtd (\S+)$/.exec(Buffer.from(bytes.subarray(0, end)).toString())
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
- format: Number(header[1]),
69
- version: header[2]!,
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
  }