@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +41 -6
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +1114 -865
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1135 -218
- package/dist/workflow-kit.mjs +9 -7
- package/dist/workflows/feature.mjs +152 -35
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +223 -54
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +10 -9
- package/src/cli/Program.ts +24 -72
- package/src/edge/Edge.test.ts +261 -3
- package/src/edge/Edge.ts +218 -41
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +2 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +149 -52
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +269 -47
- package/src/testkit/InMemRepo.ts +61 -8
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflows/feature/review.test.ts +238 -12
- package/src/workflows/feature/review.ts +172 -31
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/tail.ts +3 -11
- package/src/workflows/shared/text.test.ts +106 -0
- package/src/workflows/shared/text.ts +57 -1
- 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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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(
|
|
425
|
+
const closes = parseCommitMessage(tip.subject).parsed?.to === def.initial
|
|
425
426
|
if (!closes) {
|
|
426
|
-
const history = yield* historyUpTo(git, def,
|
|
427
|
+
const history = yield* historyUpTo(git, def, tip.hash)
|
|
427
428
|
return yield* runOf(history, locateEpisode(def, history))
|
|
428
429
|
}
|
|
429
|
-
//
|
|
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
|
|
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 (
|
|
435
|
-
const parentRef = `${
|
|
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,
|
|
439
|
-
if (closing.length === 0) closing = yield* git.commitHistory(undefined,
|
|
440
|
-
return yield* runOf([...before, ...closing], { ...location },
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
875
|
+
const setupFor = (
|
|
876
|
+
config: ConfigOperations,
|
|
877
|
+
run: ProcessRun,
|
|
878
|
+
workspace: WorkspaceOps,
|
|
879
|
+
host: HostOps,
|
|
858
880
|
startOverrides: Readonly<Record<string, string>> = {},
|
|
859
|
-
)
|
|
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 =
|
|
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
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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
|
+
})
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -24,8 +24,9 @@ const commitAllowEmpty = (message: string): string => {
|
|
|
24
24
|
].join("\n")
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/** `git add -A
|
|
28
|
-
export const commitAll = (message: string): string =>
|
|
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"
|
package/src/flows/scripts.ts
CHANGED
|
@@ -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
|
+
)
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -39,12 +39,9 @@ describe("resolvedRefOrCorrupted", () => {
|
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|
package/src/platform/Git.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
* `
|
|
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
|
|
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(
|
|
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(
|
|
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)
|
package/src/platform/index.ts
CHANGED
|
@@ -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 {
|
|
22
|
+
export {
|
|
23
|
+
HISTORY_REF,
|
|
24
|
+
processHistoryRef,
|
|
25
|
+
PROCESS_HISTORY_PREFIX,
|
|
26
|
+
readRetainedHistory,
|
|
27
|
+
restorability,
|
|
28
|
+
} from "./RetainedHistory.js"
|