@pmelab/gtd 24.2.0 → 24.4.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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -6
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
  5. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  6. package/dist/flows.mjs +9 -1
  7. package/dist/gtd.bundle.mjs +1114 -865
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1135 -218
  10. package/dist/workflow-kit.mjs +9 -7
  11. package/dist/workflows/feature.mjs +152 -35
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +1 -1
  14. package/dist/workflows.mjs +223 -54
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +57 -0
  17. package/src/cli/Cli.ts +89 -9
  18. package/src/cli/History.test.ts +225 -0
  19. package/src/cli/History.ts +143 -0
  20. package/src/cli/Output.ts +68 -0
  21. package/src/cli/Program.test.ts +10 -9
  22. package/src/cli/Program.ts +24 -72
  23. package/src/edge/Edge.test.ts +261 -3
  24. package/src/edge/Edge.ts +218 -41
  25. package/src/edge/index.ts +1 -0
  26. package/src/emit/GitScript.test.ts +27 -0
  27. package/src/emit/GitScript.ts +18 -2
  28. package/src/emit/index.ts +2 -0
  29. package/src/flows/scripts.ts +6 -0
  30. package/src/platform/Git.test.ts +6 -9
  31. package/src/platform/Git.ts +48 -10
  32. package/src/platform/RetainedHistory.test.ts +14 -0
  33. package/src/platform/RetainedHistory.ts +5 -0
  34. package/src/platform/index.ts +7 -1
  35. package/src/replay/Trailers.test.ts +32 -0
  36. package/src/replay/Trailers.ts +17 -0
  37. package/src/steering/FORMATS.md +1 -1
  38. package/src/steering/SteeringFormat.test.ts +9 -1
  39. package/src/steering/SteeringFormat.ts +7 -0
  40. package/src/steering/contract.test.ts +3 -0
  41. package/src/steering/index.ts +1 -0
  42. package/src/steering/review.test.ts +30 -0
  43. package/src/steering/review.ts +8 -2
  44. package/src/step/LandStep.ts +3 -1
  45. package/src/step/RepoSnapshot.ts +7 -1
  46. package/src/step/planStart.test.ts +6 -3
  47. package/src/step/planStep.test.ts +29 -1
  48. package/src/step/planStep.ts +8 -0
  49. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  50. package/src/testkit/EmittedScriptRecognizer.ts +149 -52
  51. package/src/testkit/EmittedScripts.test.ts +166 -0
  52. package/src/testkit/FakeGitOperations.ts +6 -2
  53. package/src/testkit/FlowScripts.test.ts +242 -0
  54. package/src/testkit/FlowScripts.ts +326 -0
  55. package/src/testkit/GitTiers.ts +269 -47
  56. package/src/testkit/InMemRepo.ts +61 -8
  57. package/src/testkit/Layers.ts +10 -2
  58. package/src/testkit/TestRepo.ts +4 -1
  59. package/src/testkit/index.ts +1 -1
  60. package/src/workflow/discovery.test.ts +147 -0
  61. package/src/workflow/discovery.ts +5 -0
  62. package/src/workflow/fingerprint.test.ts +20 -4
  63. package/src/workflow/fingerprint.ts +8 -9
  64. package/src/workflow/index.ts +1 -6
  65. package/src/workflow/load.test.ts +5 -19
  66. package/src/workflow/load.ts +94 -10
  67. package/src/workflow-kit/review.test.ts +84 -0
  68. package/src/workflow-kit/review.ts +9 -6
  69. package/src/workflows/feature/review.test.ts +238 -12
  70. package/src/workflows/feature/review.ts +172 -31
  71. package/src/workflows/feature/steps.test.ts +12 -0
  72. package/src/workflows/feature/steps.ts +8 -1
  73. package/src/workflows/shared/access.test.ts +4 -2
  74. package/src/workflows/shared/access.ts +1 -0
  75. package/src/workflows/shared/options.test.ts +8 -0
  76. package/src/workflows/shared/options.ts +1 -0
  77. package/src/workflows/shared/steps.ts +2 -0
  78. package/src/workflows/shared/tail.ts +3 -11
  79. package/src/workflows/shared/text.test.ts +106 -0
  80. package/src/workflows/shared/text.ts +57 -1
  81. package/src/workflows/shared/vars.ts +1 -0
