@pmelab/gtd 63.0.0 → 64.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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -11
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-Cem5i6Xl.mjs} +422 -2
  6. package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-DfWZQCWQ.mjs} +1073 -328
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4372 -1310
  9. package/dist/gtd.core.mjs +126 -118
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +22 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +34 -34
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +53 -19
  23. package/src/emit/SteeringMode.test.ts +4 -2
  24. package/src/emit/SteeringMode.ts +31 -10
  25. package/src/flows/index.ts +1 -0
  26. package/src/flows/runtime.test.ts +2 -1
  27. package/src/flows/runtime.ts +8 -2
  28. package/src/gherkin/messages.test.ts +1 -1
  29. package/src/gherkin/module-config.feature +1 -1
  30. package/src/gherkin/testkit.feature +2 -2
  31. package/src/install/Version.ts +14 -13
  32. package/src/platform/Host.ts +75 -22
  33. package/src/platform/NodePinFile.ts +44 -0
  34. package/src/platform/NodeRuntime.ts +19 -0
  35. package/src/platform/NodeSync.ts +64 -0
  36. package/src/platform/PinFile.ts +0 -50
  37. package/src/platform/Sha.ts +121 -0
  38. package/src/platform/SyncIo.ts +35 -0
  39. package/src/platform/Workspace.test.ts +8 -3
  40. package/src/platform/Workspace.ts +82 -123
  41. package/src/platform/WorktreeState.test.ts +8 -4
  42. package/src/platform/WorktreeState.ts +21 -19
  43. package/src/platform/index.ts +18 -5
  44. package/src/replay/Format.test.ts +16 -13
  45. package/src/replay/Format.ts +4 -2
  46. package/src/replay/Replay.test.ts +25 -5
  47. package/src/replay/Replay.ts +10 -6
  48. package/src/replay/Trailers.test.ts +10 -10
  49. package/src/replay/Trailers.ts +23 -1
  50. package/src/replay/index.ts +1 -0
  51. package/src/steering/Declared.test.ts +218 -0
  52. package/src/steering/Declared.ts +607 -0
  53. package/src/steering/FORMATS.md +3 -0
  54. package/src/steering/index.ts +12 -0
  55. package/src/steering/internal.ts +1 -0
  56. package/src/step/planStart.test.ts +2 -2
  57. package/src/step/planStep.test.ts +4 -4
  58. package/src/testkit/TestRepo.test.ts +1 -1
  59. package/src/testkit/TestRepo.ts +4 -18
  60. package/src/twins/EmittedScriptRecognizer.ts +5 -3
  61. package/src/twins/EmittedScripts.test.ts +19 -0
  62. package/src/twins/Host.test.ts +151 -0
  63. package/src/twins/Layers.test.ts +2 -2
  64. package/src/twins/Layers.ts +44 -18
  65. package/src/twins/Path.test.ts +52 -0
  66. package/src/twins/SyncIo.test.ts +153 -0
  67. package/src/twins/SyncIo.ts +159 -0
  68. package/src/twins/cliIo.ts +8 -1
  69. package/src/twins/index.ts +5 -0
  70. package/src/workflow/SteeringFormats.ts +4 -0
  71. package/src/workflow/Workflow.ts +2 -0
  72. package/src/workflow/discovery.test.ts +6 -5
  73. package/src/workflow/discovery.ts +43 -32
  74. package/src/workflow/fingerprint.test.ts +23 -3
  75. package/src/workflow/fingerprint.ts +9 -11
  76. package/src/workflow/index.ts +5 -1
  77. package/src/workflow/load.test.ts +16 -6
  78. package/src/workflow/load.ts +107 -69
  79. package/src/workflow/pin.test.ts +3 -2
  80. package/src/workflow/pin.ts +17 -11
@@ -1,38 +1,22 @@
1
- import { execFileSync } from "node:child_process"
2
- import { createHash } from "node:crypto"
3
- import {
4
- lstatSync,
5
- mkdirSync,
6
- readFileSync,
7
- readlinkSync,
8
- renameSync,
9
- writeFileSync,
10
- } from "node:fs"
11
- import { dirname, isAbsolute, join } from "node:path"
12
- import { Context, Effect, Layer } from "effect"
1
+ import { Context, Effect, Layer, Path } from "effect"
13
2
  import { GitService, type GitOperations } from "./Git.js"
