@pmelab/gtd 24.1.0 → 24.3.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 +37 -10
- package/claude/hooks/access.ts +25 -0
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
- package/dist/gtd.bundle.mjs +337 -55
- package/dist/testkit.mjs +477 -65
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- 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 +7 -1
- package/src/cli/Program.ts +23 -64
- package/src/edge/Edge.test.ts +256 -1
- package/src/edge/Edge.ts +217 -39
- 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 +1 -0
- 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/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.ts +1 -1
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/GitTiers.ts +133 -2
- package/src/testkit/InMemRepo.ts +26 -6
package/src/edge/Edge.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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 {
|
|
@@ -42,6 +43,9 @@ import {
|
|
|
42
43
|
import type { ScopeAccess } from "../flows/index.js"
|
|
43
44
|
import { steeringFormatFor } from "../steering/index.js"
|
|
44
45
|
import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
|
|
46
|
+
import { GTD_VERSION } from "../install/index.js"
|
|
47
|
+
import { processHistoryRef } from "../platform/index.js"
|
|
48
|
+
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
45
49
|
import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
|
|
46
50
|
|
|
47
51
|
export { UNATTRIBUTED_MODEL }
|
|
@@ -405,42 +409,59 @@ export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> =
|
|
|
405
409
|
},
|
|
406
410
|
)
|
|
407
411
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const headEntry = (yield* git.subjectHistory(1, 0))[0]
|
|
418
|
-
if (headEntry === undefined) {
|
|
412
|
+
// `tip` is resolved to a hash once: every read below is pinned to it, so a
|
|
413
|
+
// moving HEAD cannot split one run across two commits.
|
|
414
|
+
const runAtTip = (
|
|
415
|
+
git: GitOperations,
|
|
416
|
+
def: WorkflowDefinition,
|
|
417
|
+
tip: { readonly hash: string; readonly subject: string } | undefined,
|
|
418
|
+
): Effect.Effect<ProcessRun, Error> =>
|
|
419
|
+
Effect.gen(function* () {
|
|
420
|
+
if (tip === undefined) {
|
|
419
421
|
// No commits at all — `historyUpTo` folds an empty repository to an
|
|
420
422
|
// empty run on its own; there is no hash yet to pin anything to.
|
|
421
423
|
const history = yield* historyUpTo(git, def, undefined)
|
|
422
424
|
return yield* runOf(history, locateEpisode(def, history))
|
|
423
425
|
}
|
|
424
|
-
const closes = parseCommitMessage(
|
|
426
|
+
const closes = parseCommitMessage(tip.subject).parsed?.to === def.initial
|
|
425
427
|
if (!closes) {
|
|
426
|
-
const history = yield* historyUpTo(git, def,
|
|
428
|
+
const history = yield* historyUpTo(git, def, tip.hash)
|
|
427
429
|
return yield* runOf(history, locateEpisode(def, history))
|
|
428
430
|
}
|
|
429
|
-
//
|
|
430
|
-
// a boundary to `findBoundaryBase` (landing the initial state), stopping
|
|
431
|
+
// The tip itself closes the process: its own subject would otherwise read
|
|
432
|
+
// as a boundary to `findBoundaryBase` (landing the initial state), stopping
|
|
431
433
|
// the page one commit too soon. Search for the PRIOR episode's boundary
|
|
432
|
-
// from
|
|
434
|
+
// from its parent instead, then read the closing commit separately —
|
|
433
435
|
// `commitHistory` falls back to the whole history when that parent ref
|
|
434
|
-
// doesn't resolve (
|
|
435
|
-
const parentRef = `${
|
|
436
|
+
// doesn't resolve (the tip is the repository's root commit).
|
|
437
|
+
const parentRef = `${tip.hash}~1`
|
|
436
438
|
const before = yield* historyUpTo(git, def, parentRef)
|
|
437
439
|
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 },
|
|
440
|
+
let closing = yield* git.commitHistory(parentRef, tip.hash)
|
|
441
|
+
if (closing.length === 0) closing = yield* git.commitHistory(undefined, tip.hash)
|
|
442
|
+
return yield* runOf([...before, ...closing], { ...location }, tip.hash)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
/** The process HEAD closes or sits inside — `gtd summary`'s run. */
|
|
446
|
+
export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
447
|
+
function* () {
|
|
448
|
+
const git = yield* GitService
|
|
449
|
+
const def = (yield* (yield* ConfigService).load).workflow
|
|
450
|
+
// Cheap: just HEAD's own subject, never the commits before it.
|
|
451
|
+
return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
|
|
441
452
|
},
|
|
442
453
|
)
|
|
443
454
|
|
|
455
|
+
/** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
|
|
456
|
+
export const closedRunAt = (tip: string): Effect.Effect<ProcessRun, Error, ConfigRequirements> =>
|
|
457
|
+
Effect.gen(function* () {
|
|
458
|
+
const git = yield* GitService
|
|
459
|
+
const def = (yield* (yield* ConfigService).load).workflow
|
|
460
|
+
const hash = yield* git.resolveRef(tip)
|
|
461
|
+
const subject = yield* git.lastCommitSubject(hash)
|
|
462
|
+
return yield* runAtTip(git, def, { hash, subject })
|
|
463
|
+
})
|
|
464
|
+
|
|
444
465
|
// ── Trees ───────────────────────────────────────────────────────────────────
|
|
445
466
|
|
|
446
467
|
const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
|
|
@@ -852,17 +873,14 @@ const costByModel = (entries: readonly CostEntry[]): ModelCost[] => {
|
|
|
852
873
|
|
|
853
874
|
export type RestRequirements = ConfigRequirements
|
|
854
875
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
876
|
+
const setupFor = (
|
|
877
|
+
config: ConfigOperations,
|
|
878
|
+
run: ProcessRun,
|
|
879
|
+
workspace: WorkspaceOps,
|
|
880
|
+
host: HostOps,
|
|
858
881
|
startOverrides: Readonly<Record<string, string>> = {},
|
|
859
|
-
)
|
|
882
|
+
) =>
|
|
860
883
|
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
884
|
const resolved = yield* workflowOf(config, run)
|
|
867
885
|
const def = resolved.def
|
|
868
886
|
const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides)
|
|
@@ -904,6 +922,27 @@ export const restAt = (
|
|
|
904
922
|
episode,
|
|
905
923
|
pendingWorktree: () => (pendingMemo ??= workspace.worktreeSync()),
|
|
906
924
|
}
|
|
925
|
+
return { resolved, def, vars, env, setup }
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
929
|
+
export const restAt = (
|
|
930
|
+
ref: string | undefined,
|
|
931
|
+
startOverrides: Readonly<Record<string, string>> = {},
|
|
932
|
+
): Effect.Effect<Rest, Error, RestRequirements> =>
|
|
933
|
+
Effect.gen(function* () {
|
|
934
|
+
const git = yield* GitService
|
|
935
|
+
const config = yield* (yield* ConfigService).load
|
|
936
|
+
const workspace = yield* Workspace
|
|
937
|
+
const host = yield* Host
|
|
938
|
+
const run = yield* computeProcessRun(git, config.workflow, ref)
|
|
939
|
+
const { resolved, def, vars, env, setup } = yield* setupFor(
|
|
940
|
+
config,
|
|
941
|
+
run,
|
|
942
|
+
workspace,
|
|
943
|
+
host,
|
|
944
|
+
startOverrides,
|
|
945
|
+
)
|
|
907
946
|
// No `pending` tree: `restAt` resolves the LANDED process only, never a
|
|
908
947
|
// pending working-tree edit (that's `decideLanding`'s own `replayFor`
|
|
909
948
|
// call, below, deciding what landing right now WOULD do). `gtd lsp` caches
|
|
@@ -1121,15 +1160,26 @@ const initialStepRefusal = (
|
|
|
1121
1160
|
}
|
|
1122
1161
|
: undefined
|
|
1123
1162
|
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1163
|
+
const closingTrailers = (branch: Option.Option<string>) => ({
|
|
1164
|
+
version: GTD_VERSION,
|
|
1165
|
+
...(Option.isSome(branch) ? { branch: branch.value } : {}),
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
const isSettledNoop = (rest: Rest, to: StateName): boolean =>
|
|
1169
|
+
rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state
|
|
1170
|
+
|
|
1171
|
+
const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): Landing => {
|
|
1172
|
+
if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
|
|
1128
1173
|
// The landing that leaves the flow's first step starts the process: it
|
|
1129
1174
|
// pins the process settings every later invocation reads back.
|
|
1130
|
-
const
|
|
1175
|
+
const underway = !noProcessUnderway(rest)
|
|
1176
|
+
const starts = !underway && to !== rest.def.initial
|
|
1131
1177
|
const refused = starts ? multilineRefusal(rest.vars) : undefined
|
|
1132
1178
|
if (refused !== undefined) return refused
|
|
1179
|
+
// Landing on the first step while a process is underway ends it (flow
|
|
1180
|
+
// return and `restart()` alike); the script then points the history ref at
|
|
1181
|
+
// that very commit.
|
|
1182
|
+
const finishes = underway && to === rest.def.initial
|
|
1133
1183
|
return {
|
|
1134
1184
|
kind: "commit",
|
|
1135
1185
|
to,
|
|
@@ -1139,7 +1189,9 @@ const landingTo = (rest: Rest, to: StateName): Landing => {
|
|
|
1139
1189
|
to,
|
|
1140
1190
|
step: rest.step.id,
|
|
1141
1191
|
...(starts ? { vars: rest.vars, config: rest.config } : {}),
|
|
1192
|
+
...(finishes ? closingTrailers(branch) : {}),
|
|
1142
1193
|
},
|
|
1194
|
+
...(finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}),
|
|
1143
1195
|
}
|
|
1144
1196
|
}
|
|
1145
1197
|
|
|
@@ -1151,6 +1203,7 @@ const landingTo = (rest: Rest, to: StateName): Landing => {
|
|
|
1151
1203
|
const decideLanding = (
|
|
1152
1204
|
rest: Rest,
|
|
1153
1205
|
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1206
|
+
branch: Option.Option<string>,
|
|
1154
1207
|
): Effect.Effect<Landing, Error> =>
|
|
1155
1208
|
Effect.gen(function* () {
|
|
1156
1209
|
const early = cleanLanding(rest)
|
|
@@ -1171,12 +1224,12 @@ const decideLanding = (
|
|
|
1171
1224
|
)
|
|
1172
1225
|
const to = yield* landingTarget(outcome, rest)
|
|
1173
1226
|
if (typeof to !== "string") return to
|
|
1174
|
-
return landingTo(rest, to)
|
|
1227
|
+
return landingTo(rest, to, branch)
|
|
1175
1228
|
})
|
|
1176
1229
|
|
|
1177
1230
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
1178
1231
|
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1179
|
-
decideLanding(rest, undefined).pipe(
|
|
1232
|
+
decideLanding(rest, undefined, Option.none()).pipe(
|
|
1180
1233
|
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1181
1234
|
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
1182
1235
|
)
|
|
@@ -1187,7 +1240,8 @@ export const snapshotFromRest = (
|
|
|
1187
1240
|
verdicts?: readonly JudgeVerdict[],
|
|
1188
1241
|
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
|
|
1189
1242
|
Effect.gen(function* () {
|
|
1190
|
-
const
|
|
1243
|
+
const branch = yield* (yield* GitService).currentBranch()
|
|
1244
|
+
const landing = yield* decideLanding(rest, verdicts, branch)
|
|
1191
1245
|
return {
|
|
1192
1246
|
stepDef: rest.stepDef,
|
|
1193
1247
|
state: rest.state,
|
|
@@ -1223,3 +1277,127 @@ export const summaryFor = (
|
|
|
1223
1277
|
...settingsFor(resolved, run.pinnedVars, host.env),
|
|
1224
1278
|
})
|
|
1225
1279
|
})
|
|
1280
|
+
|
|
1281
|
+
export interface HistoryStep {
|
|
1282
|
+
readonly hash: string
|
|
1283
|
+
readonly subject: string
|
|
1284
|
+
readonly actor: string
|
|
1285
|
+
readonly from: StateName | undefined
|
|
1286
|
+
readonly to: StateName
|
|
1287
|
+
readonly verdicts: readonly JudgeVerdict[]
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
export interface HistoryTrail {
|
|
1291
|
+
readonly tip: string
|
|
1292
|
+
readonly steps: readonly HistoryStep[]
|
|
1293
|
+
readonly ended: "return" | "restart"
|
|
1294
|
+
readonly version: {
|
|
1295
|
+
readonly recorded: string | undefined
|
|
1296
|
+
readonly running: string
|
|
1297
|
+
}
|
|
1298
|
+
/** Set when recorded !== running (a missing recorded version counts as different). */
|
|
1299
|
+
readonly versionWarning?: string
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** 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. */
|
|
1303
|
+
const inRepoConfigPaths = (
|
|
1304
|
+
root: string,
|
|
1305
|
+
files: readonly string[],
|
|
1306
|
+
recorded: ConfigFingerprint,
|
|
1307
|
+
): readonly string[] => {
|
|
1308
|
+
const paths = new Set<string>(Object.keys(recorded))
|
|
1309
|
+
for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
|
|
1310
|
+
return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const configRefusal = (tip: string, paths: readonly string[]): Error =>
|
|
1314
|
+
new Error(
|
|
1315
|
+
`gtd history: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ` +
|
|
1316
|
+
`A history ref replays under today's config, so it is unreadable after the config changes`,
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
const differingConfigPaths = (
|
|
1320
|
+
git: GitService["Type"],
|
|
1321
|
+
workspace: Workspace["Type"],
|
|
1322
|
+
root: string,
|
|
1323
|
+
config: { readonly workflowFiles: readonly string[] },
|
|
1324
|
+
run: ProcessRun,
|
|
1325
|
+
hash: string,
|
|
1326
|
+
) =>
|
|
1327
|
+
Effect.gen(function* () {
|
|
1328
|
+
const differing: string[] = []
|
|
1329
|
+
for (const path of inRepoConfigPaths(
|
|
1330
|
+
root,
|
|
1331
|
+
config.workflowFiles,
|
|
1332
|
+
run.pinnedConfig?.files ?? {},
|
|
1333
|
+
)) {
|
|
1334
|
+
const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
|
|
1335
|
+
if (atTip !== workspace.readSync(path)) differing.push(path)
|
|
1336
|
+
}
|
|
1337
|
+
return differing
|
|
1338
|
+
})
|
|
1339
|
+
|
|
1340
|
+
const notClosedError = (
|
|
1341
|
+
hash: string,
|
|
1342
|
+
outcome: Exclude<Awaited<ReturnType<typeof replayFor>>, { kind: "ended" }>,
|
|
1343
|
+
): Error =>
|
|
1344
|
+
outcome.kind === "rest"
|
|
1345
|
+
? new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`)
|
|
1346
|
+
: (replayError(outcome) ?? new Error("gtd history: replay failed"))
|
|
1347
|
+
|
|
1348
|
+
const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
|
|
1349
|
+
const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]))
|
|
1350
|
+
return run.trace.map((entry): HistoryStep => {
|
|
1351
|
+
const message = parseCommitMessage(messages.get(entry.hash) ?? "")
|
|
1352
|
+
return {
|
|
1353
|
+
hash: entry.hash,
|
|
1354
|
+
subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
|
|
1355
|
+
actor: entry.actor,
|
|
1356
|
+
from: message.parsed?.from,
|
|
1357
|
+
to: entry.state,
|
|
1358
|
+
verdicts: message.judge,
|
|
1359
|
+
}
|
|
1360
|
+
})
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const versionWarningFor = (recorded: string | undefined): string | undefined =>
|
|
1364
|
+
recorded === GTD_VERSION
|
|
1365
|
+
? undefined
|
|
1366
|
+
: `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`
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Replays the closed run at `tip` under today's config. The workflow is not
|
|
1370
|
+
* fingerprinted for a version change: one imported from `@pmelab/gtd/workflows`
|
|
1371
|
+
* moves with every upgrade, and the version warning is the only signal.
|
|
1372
|
+
*/
|
|
1373
|
+
export const historyTrail = (tip: string): Effect.Effect<HistoryTrail, Error, RestRequirements> =>
|
|
1374
|
+
Effect.gen(function* () {
|
|
1375
|
+
const git = yield* GitService
|
|
1376
|
+
const config = yield* (yield* ConfigService).load
|
|
1377
|
+
const workspace = yield* Workspace
|
|
1378
|
+
const host = yield* Host
|
|
1379
|
+
const hash = yield* git.resolveRef(tip)
|
|
1380
|
+
const run = yield* closedRunAt(hash)
|
|
1381
|
+
|
|
1382
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
|
|
1383
|
+
if (differing.length > 0) return yield* Effect.fail(configRefusal(hash, differing))
|
|
1384
|
+
|
|
1385
|
+
const { setup } = yield* setupFor(config, run, workspace, host)
|
|
1386
|
+
yield* workspace.episodeTrees(
|
|
1387
|
+
run.episode.base,
|
|
1388
|
+
run.episode.commits.map((c) => c.hash),
|
|
1389
|
+
)
|
|
1390
|
+
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1391
|
+
if (outcome.kind !== "ended") return yield* Effect.fail(notClosedError(hash, outcome))
|
|
1392
|
+
|
|
1393
|
+
const steps = historySteps(run)
|
|
1394
|
+
const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version
|
|
1395
|
+
const versionWarning = versionWarningFor(recorded)
|
|
1396
|
+
return {
|
|
1397
|
+
tip: hash,
|
|
1398
|
+
steps,
|
|
1399
|
+
ended: outcome.via,
|
|
1400
|
+
version: { recorded, running: GTD_VERSION },
|
|
1401
|
+
...(versionWarning !== undefined ? { versionWarning } : {}),
|
|
1402
|
+
}
|
|
1403
|
+
})
|
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
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"
|
|
@@ -92,6 +92,38 @@ describe("the commit-message codec", () => {
|
|
|
92
92
|
expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
+
it("round-trips Gtd-Branch and Gtd-Version, after Gtd-Judge and before Gtd-Format", () => {
|
|
96
|
+
const message = formatCommitMessage({
|
|
97
|
+
actor: "agent",
|
|
98
|
+
to: "idle",
|
|
99
|
+
judge: [{ id: "q1", answer: true, p: 0.9 }],
|
|
100
|
+
branch: "feat/x",
|
|
101
|
+
version: "1.2.3",
|
|
102
|
+
})
|
|
103
|
+
const lines = message.split("\n")
|
|
104
|
+
const at = (prefix: string) => lines.findIndex((l) => l.startsWith(prefix))
|
|
105
|
+
expect(at("Gtd-Judge:")).toBeLessThan(at("Gtd-Branch:"))
|
|
106
|
+
expect(at("Gtd-Branch:")).toBeLessThan(at("Gtd-Version:"))
|
|
107
|
+
expect(at("Gtd-Version:")).toBeLessThan(at("Gtd-Format:"))
|
|
108
|
+
const parsed = parseCommitMessage(message)
|
|
109
|
+
expect(parsed.branch).toBe("feat/x")
|
|
110
|
+
expect(parsed.version).toBe("1.2.3")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("omits Gtd-Branch and Gtd-Version when unset, and reads them as undefined", () => {
|
|
114
|
+
const message = formatCommitMessage({ actor: "agent", to: "idle" })
|
|
115
|
+
expect(message).not.toContain("Gtd-Branch")
|
|
116
|
+
expect(message).not.toContain("Gtd-Version")
|
|
117
|
+
const parsed = parseCommitMessage(message)
|
|
118
|
+
expect(parsed.branch).toBeUndefined()
|
|
119
|
+
expect(parsed.version).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("keeps Gtd-Format at 1 when branch and version are written", () => {
|
|
123
|
+
const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
|
|
124
|
+
expect(message.endsWith("Gtd-Format: 1")).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
95
127
|
it("writes Gtd-Format last on every message, even one with no other trailer", () => {
|
|
96
128
|
const message = formatCommitMessage({ actor: "agent", to: "building" })
|
|
97
129
|
expect(message).toBe("gtd(agent): building\n\nGtd-Format: 1")
|