package/src/edge/Edge.ts CHANGED
@@ -1,17 +1,17 @@
1
- import { Effect } from "effect"
1
+ import { Effect, Option } from "effect"
2
2
  import {
3
3
  Narrator,
4
4
  GitService,
5
5
  Host,
6
6
  Workspace,
7
7
  type GitOperations,
8
+ type HostOps,
8
9
  type WorkspaceOps,
9
10
  } from "../platform/index.js"
10
11
  import {
11
12
  ConfigDiscovery,
12
13
  ConfigService,
13
14
  changedConfigFiles,
14
- configFingerprint,
15
15
  contentHash,
16
16
  multilineSetting,
17
17
  resolveVars,
@@ -42,6 +42,9 @@ import {
42
42
  import type { ScopeAccess } from "../flows/index.js"
43
43
  import { steeringFormatFor } from "../steering/index.js"
44
44
  import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
45
+ import { GTD_VERSION } from "../install/index.js"
46
+ import { processHistoryRef } from "../platform/index.js"
47
+ import { isAbsolute, relative, resolve, sep } from "node:path"
45
48
  import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
46
49
 
47
50
  export { UNATTRIBUTED_MODEL }
@@ -405,42 +408,59 @@ export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> =
405
408
  },
406
409
  )
407
410
 