14
- import { Host } from "./Host.js"
3
+ import { Host, type HostOps } from "./Host.js"
4
+ import { nodeSyncIo } from "./NodeSync.js"
5
+ import type { SyncIo } from "./SyncIo.js"
15
6
 
16
7
  const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
17
8
 
18
- // A tree listing, or one committed file, can overflow execFileSync's 1 MB default.
19
- const MAX_BUFFER = 256 * 1024 * 1024
20
9
  const HASH_CHUNK = 500
10
+ const encoder = new TextEncoder()
21
11
 
22
12
  /** A full sha1 or sha256 object id — the only refs an immutable, disk-cached read is safe to key on. */
23
13
  const isObjectHash = (ref: string): boolean => /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(ref)
24
14
 
25
15
  const GTD_CACHE_SUBDIR = "gtd-cache"
26
16
 
27
- const treeCachePath = (commonDir: string, hash: string): string =>
28
- join(commonDir, GTD_CACHE_SUBDIR, "trees", hash)
29
-
30
- const blobCachePath = (commonDir: string, id: string): string =>
31
- join(commonDir, GTD_CACHE_SUBDIR, "blobs", id)
32
-
33
- const readTextCache = (path: string): string | undefined => {
17
+ const readTextCache = (io: SyncIo, path: string): string | undefined => {
34
18
  try {
35
- return readFileSync(path, "utf8")
19
+ return io.readText(path)
36
20
  } catch {
37
21
  return undefined
38
22
  }
@@ -46,20 +30,26 @@ const readTextCache = (path: string): string | undefined => {
46
30
  * cache is a best-effort speedup, never a correctness dependency, and the
47
31
  * alternative — failing a gtd command over a cache write — is worse.
48
32
  */
49
- const writeTextCacheAtomic = (path: string, content: string): void => {
33
+ const writeTextCacheAtomic = (
34
+ io: SyncIo,
35
+ paths: Path.Path,
36
+ pid: number,
37
+ path: string,
38
+ content: string,
39
+ ): void => {
50
40
  try {
51
- mkdirSync(dirname(path), { recursive: true })
52
- const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
53
- writeFileSync(tmp, content, "utf8")
54
- renameSync(tmp, path)
41
+ io.makeDirectory(paths.dirname(path))
42
+ const tmp = `${path}.tmp-${pid}-${Math.random().toString(36).slice(2)}`
43
+ io.writeText(tmp, content)
44
+ io.rename(tmp, path)
55
45
  } catch {
56
46
  // A full disk or read-only git dir silently disables the cache.
57
47
  }
58
48
  }
59
49
 
60
50
  /** A commit's `path → blob id` map, serialized as JSON. `undefined` on ANY read failure — missing, truncated, unparseable — so the caller falls back to asking git. */
61
- const readTreeCache = (commonDir: string, hash: string): Map<string, string> | undefined => {
62
- const raw = readTextCache(treeCachePath(commonDir, hash))
51
+ const readTreeCache = (io: SyncIo, file: string): Map<string, string> | undefined => {
52
+ const raw = readTextCache(io, file)
63
53
  if (raw === undefined) return undefined
64
54
  try {
65
55
  const parsed: unknown = JSON.parse(raw)
@@ -75,8 +65,13 @@ const readTreeCache = (commonDir: string, hash: string): Map<string, string> | u
75
65
  }
76
66
  }
77
67
 
78
- const writeTreeCache = (commonDir: string, hash: string, tree: ReadonlyMap<string, string>): void =>
79
- writeTextCacheAtomic(treeCachePath(commonDir, hash), JSON.stringify(Object.fromEntries(tree)))
68
+ const writeTreeCache = (
69
+ io: SyncIo,
70
+ paths: Path.Path,
71
+ pid: number,
72
+ file: string,
73
+ tree: ReadonlyMap<string, string>,
74
+ ): void => writeTextCacheAtomic(io, paths, pid, file, JSON.stringify(Object.fromEntries(tree)))
80
75
 
81
76
  /**
82
77
  * One raw `git log --raw` diff entry, already resolved to what it does to a
@@ -177,19 +172,14 @@ const parseLsTree = (out: string): Map<string, string> => {
177
172
  * check `ok` before doing so.
178
173
  */
179
174
  const fetchTree = (
175
+ io: SyncIo,
180
176
  root: string,
181
177
  ref: string,
182
178
  ): { readonly ok: boolean; readonly entries: Map<string, string> } => {
183
179
  try {
184
180
  return {
185
181
  ok: true,
186
- entries: parseLsTree(
187
- execFileSync("git", ["ls-tree", "-r", "-z", "--full-tree", ref], {
188
- cwd: root,
189
- encoding: "utf8",
190
- maxBuffer: MAX_BUFFER,
191
- }),
192
- ),
182
+ entries: parseLsTree(io.git(root, ["ls-tree", "-r", "-z", "--full-tree", ref])),
193
183
  }
194
184
  } catch {
195
185
  return { ok: false, entries: new Map() }
@@ -263,8 +253,8 @@ export interface WorkspaceOps {
263
253
  readonly writeAtPath: (path: string, content: string) => Effect.Effect<void, Error>
264
254
  }
265
255
 
266
- const assertRepoRelative = (path: string): string => {
267
- if (isAbsolute(path)) {
256
+ const assertRepoRelative = (paths: Path.Path, path: string): string => {
257
+ if (paths.isAbsolute(path)) {
268
258
  throw new Error(
269
259
  `Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath"/"writeAtPath" to read/write outside the repo)`,
270
260
  )
@@ -272,24 +262,6 @@ const assertRepoRelative = (path: string): string => {
272
262
  return path
273
263
  }
274
264
 
275
- const readFileOrAbsent = (path: string): string | undefined => {
276
- try {
277
- return readFileSync(path, "utf8")
278
- } catch (e) {
279
- if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
280
- throw e
281
- }
282
- }
283
-
284
- const lstatOrAbsent = (path: string): ReturnType<typeof lstatSync> | undefined => {
285
- try {
286
- return lstatSync(path)
287
- } catch (e) {
288
- if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
289
- throw e
290
- }
291
- }
292
-
293
265
  /** One replayed commit: its hash and the (already-`\n`-stripped) `--raw -z` body `parseRawDiff` consumes. */
294
266
  interface RawLogEntry {
295
267
  readonly hash: string
@@ -304,27 +276,24 @@ interface RawLogEntry {
304
276
  * bounds no longer match git's own history.
305
277
  */
306
278
  const runRawLogReplay = (
279
+ io: SyncIo,
307
280
  root: string,
308
281
  base: string | undefined,
309
282
  head: string,
310
283
  commits: readonly string[],
311
284
  ): readonly RawLogEntry[] => {
312
285
  const range = base !== undefined ? `${base}..${head}` : head
313
- const out = execFileSync(
314
- "git",
315
- [
316
- "log",
317
- "--first-parent",
318
- "--reverse",
319
- "-z",
320
- "--raw",
321
- "-r",
322
- "--no-abbrev",
323
- "--format=%x01%H",
324
- range,
325
- ],
326
- { cwd: root, encoding: "utf8", maxBuffer: MAX_BUFFER },
327
- )
286
+ const out = io.git(root, [
287
+ "log",
288
+ "--first-parent",
289
+ "--reverse",
290
+ "-z",
291
+ "--raw",
292
+ "-r",
293
+ "--no-abbrev",
294
+ "--format=%x01%H",
295
+ range,
296
+ ])
328
297
  const chunks = out.split("\x01").filter((c) => c.length > 0)
329
298
  if (chunks.length !== commits.length) {
330
299
  throw new Error(
@@ -363,11 +332,19 @@ const foldRawDiffs = (
363
332
  }
364
333
 
365
334
  const makeWorkspaceOps = (
366
- root: string,
335
+ { root, pid }: HostOps,
367
336
  git: GitOperations,
368
337
  commonDir: string | undefined,
338
+ paths: Path.Path,
339
+ io: SyncIo,
369
340
  ): WorkspaceOps => {
370
- const resolveAny = (path: string): string => (isAbsolute(path) ? path : join(root, path))
341
+ const resolveAny = (path: string): string =>
342
+ paths.isAbsolute(path) ? path : paths.join(root, path)
343
+ const repoPath = (path: string): string => paths.join(root, assertRepoRelative(paths, path))
344
+ const treeCachePath = (dir: string, hash: string): string =>
345
+ paths.join(dir, GTD_CACHE_SUBDIR, "trees", hash)
346
+ const blobCachePath = (dir: string, id: string): string =>
347
+ paths.join(dir, GTD_CACHE_SUBDIR, "blobs", id)
371
348
 
372
349
  // Per-PROCESS memo, on the workspace instance rather than any one replay's
373
350
  // `TreeView` — an immutable key (a commit hash, a blob id) can never serve
@@ -381,18 +358,18 @@ const makeWorkspaceOps = (
381
358
  const memoHit = treeMemo.get(hash)
382
359
  if (memoHit !== undefined) return memoHit
383
360
  if (commonDir === undefined) return undefined
384
- const cached = readTreeCache(commonDir, hash)
361
+ const cached = readTreeCache(io, treeCachePath(commonDir, hash))
385
362
  if (cached !== undefined) treeMemo.set(hash, cached)
386
363
  return cached
387
364
  }
388
365
 
389
366
  const rememberTree = (hash: string, tree: ReadonlyMap<string, string>): void => {
390
367
  treeMemo.set(hash, tree)
391
- if (commonDir !== undefined) writeTreeCache(commonDir, hash, tree)
368
+ if (commonDir !== undefined)
369
+ writeTreeCache(io, paths, pid, treeCachePath(commonDir, hash), tree)
392
370
  }
393
371
 
394
- const readSync = (path: string): string | undefined =>
395
- readFileOrAbsent(join(root, assertRepoRelative(path)))
372
+ const readSync = (path: string): string | undefined => io.readText(repoPath(path))
396
373
 
397
374
  /**
398
375
  * A blob's content, keyed on its OWN id rather than a (path, ref) pair —
@@ -405,7 +382,7 @@ const makeWorkspaceOps = (
405
382
  const memoHit = blobMemo.get(id)
406
383
  if (memoHit !== undefined) return memoHit
407
384
  if (commonDir !== undefined) {
408
- const cached = readTextCache(blobCachePath(commonDir, id))
385
+ const cached = readTextCache(io, blobCachePath(commonDir, id))
409
386
  if (cached !== undefined) {
410
387
  blobMemo.set(id, cached)
411
388
  return cached
@@ -413,23 +390,20 @@ const makeWorkspaceOps = (
413
390
  }
414
391
  let content: string
415
392
  try {
416
- content = execFileSync("git", ["cat-file", "-p", id], {
417
- cwd: root,
418
- encoding: "utf8",
419
- maxBuffer: MAX_BUFFER,
420
- })
393
+ content = io.git(root, ["cat-file", "-p", id])
421
394
  } catch {
422
395
  return undefined
423
396
  }
424
397
  blobMemo.set(id, content)
425
- if (commonDir !== undefined) writeTextCacheAtomic(blobCachePath(commonDir, id), content)
398
+ if (commonDir !== undefined)
399
+ writeTextCacheAtomic(io, paths, pid, blobCachePath(commonDir, id), content)
426
400
  return content
427
401
  }
428
402
 
429
403
  // A raw, synchronous `git show <ref>:<path>` — the sync counterpart to
430
404
  // `committed`, for replay, which can't `yield*` mid-flow. Bypasses the Effect-based `GitOperations`
431
405
  // entirely, the same escape `readSync` already takes around the Effect
432
- // `FileSystem` — both are raw Node calls, not a second git abstraction.
406
+ // `FileSystem` — both go through the sync port, not a second git abstraction.
433
407
  // ANY failure (missing ref, missing path at that ref, git not on PATH)
434
408
  // folds to `undefined`, matching `committed`'s own catch-all-as-absence.
435
409
  //
@@ -439,17 +413,13 @@ const makeWorkspaceOps = (
439
413
  // is never cached here, since it can move mid-process (a landing commit),
440
414
  // and goes straight to `git show` as before.
441
415
  const readCommittedSync = (path: string, ref = "HEAD"): string | undefined => {
442
- const relPath = assertRepoRelative(path)
416
+ const relPath = assertRepoRelative(paths, path)
443
417
  if (isObjectHash(ref)) {
444
418
  const id = treeSync(ref).get(relPath)
445
419
  return id === undefined ? undefined : readBlobSync(id)
446
420
  }
447
421
  try {
448
- return execFileSync("git", ["show", `${ref}:${relPath}`], {
449
- cwd: root,
450
- encoding: "utf8",
451
- maxBuffer: MAX_BUFFER,
452
- })
422
+ return io.git(root, ["show", `${ref}:${relPath}`])
453
423
  } catch {
454
424
  return undefined
455
425
  }
@@ -464,7 +434,7 @@ const makeWorkspaceOps = (
464
434
  const cached = peekTree(ref)
465
435
  if (cached !== undefined) return cached
466
436
  }
467
- const { ok, entries } = fetchTree(root, ref)
437
+ const { ok, entries } = fetchTree(io, root, ref)
468
438
  if (!ok) {
469
439
  if (onFailure === "throw") {
470
440
  throw new Error(`gtd: failed to read the tree at "${ref}" — git ls-tree did not succeed`)
@@ -510,7 +480,7 @@ const makeWorkspaceOps = (
510
480
  }
511
481
 
512
482
  const head = commits[commits.length - 1]!
513
- const replay = runRawLogReplay(root, base, head, commits)
483
+ const replay = runRawLogReplay(io, root, base, head, commits)
514
484
  for (const [hash, tree] of foldRawDiffs(baseTree, replay, rememberTree))
515
485
  result.set(hash, tree)
516
486
  return result
@@ -518,13 +488,7 @@ const makeWorkspaceOps = (
518
488
  catch: toError,
519
489
  })
520
490
 
521
- const gitSync = (args: readonly string[], input?: string): string =>
522
- execFileSync("git", args, {
523
- cwd: root,
524
- encoding: "utf8",
525
- maxBuffer: MAX_BUFFER,
526
- ...(input !== undefined ? { input } : {}),
527
- })
491
+ const gitSync = (args: readonly string[]): string => io.git(root, args)
528
492
 
529
493
  // A path git status reports is re-hashed the way `git add` would store it;
530
494
  // every other path keeps HEAD's blob id, so an untouched file is never
@@ -544,13 +508,13 @@ const makeWorkspaceOps = (
544
508
  for (const record of status.split("\0")) {
545
509
  if (record.length < 4) continue
546
510
  const path = record.slice(3).replace(/\/$/, "")
547
- const stat = lstatOrAbsent(join(root, path))
511
+ const stat = io.lstat(paths.join(root, path))
548
512
  if (stat === undefined) entries.delete(path)
549
- else if (stat.isSymbolicLink()) {
550
- entries.set(path, blobId(readlinkSync(join(root, path), "utf8")))
551
- } else if (stat.isDirectory()) {
513
+ else if (stat.type === "symlink") {
514
+ entries.set(path, blobId(io.readLink(paths.join(root, path))))
515
+ } else if (stat.type === "directory") {
552
516
  // Only a directory holding its own repository is committed, as a gitlink.
553
- if (lstatOrAbsent(join(root, path, ".git")) === undefined) entries.delete(path)
517
+ if (io.lstat(paths.join(root, path, ".git")) === undefined) entries.delete(path)
554
518
  else entries.set(path, gitlinkOf(path))
555
519
  } else files.push(path)
556
520
  }
@@ -567,10 +531,7 @@ const makeWorkspaceOps = (
567
531
  /** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
568
532
  const gitlinkOf = (path: string): string | undefined => {
569
533
  try {
570
- return execFileSync("git", ["rev-parse", "HEAD"], {
571
- cwd: join(root, path),
572
- encoding: "utf8",
573
- }).trim()
534
+ return io.git(paths.join(root, path), ["rev-parse", "HEAD"]).trim()
574
535
  } catch {
575
536
  return undefined
576
537
  }
@@ -579,11 +540,8 @@ const makeWorkspaceOps = (
579
540
  let objectFormat: string | undefined
580
541
  const blobId = (content: string): string => {
581
542
  objectFormat ??= gitSync(["rev-parse", "--show-object-format"]).trim()
582
- const bytes = Buffer.from(content, "utf8")
583
- return createHash(objectFormat === "sha256" ? "sha256" : "sha1")
584
- .update(`blob ${bytes.length}\0`)
585
- .update(bytes)
586
- .digest("hex")
543
+ const bytes = encoder.encode(content)
544
+ return io.hash(objectFormat === "sha256" ? "sha256" : "sha1", `blob ${bytes.length}\0`, bytes)
587
545
  }
588
546
 
589
547
  return {
@@ -594,19 +552,19 @@ const makeWorkspaceOps = (
594
552
  read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
595
553
  write: (path, content) =>
596
554
  Effect.try({
597
- try: () => writeFileSync(join(root, assertRepoRelative(path)), content, "utf8"),
555
+ try: () => io.writeText(repoPath(path), content),
598
556
  catch: toError,
599
557
  }),
600
558
  committed: (path, ref = "HEAD") =>
601
- Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
559
+ Effect.try({ try: () => assertRepoRelative(paths, path), catch: toError }).pipe(
602
560
  Effect.flatMap((relPath) =>
603
561
  git.readFileAtRef(ref, relPath).pipe(Effect.catch(() => Effect.succeed(undefined))),
604
562
  ),
605
563
  ),
606
564
  readCommittedSync,
607
- atPath: (path) => readFileOrAbsent(resolveAny(path)),
565
+ atPath: (path) => io.readText(resolveAny(path)),
608
566
  writeAtPath: (path, content) =>
609
- Effect.try({ try: () => writeFileSync(resolveAny(path), content, "utf8"), catch: toError }),
567
+ Effect.try({ try: () => io.writeText(resolveAny(path), content), catch: toError }),
610
568
  }
611
569
  }
612
570
 
@@ -614,8 +572,9 @@ export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Works
614
572
  static Live = Layer.effect(
615
573
  Workspace,
616
574
  Effect.gen(function* () {
617
- const { root } = yield* Host
575
+ const host = yield* Host
618
576
  const git = yield* GitService
577
+ const paths = yield* Path.Path
619
578
  // Resolved once per process, not lazily inside a sync method: the
620
579
  // common dir is git's own concern to answer, and every cache read/
621
580
  // write below is synchronous. A repo whose common dir can't be
@@ -625,7 +584,7 @@ export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Works
625
584
  const commonDir = yield* git
626
585
  .gitCommonDir()
627
586
  .pipe(Effect.catch(() => Effect.succeed(undefined)))
628
- return makeWorkspaceOps(root, git, commonDir)
587
+ return makeWorkspaceOps(host, git, commonDir, paths, nodeSyncIo)
629
588
  }),
630
589
  )
631
590
  }
@@ -11,14 +11,14 @@ import { join } from "node:path"
11
11
  import { mkdtempSync, realpathSync } from "node:fs"
12
12
  import { execSync } from "node:child_process"
13
13
  import { tmpdir } from "node:os"
14
- import { Effect, Layer } from "effect"
14
+ import { Effect, Layer, type Path } from "effect"
15
15
  import { describe, expect, it } from "vitest"
16
16
  import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
17
17
  import { GitService, Host, Workspace, NodeServices } from "./index.js"
18
18
  import { InMemRepo, testLayers } from "../twins/index.js"
19
19
 
20
20
  const provide = <A>(
21
- eff: Effect.Effect<A, never, Host | Workspace>,
21
+ eff: Effect.Effect<A, never, Host | Path.Path | Workspace>,
22
22
  repo: InMemRepo,
23
23
  opts: {
24
24
  readonly root?: string
@@ -112,9 +112,13 @@ describe("worktreeGitDir [real git]", () => {
112
112
  const runIn = (root: string) => {
113
113
  const hostLayer = Host.layer({ root, home: root, env: {} })
114
114
  const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
115
- const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
115
+ const workspaceLayer = Workspace.Live.pipe(
116
+ Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
117
+ )
116
118
  return Effect.runPromise(
117
- worktreeGitDir.pipe(Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer))),
119
+ worktreeGitDir.pipe(
120
+ Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer, NodeServices.layer)),
121
+ ),
118
122
  )
119
123
  }
120
124
 
@@ -1,5 +1,4 @@
1
- import { isAbsolute, join } from "node:path"
2
- import { Effect } from "effect"
1
+ import { Effect, Path } from "effect"
3
2
  import { Host } from "./Host.js"
4
3
  import { Workspace } from "./Workspace.js"
5
4
 
@@ -11,9 +10,10 @@ const GITDIR_POINTER = /^gitdir:\s*(.+)$/m
11
10
  * read failure or a `.git` file with no `gitdir:` line both fall back to
12
11
  * `".git"`, which is also correct for the in-memory test tier.
13
12
  */
14
- export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Effect.gen(
15
- function* () {
13
+ export const worktreeGitDir: Effect.Effect<string, never, Host | Path.Path | Workspace> =
14
+ Effect.gen(function* () {
16
15
  const { root } = yield* Host
16
+ const path = yield* Path.Path
17
17
  const workspace = yield* Workspace
18
18
  const content = yield* workspace
19
19
  .read(".git")
@@ -21,9 +21,8 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
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()
24
- return isAbsolute(gitdir) ? gitdir : join(root, gitdir)
25
- },
26
- )
24
+ return path.isAbsolute(gitdir) ? gitdir : path.join(root, gitdir)
25
+ })
27
26
 
28
27
  /**
29
28
  * The per-worktree loop log path `gtd next --json` reports as `log`.
@@ -34,17 +33,20 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
34
33
  * doesn't want that (e.g. the e2e suite) must scrub it before invoking gtd.
35
34
  * Advisory only — gtd neither creates nor truncates the file.
36
35
  */
37
- export const loopLogPath: Effect.Effect<string, never, Host | Workspace> = Effect.gen(function* () {
38
- const { root, env } = yield* Host
39
- const override = env.GTD_LOOP_LOG
40
- if (override !== undefined && override !== "") return override
36
+ export const loopLogPath: Effect.Effect<string, never, Host | Path.Path | Workspace> = Effect.gen(
37
+ function* () {
38
+ const { root, env } = yield* Host
39
+ const path = yield* Path.Path
40
+ const override = env.GTD_LOOP_LOG
41
+ if (override !== undefined && override !== "") return override
41
42
 
42
- const gitDirEnv = env.GIT_DIR
43
- if (gitDirEnv !== undefined && gitDirEnv !== "") {
44
- const gitDir = isAbsolute(gitDirEnv) ? gitDirEnv : join(root, gitDirEnv)
45
- return join(gitDir, "gtd-loop.log")
46
- }
43
+ const gitDirEnv = env.GIT_DIR
44
+ if (gitDirEnv !== undefined && gitDirEnv !== "") {
45
+ const gitDir = path.isAbsolute(gitDirEnv) ? gitDirEnv : path.join(root, gitDirEnv)
46
+ return path.join(gitDir, "gtd-loop.log")
47
+ }
47
48
 
48
- const gitDir = yield* worktreeGitDir
49
- return join(gitDir, "gtd-loop.log")
50
- })
49
+ const gitDir = yield* worktreeGitDir
50
+ return path.join(gitDir, "gtd-loop.log")
51
+ },
52
+ )
@@ -1,7 +1,8 @@
1
1
  /**
2
- * gtd's two ports onto its environment: `Workspace` (repo file content —
3
- * working-tree reads/writes plus the read-at-ref path) and `Host` (root,
4
- * home, env, scratch). `GitService` moved in alongside `Workspace` since
2
+ * gtd's ports onto its environment: `Workspace` (repo file content —
3
+ * working-tree reads/writes plus the read-at-ref path), `Host` (root,
4
+ * home, env, scratch, process identity, stdio) and `SyncIo`, the reads that
5
+ * must stay synchronous, which only `Node*.ts` implements against Node. `GitService` moved in alongside `Workspace` since
5
6
  * `committed` IS `git.readFileAtRef` — one module owns every read.
6
7
  *
7
8
  * Seam rule, stated once: a seam is a `Context.Service` unless the consuming
@@ -9,7 +10,14 @@
9
10
  * reads are that exception, and use the `*Sync` members.
10
11
  */
11
12
  export { Workspace, type WorkspaceOps } from "./Workspace.js"
12
- export { Host, type HostOps } from "./Host.js"
13
+ export {
14
+ Host,
15
+ nodeStdio,
16
+ scratchDirOf,
17
+ type HostOps,
18
+ type NodeProcess,
19
+ type Stdio,
20
+ } from "./Host.js"
13
21
  export {
14
22
  GitService,
15
23
  type GitOperations,
@@ -29,4 +37,9 @@ export {
29
37
  readRetainedHistory,
30
38
  restorability,
31
39
  } from "./RetainedHistory.js"
32
- export { livePinFile, runPinModule, type PinFile } from "./PinFile.js"
40
+ export { livePinFile, runPinModule } from "./NodePinFile.js"
41
+ export type { PinFile } from "./PinFile.js"
42
+ export type { FileInfo, SyncIo } from "./SyncIo.js"
43
+ export { nodeSyncIo } from "./NodeSync.js"
44
+ export { pureHash } from "./Sha.js"
45
+ export * as NodeRuntime from "./NodeRuntime.js"
@@ -3,12 +3,13 @@ import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js
3
3
 
4
4
  describe("the history format", () => {
5
5
  it("accepts a history in the current format and in the one before it", () => {
6
- expect(formatFault([{ hash: "a".repeat(40), format: 3 }])).toBeUndefined()
6
+ expect(formatFault([{ hash: "a".repeat(40), format: 4 }])).toBeUndefined()
7
7
  expect(
8
8
  formatFault([
9
9
  { hash: "a".repeat(40), format: 1 },
10
10
  { hash: "b".repeat(40), format: 2 },
11
11
  { hash: "c".repeat(40), format: 3 },
12
+ { hash: "d".repeat(40), format: 4 },
12
13
  ]),
13
14
  ).toBeUndefined()
14
15
  expect(formatFault([])).toBeUndefined()
@@ -17,11 +18,11 @@ describe("the history format", () => {
17
18
  it("names the first foreign commit, the found and readable formats, and what to do", () => {
18
19
  const fault = formatFault([
19
20
  { hash: "a".repeat(40), format: 1 },
20
- { hash: "b".repeat(40), format: 4 },
21
- { hash: "c".repeat(40), format: 5 },
21
+ { hash: "b".repeat(40), format: 5 },
22
+ { hash: "c".repeat(40), format: 6 },
22
23
  ])
23
24
  expect(fault).toBe(
24
- "gtd: commit bbbbbbb is written in history format 4, but this gtd reads formats 1 to 3 — run a gtd release that reads format 4 to continue this process",
25
+ "gtd: commit bbbbbbb is written in history format 5, but this gtd reads formats 1 to 4 — run a gtd release that reads format 5 to continue this process",
25
26
  )
26
27
  })
27
28
  })
@@ -36,8 +37,8 @@ describe("Gtd-Approved and the history format", () => {
36
37
  expect(message).toContain(
37
38
  'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
38
39
  )
39
- expect(message).toContain("Gtd-Format: 3")
40
- expect(parseCommitMessage(message).format).toBe(3)
40
+ expect(message).toContain("Gtd-Format: 4")
41
+ expect(parseCommitMessage(message).format).toBe(4)
41
42
  })
42
43
  })
43
44
 
@@ -45,20 +46,20 @@ describe("Gtd-Pin", () => {
45
46
  it("round-trips add and remove, after Gtd-Mark and before Gtd-Branch", () => {
46
47
  const message = formatCommitMessage({
47
48
  actor: "check",
48
- from: "unpin",
49
+ from: "gtd:unpin",
49
50
  to: "idle",
50
51
  pin: "remove",
51
52
  branch: "main",
52
53
  })
53
54
  expect(message).toBe(
54
- "gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 3",
55
+ "gtd(check): gtd:unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 4",
55
56
  )
56
57
  expect(parseCommitMessage(message).pin).toBe("remove")
57
- expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
58
+ expect(parseCommitMessage("gtd(check): gtd:pin → a\n\nGtd-Pin: add").pin).toBe("add")
58
59
  })
59
60
 
60
61
  it("reads an unknown value as no pin", () => {
61
- expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
62
+ expect(parseCommitMessage("gtd(check): gtd:pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
62
63
  })
63
64
  })
64
65
 
@@ -67,15 +68,17 @@ describe("Gtd-Anchor", () => {
67
68
  const anchor = "a".repeat(40)
68
69
  const message = formatCommitMessage({
69
70
  actor: "check",
70
- from: "pin",
71
+ from: "gtd:pin",
71
72
  to: "b",
72
73
  pin: "add",
73
74
  anchor,
74
75
  })
75
76
  expect(message).toBe(
76
- `gtd(check): pin → b\n\nGtd-Pin: add\nGtd-Anchor: ${anchor}\nGtd-Format: 3`,
77
+ `gtd(check): gtd:pin → b\n\nGtd-Pin: add\nGtd-Anchor: ${anchor}\nGtd-Format: 4`,
77
78
  )
78
79
  expect(parseCommitMessage(message).anchor).toBe(anchor)
79
- expect(parseCommitMessage("gtd(check): pin → b\n\nGtd-Anchor: HEAD~2").anchor).toBeUndefined()
80
+ expect(
81
+ parseCommitMessage("gtd(check): gtd:pin → b\n\nGtd-Anchor: HEAD~2").anchor,
82
+ ).toBeUndefined()
80
83
  })
81
84
  })
@@ -1,8 +1,10 @@
1
1
  // Bump only when what replay reads back out of git changes incompatibly — the
2
2
  // package version must not stand in for it, since most majors leave history
3
3
  // untouched. Format 2 adds pin commits, which format 1 would replay as turns;
4
- // format 3 adds the anchor, whose set-aside commits format 2 would replay.
5
- export const HISTORY_FORMAT = 3
4
+ // format 3 adds the anchor, whose set-aside commits format 2 would replay;
5
+ // format 4 renames the pin beats `gtd:pin`/`gtd:unpin`, which format 3 would
6
+ // read as steps.
7
+ export const HISTORY_FORMAT = 4
6
8
  const OLDEST_READABLE_FORMAT = 1
7
9
 
8
10
  const short = (hash: string): string => hash.slice(0, 7)