408
- /** The process HEAD closes or sits inside — `gtd summary`'s run. */
409
- export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
410
- function* () {
411
- const git = yield* GitService
412
- const def = (yield* (yield* ConfigService).load).workflow
413
- // Cheap: just HEAD's own subject, to decide which of the two reads below
414
- // this run needs — never the commits before it. Its hash is also this
415
- // run's ONE resolution of literal HEAD: every further read below is
416
- // pinned to it, rather than re-resolving a `head` that could move.
417
- const headEntry = (yield* git.subjectHistory(1, 0))[0]
418
- if (headEntry === undefined) {
411
+ // `tip` is resolved to a hash once: every read below is pinned to it, so a
412
+ // moving HEAD cannot split one run across two commits.
413
+ const runAtTip = (
414
+ git: GitOperations,
415
+ def: WorkflowDefinition,
416
+ tip: { readonly hash: string; readonly subject: string } | undefined,
417
+ ): Effect.Effect<ProcessRun, Error> =>
418
+ Effect.gen(function* () {
419
+ if (tip === undefined) {
419
420
  // No commits at all — `historyUpTo` folds an empty repository to an
420
421
  // empty run on its own; there is no hash yet to pin anything to.
421
422
  const history = yield* historyUpTo(git, def, undefined)
422
423
  return yield* runOf(history, locateEpisode(def, history))
423
424
  }
424
- const closes = parseCommitMessage(headEntry.subject).parsed?.to === def.initial
425
+ const closes = parseCommitMessage(tip.subject).parsed?.to === def.initial
425
426
  if (!closes) {
426
- const history = yield* historyUpTo(git, def, headEntry.hash)
427
+ const history = yield* historyUpTo(git, def, tip.hash)
427
428
  return yield* runOf(history, locateEpisode(def, history))
428
429
  }
429
- // HEAD itself closes the process: its own subject would otherwise read as
430
- // a boundary to `findBoundaryBase` (landing the initial state), stopping
430
+ // The tip itself closes the process: its own subject would otherwise read
431
+ // as a boundary to `findBoundaryBase` (landing the initial state), stopping
431
432
  // the page one commit too soon. Search for the PRIOR episode's boundary
432
- // from HEAD's parent instead, then read the closing commit separately —
433
+ // from its parent instead, then read the closing commit separately —
433
434
  // `commitHistory` falls back to the whole history when that parent ref
434
- // doesn't resolve (HEAD is the repository's root commit).
435
- const parentRef = `${headEntry.hash}~1`
435
+ // doesn't resolve (the tip is the repository's root commit).
436
+ const parentRef = `${tip.hash}~1`
436
437
  const before = yield* historyUpTo(git, def, parentRef)
437
438
  const location = locateEpisode(def, before)
438
- let closing = yield* git.commitHistory(parentRef, headEntry.hash)
439
- if (closing.length === 0) closing = yield* git.commitHistory(undefined, headEntry.hash)
440
- return yield* runOf([...before, ...closing], { ...location }, headEntry.hash)
439
+ let closing = yield* git.commitHistory(parentRef, tip.hash)
440
+ if (closing.length === 0) closing = yield* git.commitHistory(undefined, tip.hash)
441
+ return yield* runOf([...before, ...closing], { ...location }, tip.hash)
442
+ })
443
+
444
+ /** The process HEAD closes or sits inside — `gtd summary`'s run. */
445
+ export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
446
+ function* () {
447
+ const git = yield* GitService
448
+ const def = (yield* (yield* ConfigService).load).workflow
449
+ // Cheap: just HEAD's own subject, never the commits before it.
450
+ return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
441
451
  },
442
452
  )
443
453
 
454
+ /** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
455
+ export const closedRunAt = (tip: string): Effect.Effect<ProcessRun, Error, ConfigRequirements> =>
456
+ Effect.gen(function* () {
457
+ const git = yield* GitService
458
+ const def = (yield* (yield* ConfigService).load).workflow
459
+ const hash = yield* git.resolveRef(tip)
460
+ const subject = yield* git.lastCommitSubject(hash)
461
+ return yield* runAtTip(git, def, { hash, subject })
462
+ })
463
+
444
464
  // ── Trees ───────────────────────────────────────────────────────────────────
445
465
 
446
466
  const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
@@ -852,17 +872,14 @@ const costByModel = (entries: readonly CostEntry[]): ModelCost[] => {
852
872
 
853
873
  export type RestRequirements = ConfigRequirements
854
874
 
855
- /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
856
- export const restAt = (
857
- ref: string | undefined,
875
+ const setupFor = (
876
+ config: ConfigOperations,
877
+ run: ProcessRun,
878
+ workspace: WorkspaceOps,
879
+ host: HostOps,
858
880
  startOverrides: Readonly<Record<string, string>> = {},
859
- ): Effect.Effect<Rest, Error, RestRequirements> =>
881
+ ) =>
860
882
  Effect.gen(function* () {
861
- const git = yield* GitService
862
- const config = yield* (yield* ConfigService).load
863
- const workspace = yield* Workspace
864
- const host = yield* Host
865
- const run = yield* computeProcessRun(git, config.workflow, ref)
866
883
  const resolved = yield* workflowOf(config, run)
867
884
  const def = resolved.def
868
885
  const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides)
@@ -904,6 +921,27 @@ export const restAt = (
904
921
  episode,
905
922
  pendingWorktree: () => (pendingMemo ??= workspace.worktreeSync()),
906
923
  }
924
+ return { resolved, def, vars, env, setup }
925
+ })
926
+
927
+ /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
928
+ export const restAt = (
929
+ ref: string | undefined,
930
+ startOverrides: Readonly<Record<string, string>> = {},
931
+ ): Effect.Effect<Rest, Error, RestRequirements> =>
932
+ Effect.gen(function* () {
933
+ const git = yield* GitService
934
+ const config = yield* (yield* ConfigService).load
935
+ const workspace = yield* Workspace
936
+ const host = yield* Host
937
+ const run = yield* computeProcessRun(git, config.workflow, ref)
938
+ const { resolved, def, vars, env, setup } = yield* setupFor(
939
+ config,
940
+ run,
941
+ workspace,
942
+ host,
943
+ startOverrides,
944
+ )
907
945
  // No `pending` tree: `restAt` resolves the LANDED process only, never a
908
946
  // pending working-tree edit (that's `decideLanding`'s own `replayFor`
909
947
  // call, below, deciding what landing right now WOULD do). `gtd lsp` caches
@@ -933,7 +971,7 @@ export const restAt = (
933
971
  }))
934
972
  : []
935
973
  const memory = memoryOf(outcome.trace, run)
936
- const configNow = configFingerprint(config.workflowFiles, host.root)
974
+ const configNow = config.fingerprint
937
975
  // Only the current rest warns, so a command must read it once — a second
938
976
  // read warns twice. An earlier ref (restore's tip) would warn about a
939
977
  // process the command is not acting on.
@@ -1121,15 +1159,26 @@ const initialStepRefusal = (
1121
1159
  }
1122
1160
  : undefined
1123
1161
 
1124
- const landingTo = (rest: Rest, to: StateName): Landing => {
1125
- if (rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state) {
1126
- return { kind: "noop", settled: true }
1127
- }
1162
+ const closingTrailers = (branch: Option.Option<string>) => ({
1163
+ version: GTD_VERSION,
1164
+ ...(Option.isSome(branch) ? { branch: branch.value } : {}),
1165
+ })
1166
+
1167
+ const isSettledNoop = (rest: Rest, to: StateName): boolean =>
1168
+ rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state
1169
+
1170
+ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): Landing => {
1171
+ if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
1128
1172
  // The landing that leaves the flow's first step starts the process: it
1129
1173
  // pins the process settings every later invocation reads back.
1130
- const starts = noProcessUnderway(rest) && to !== rest.def.initial
1174
+ const underway = !noProcessUnderway(rest)
1175
+ const starts = !underway && to !== rest.def.initial
1131
1176
  const refused = starts ? multilineRefusal(rest.vars) : undefined
1132
1177
  if (refused !== undefined) return refused
1178
+ // Landing on the first step while a process is underway ends it (flow
1179
+ // return and `restart()` alike); the script then points the history ref at
1180
+ // that very commit.
1181
+ const finishes = underway && to === rest.def.initial
1133
1182
  return {
1134
1183
  kind: "commit",
1135
1184
  to,
@@ -1139,7 +1188,9 @@ const landingTo = (rest: Rest, to: StateName): Landing => {
1139
1188
  to,
1140
1189
  step: rest.step.id,
1141
1190
  ...(starts ? { vars: rest.vars, config: rest.config } : {}),
1191
+ ...(finishes ? closingTrailers(branch) : {}),
1142
1192
  },
1193
+ ...(finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}),
1143
1194
  }
1144
1195
  }
1145
1196
 
@@ -1151,6 +1202,7 @@ const landingTo = (rest: Rest, to: StateName): Landing => {
1151
1202
  const decideLanding = (
1152
1203
  rest: Rest,
1153
1204
  verdicts: readonly JudgeVerdict[] | undefined,
1205
+ branch: Option.Option<string>,
1154
1206
  ): Effect.Effect<Landing, Error> =>
1155
1207
  Effect.gen(function* () {
1156
1208
  const early = cleanLanding(rest)
@@ -1171,12 +1223,12 @@ const decideLanding = (
1171
1223
  )
1172
1224
  const to = yield* landingTarget(outcome, rest)
1173
1225
  if (typeof to !== "string") return to
1174
- return landingTo(rest, to)
1226
+ return landingTo(rest, to, branch)
1175
1227
  })
1176
1228
 
1177
1229
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
1178
1230
  export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1179
- decideLanding(rest, undefined).pipe(
1231
+ decideLanding(rest, undefined, Option.none()).pipe(
1180
1232
  Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1181
1233
  Effect.catchAll(() => Effect.succeed(undefined)),
1182
1234
  )
@@ -1187,7 +1239,8 @@ export const snapshotFromRest = (
1187
1239
  verdicts?: readonly JudgeVerdict[],
1188
1240
  ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
1189
1241
  Effect.gen(function* () {
1190
- const landing = yield* decideLanding(rest, verdicts)
1242
+ const branch = yield* (yield* GitService).currentBranch()
1243
+ const landing = yield* decideLanding(rest, verdicts, branch)
1191
1244
  return {
1192
1245
  stepDef: rest.stepDef,
1193
1246
  state: rest.state,
@@ -1223,3 +1276,127 @@ export const summaryFor = (
1223
1276
  ...settingsFor(resolved, run.pinnedVars, host.env),
1224
1277
  })
1225
1278
  })
1279
+
1280
+ export interface HistoryStep {
1281
+ readonly hash: string
1282
+ readonly subject: string
1283
+ readonly actor: string
1284
+ readonly from: StateName | undefined
1285
+ readonly to: StateName
1286
+ readonly verdicts: readonly JudgeVerdict[]
1287
+ }
1288
+
1289
+ export interface HistoryTrail {
1290
+ readonly tip: string
1291
+ readonly steps: readonly HistoryStep[]
1292
+ readonly ended: "return" | "restart"
1293
+ readonly version: {
1294
+ readonly recorded: string | undefined
1295
+ readonly running: string
1296
+ }
1297
+ /** Set when recorded !== running (a missing recorded version counts as different). */
1298
+ readonly versionWarning?: string
1299
+ }
1300
+
1301
+ /** Repository-relative paths of the config files today's config and the process's own record name; one outside the repository cannot be read at a ref, so it is left out. */
1302
+ const inRepoConfigPaths = (
1303
+ root: string,
1304
+ files: readonly string[],
1305
+ recorded: ConfigFingerprint,
1306
+ ): readonly string[] => {
1307
+ const paths = new Set<string>(Object.keys(recorded))
1308
+ for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
1309
+ return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
1310
+ }
1311
+
1312
+ const configRefusal = (tip: string, paths: readonly string[]): Error =>
1313
+ new Error(
1314
+ `gtd history: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ` +
1315
+ `A history ref replays under today's config, so it is unreadable after the config changes`,
1316
+ )
1317
+
1318
+ const differingConfigPaths = (
1319
+ git: GitService["Type"],
1320
+ workspace: Workspace["Type"],
1321
+ root: string,
1322
+ config: { readonly fingerprint: ConfigFingerprint },
1323
+ run: ProcessRun,
1324
+ hash: string,
1325
+ ) =>
1326
+ Effect.gen(function* () {
1327
+ const differing: string[] = []
1328
+ for (const path of inRepoConfigPaths(
1329
+ root,
1330
+ Object.keys(config.fingerprint),
1331
+ run.pinnedConfig?.files ?? {},
1332
+ )) {
1333
+ const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
1334
+ if (atTip !== workspace.readSync(path)) differing.push(path)
1335
+ }
1336
+ return differing
1337
+ })
1338
+
1339
+ const notClosedError = (
1340
+ hash: string,
1341
+ outcome: Exclude<Awaited<ReturnType<typeof replayFor>>, { kind: "ended" }>,
1342
+ ): Error =>
1343
+ outcome.kind === "rest"
1344
+ ? new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`)
1345
+ : (replayError(outcome) ?? new Error("gtd history: replay failed"))
1346
+
1347
+ const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
1348
+ const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]))
1349
+ return run.trace.map((entry): HistoryStep => {
1350
+ const message = parseCommitMessage(messages.get(entry.hash) ?? "")
1351
+ return {
1352
+ hash: entry.hash,
1353
+ subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
1354
+ actor: entry.actor,
1355
+ from: message.parsed?.from,
1356
+ to: entry.state,
1357
+ verdicts: message.judge,
1358
+ }
1359
+ })
1360
+ }
1361
+
1362
+ const versionWarningFor = (recorded: string | undefined): string | undefined =>
1363
+ recorded === GTD_VERSION
1364
+ ? undefined
1365
+ : `gtd: warning: this history was recorded by ${recorded === undefined ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — a workflow imported from @pmelab/gtd/workflows may have changed since`
1366
+
1367
+ /**
1368
+ * Replays the closed run at `tip` under today's config. The workflow is not
1369
+ * fingerprinted for a version change: one imported from `@pmelab/gtd/workflows`
1370
+ * moves with every upgrade, and the version warning is the only signal.
1371
+ */
1372
+ export const historyTrail = (tip: string): Effect.Effect<HistoryTrail, Error, RestRequirements> =>
1373
+ Effect.gen(function* () {
1374
+ const git = yield* GitService
1375
+ const config = yield* (yield* ConfigService).load
1376
+ const workspace = yield* Workspace
1377
+ const host = yield* Host
1378
+ const hash = yield* git.resolveRef(tip)
1379
+ const run = yield* closedRunAt(hash)
1380
+
1381
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1382
+ if (differing.length > 0) return yield* Effect.fail(configRefusal(hash, differing))
1383
+
1384
+ const { setup } = yield* setupFor(config, run, workspace, host)
1385
+ yield* workspace.episodeTrees(
1386
+ run.episode.base,
1387
+ run.episode.commits.map((c) => c.hash),
1388
+ )
1389
+ const outcome = yield* Effect.promise(() => replayFor(setup))
1390
+ if (outcome.kind !== "ended") return yield* Effect.fail(notClosedError(hash, outcome))
1391
+
1392
+ const steps = historySteps(run)
1393
+ const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version
1394
+ const versionWarning = versionWarningFor(recorded)
1395
+ return {
1396
+ tip: hash,
1397
+ steps,
1398
+ ended: outcome.via,
1399
+ version: { recorded, running: GTD_VERSION },
1400
+ ...(versionWarning !== undefined ? { versionWarning } : {}),
1401
+ }
1402
+ })
package/src/edge/index.ts CHANGED
@@ -13,4 +13,5 @@ export {
13
13
  previewLanding,
14
14
  snapshotFromRest,
15
15
  summaryFor,
16
+ historyTrail,
16
17
  } from "./Edge.js"
@@ -9,6 +9,7 @@ import {
9
9
  commitAsIs,
10
10
  deleteRef,
11
11
  discardPending,
12
+ fetchHistoryRefs,
12
13
  hardResetTo,
13
14
  mixedResetTo,
14
15
  pathspec,
@@ -258,3 +259,29 @@ describe("ScriptSurface.render", () => {
258
259
  expect(runBashCheckSyntax(script)).toBe(0)
259
260
  })
260
261
  })
262
+
263
+ describe("ScriptSurface.render — updateRef", () => {
264
+ const steps: readonly LandStep[] = [
265
+ { kind: "gitWrite", write: { kind: "commitAll", message: "gtd(human): building → done" } },
266
+ { kind: "gitWrite", write: { kind: "updateRef", ref: "refs/gtd/history/abc", target: "HEAD" } },
267
+ ]
268
+
269
+ it("renders git update-ref after the commit, under set -eu", () => {
270
+ const script = ScriptSurface.render(steps)
271
+ expect(script.startsWith("set -eu")).toBe(true)
272
+ expect(script).toContain("git update-ref 'refs/gtd/history/abc' 'HEAD'")
273
+ expect(script.indexOf("git commit")).toBeLessThan(script.indexOf("git update-ref"))
274
+ expect(runBashCheckSyntax(script)).toBe(0)
275
+ })
276
+ })
277
+
278
+ describe("fetchHistoryRefs", () => {
279
+ it("quotes the remote and uses a non-forcing history refspec", () => {
280
+ const script = fetchHistoryRefs("o'rigin")
281
+ expect(script).toContain("'o'\\''rigin'")
282
+ expect(script).toContain("refs/gtd/history/*:refs/gtd/history/*")
283
+ expect(script).not.toContain("+refs/")
284
+ expect(script).toContain("history refs arrived")
285
+ expect(runBashCheckSyntax(script)).toBe(0)
286
+ })
287
+ })
@@ -24,8 +24,9 @@ const commitAllowEmpty = (message: string): string => {
24
24
  ].join("\n")
25
25
  }
26
26
 
27
- /** `git add -A` then `commitAllowEmpty`, joined with `&&` so a failing `git add` never reaches the commit. */
28
- export const commitAll = (message: string): string => `git add -A &&\n${commitAllowEmpty(message)}`
27
+ /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
28
+ export const commitAll = (message: string): string =>
29
+ `git add -A || exit 1\n${commitAllowEmpty(message)}`
29
30
 
30
31
  /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
31
32
  export const commitAsIs = (message: string): string => commitAllowEmpty(message)
@@ -52,6 +53,19 @@ export const updateRef = (ref: string, hash: string): string =>
52
53
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
53
54
  export const deleteRef = (ref: string): string => `git update-ref -d ${shellQuote(ref)}`
54
55
 
56
+ /**
57
+ * No `+` on the refspec: a local ref the remote's tip does not descend from
58
+ * fails the fetch instead of being overwritten (a fast-forward still applies).
59
+ */
60
+ export const fetchHistoryRefs = (remote: string): string =>
61
+ [
62
+ `set -eu`,
63
+ `before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
64
+ `git fetch ${shellQuote(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
65
+ `after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
66
+ `printf '%s history refs arrived\\n' "$((after - before))"`,
67
+ ].join("\n")
68
+
55
69
  // ── ScriptSurface: LandStep[] → shell ────────────────────────────────────────
56
70
  //
57
71
  // The sole place `src/step/`'s `LandStep` data becomes runnable shell text.
@@ -84,6 +98,8 @@ const renderGitWrite = (write: GitWrite): string => {
84
98
  switch (write.kind) {
85
99
  case "commitAll":
86
100
  return commitAll(write.message)
101
+ case "updateRef":
102
+ return updateRef(write.ref, write.target)
87
103
  }
88
104
  }
89
105
 
package/src/emit/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  type EmitStep,
3
3
  fileExistsGuard,
4
+ withFileVar,
4
5
  commandForFile,
5
6
  binaryGuard,
6
7
  failurePromptWrapper,
@@ -20,6 +21,7 @@ export {
20
21
  discardPending,
21
22
  updateRef,
22
23
  deleteRef,
24
+ fetchHistoryRefs,
23
25
  type RunnableScript,
24
26
  ScriptSurface,
25
27
  } from "./GitScript.js"
@@ -118,3 +118,9 @@ export const moveScript = (from: string, to: string): string =>
118
118
  `mv -- ${quote(from)} ${quote(to)}`,
119
119
  "",
120
120
  ].join("\n")
121
+
122
+ /** Write `path` as committed at `ref` to `to`. */
123
+ export const showScript = (ref: string, path: string, to: string): string =>
124
+ ["#!/usr/bin/env sh", "set -e", `git show ${quote(`${ref}:${path}`)} > ${quote(to)}`, ""].join(
125
+ "\n",
126
+ )
@@ -39,12 +39,9 @@ describe("resolvedRefOrCorrupted", () => {
39
39
  })
40
40
  })
41
41
 
42
- /**
43
- * `GitScript.ts`'s bash builders, run through real `bash` against a real git
44
- * repo — the Live tier only, since the in-memory fake's root has no shell to
45
- * execute against. Asserts the same 9 writer postconditions as the contract
46
- * above, driven via the emitted script instead of the `GitOperations` port.
47
- */
48
- describe("GitScript", () => {
49
- runGitScriptContract()
50
- })
42
+ /** `GitScript.ts`'s builders as a driver runs them: real `sh` against the in-memory twin. */
43
+ for (const makeTier of gitTiers) {
44
+ describe(`GitScript [${makeTier().name}]`, () => {
45
+ runGitScriptContract(makeTier)
46
+ })
47
+ }
@@ -4,14 +4,22 @@ import { Host } from "./Host.js"
4
4
 
5
5
  export interface GitReaderOperations {
6
6
  readonly lastCommitSubject: (ref?: string) => Effect.Effect<string, Error>
7
+ /** Full commit message (subject + body) of `ref` (default HEAD) — keeps the body, unlike `lastCommitSubject`. */
8
+ readonly lastCommitMessage: (ref?: string) => Effect.Effect<string, Error>
9
+ /** The checked-out branch's name; `Option.none` on a detached HEAD (never fails). */
10
+ readonly currentBranch: () => Effect.Effect<Option.Option<string>, Error>
7
11
  /**
8
- * Full commit message (subject + body) of HEAD — keeps the body, unlike
9
- * `lastCommitSubject`. No current caller: it read back a `Gtd-History:`
10
- * trailer for the now-removed squash finale's `restorability` check; kept
11
- * deliberately rather than deleted, as a plain, reusable git primitive a
12
- * future edge concern may need again.
12
+ * Refs under `prefix`, newest tip committer date first (`date` is ISO 8601
13
+ * strict); `[]` when none.
13
14
  */
14
- readonly lastCommitMessage: () => Effect.Effect<string, Error>
15
+ readonly listRefs: (prefix: string) => Effect.Effect<
16
+ ReadonlyArray<{
17
+ readonly ref: string
18
+ readonly hash: string
19
+ readonly date: string
20
+ }>,
21
+ Error
22
+ >
15
23
  readonly hasCommits: () => Effect.Effect<boolean, Error>
16
24
  readonly resolveRef: (ref: string) => Effect.Effect<string, Error>
17
25
  /** The ref's hash if it resolves, `Option.none` if it doesn't exist (never fails). Used to read the retained-history ref. */
@@ -331,10 +339,40 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
331
339
 
332
340
  return {
333
341
  lastCommitSubject: (ref = "HEAD") =>
334
- exec("git", "log", "-1", "--pretty=%s", ref).pipe(Effect.map((s) => s.trim())),
342
+ exec("git", "log", "-1", "--pretty=%s", "--end-of-options", ref).pipe(
343
+ Effect.map((s) => s.trim()),
344
+ ),
335
345
 
336
- lastCommitMessage: () =>
337
- exec("git", "log", "-1", "--pretty=%B").pipe(Effect.map((s) => s.trim())),
346
+ lastCommitMessage: (ref = "HEAD") =>
347
+ exec("git", "log", "-1", "--pretty=%B", "--end-of-options", ref).pipe(
348
+ Effect.map((s) => s.trim()),
349
+ ),
350
+
351
+ currentBranch: () =>
352
+ exec("git", "symbolic-ref", "--quiet", "--short", "HEAD").pipe(
353
+ Effect.map((s) => s.trim()),
354
+ Effect.map((name) => Option.some(name)),
355
+ Effect.catchAll(() => Effect.succeed(Option.none<string>())),
356
+ ),
357
+
358
+ listRefs: (prefix: string) =>
359
+ exec(
360
+ "git",
361
+ "for-each-ref",
362
+ "--sort=-committerdate",
363
+ "--format=%(refname)%09%(objectname)%09%(committerdate:iso-strict)",
364
+ prefix,
365
+ ).pipe(
366
+ Effect.map((out) =>
367
+ out
368
+ .split("\n")
369
+ .filter((line) => line.length > 0)
370
+ .map((line) => {
371
+ const [ref = "", hash = "", date = ""] = line.split("\t")
372
+ return { ref, hash, date }
373
+ }),
374
+ ),
375
+ ),
338
376
 
339
377
  hasCommits: () =>
340
378
  exec("git", "rev-parse", "--verify", "HEAD").pipe(
@@ -382,7 +420,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
382
420
  }),
383
421
 
384
422
  resolveRef: (ref: string) =>
385
- exec("git", "rev-parse", "--verify", ref).pipe(
423
+ exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(
386
424
  Effect.map((s) => s.trim()),
387
425
  Effect.flatMap((hash) => resolvedRefOrCorrupted(ref, hash)),
388
426
  ),
@@ -4,6 +4,8 @@ import { strictGitOperations as stubGit } from "../testkit/index.js"
4
4
  import {
5
5
  clearRetainedHistory,
6
6
  HISTORY_REF,
7
+ PROCESS_HISTORY_PREFIX,
8
+ processHistoryRef,
7
9
  readRetainedHistory,
8
10
  restorability,
9
11
  } from "./RetainedHistory.js"
@@ -63,3 +65,15 @@ describe("clearRetainedHistory", () => {
63
65
  expect(deleteRef).toHaveBeenCalledWith(HISTORY_REF)
64
66
  })
65
67
  })
68
+
69
+ describe("processHistoryRef", () => {
70
+ it("is refs/gtd/history/<hash>, under PROCESS_HISTORY_PREFIX", () => {
71
+ expect(PROCESS_HISTORY_PREFIX).toBe("refs/gtd/history/")
72
+ expect(processHistoryRef("abc123")).toBe("refs/gtd/history/abc123")
73
+ })
74
+
75
+ it("never equals the per-worktree HISTORY_REF", () => {
76
+ expect(processHistoryRef("abc123")).not.toBe(HISTORY_REF)
77
+ expect(HISTORY_REF.startsWith(PROCESS_HISTORY_PREFIX)).toBe(false)
78
+ })
79
+ })
@@ -4,6 +4,11 @@ import type { GitOperations } from "./Git.js"
4
4
  /** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
5
5
  export const HISTORY_REF = "refs/worktree/gtd/history"
6
6
 
7
+ /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
8
+ export const PROCESS_HISTORY_PREFIX = "refs/gtd/history/"
9
+
10
+ export const processHistoryRef = (hash: string): string => `${PROCESS_HISTORY_PREFIX}${hash}`
11
+
7
12
  export const readRetainedHistory = (
8
13
  git: GitOperations,
9
14
  ): Effect.Effect<Option.Option<string>, Error> => git.readRefOption(HISTORY_REF)
@@ -19,4 +19,10 @@ export {
19
19
  export { Narrator, GtdError, GtdUsageError, renderFailure } from "./Commentary.js"
20
20
  export { CommandRunner } from "./CommandRunner.js"
21
21
  export { loopLogPath } from "./WorktreeState.js"
22
- export { HISTORY_REF, readRetainedHistory, restorability } from "./RetainedHistory.js"
22
+ export {
23
+ HISTORY_REF,
24
+ processHistoryRef,
25
+ PROCESS_HISTORY_PREFIX,
26
+ readRetainedHistory,
27
+ restorability,
28
+ } from "./RetainedHistory.js"