@pmelab/gtd 24.2.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.
Files changed (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +26 -2
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
  5. package/dist/gtd.bundle.mjs +337 -55
  6. package/dist/testkit.mjs +477 -65
  7. package/dist/workflows/feature.mjs +1 -1
  8. package/dist/workflows/fix.mjs +1 -1
  9. package/dist/workflows/review.mjs +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/Cli.test.ts +57 -0
  12. package/src/cli/Cli.ts +89 -9
  13. package/src/cli/History.test.ts +225 -0
  14. package/src/cli/History.ts +143 -0
  15. package/src/cli/Output.ts +68 -0
  16. package/src/cli/Program.test.ts +7 -1
  17. package/src/cli/Program.ts +23 -64
  18. package/src/edge/Edge.test.ts +256 -1
  19. package/src/edge/Edge.ts +217 -39
  20. package/src/edge/index.ts +1 -0
  21. package/src/emit/GitScript.test.ts +27 -0
  22. package/src/emit/GitScript.ts +18 -2
  23. package/src/emit/index.ts +1 -0
  24. package/src/platform/Git.ts +48 -10
  25. package/src/platform/RetainedHistory.test.ts +14 -0
  26. package/src/platform/RetainedHistory.ts +5 -0
  27. package/src/platform/index.ts +7 -1
  28. package/src/replay/Trailers.test.ts +32 -0
  29. package/src/replay/Trailers.ts +17 -0
  30. package/src/step/LandStep.ts +3 -1
  31. package/src/step/RepoSnapshot.ts +7 -1
  32. package/src/step/planStart.test.ts +6 -3
  33. package/src/step/planStep.test.ts +29 -1
  34. package/src/step/planStep.ts +8 -0
  35. package/src/testkit/EmittedScriptRecognizer.ts +1 -1
  36. package/src/testkit/FakeGitOperations.ts +6 -2
  37. package/src/testkit/GitTiers.ts +133 -2
  38. package/src/testkit/InMemRepo.ts +26 -6
@@ -72,6 +72,9 @@ export interface CommitMessage {
72
72
  readonly config: Readonly<Record<string, string>>
73
73
  readonly reviewBase: string | undefined
74
74
  readonly cost: readonly CostEntry[]
75
+ /** Written only on a process's finishing landing. */
76
+ readonly branch: string | undefined
77
+ readonly version: string | undefined
75
78
  readonly format: number
76
79
  }
77
80
 
@@ -116,6 +119,8 @@ interface Trailers {
116
119
  step: StepId | undefined
117
120
  workflow: string | undefined
118
121
  reviewBase: string | undefined
122
+ branch: string | undefined
123
+ version: string | undefined
119
124
  readonly judge: JudgeVerdict[]
120
125
  readonly vars: Record<string, string>
121
126
  readonly config: Record<string, string>
@@ -146,6 +151,12 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
146
151
  "Gtd-Review-Base": (value, into) => {
147
152
  into.reviewBase ??= value.split(/\s/)[0]
148
153
  },
154
+ "Gtd-Branch": (value, into) => {
155
+ if (value !== "") into.branch ??= value
156
+ },
157
+ "Gtd-Version": (value, into) => {
158
+ if (value !== "") into.version ??= value
159
+ },
149
160
  "Gtd-Format": (value, into) => {
150
161
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
151
162
  },
@@ -162,6 +173,8 @@ export const parseCommitMessage = (message: string): CommitMessage => {
162
173
  step: undefined,
163
174
  workflow: undefined,
164
175
  reviewBase: undefined,
176
+ branch: undefined,
177
+ version: undefined,
165
178
  judge: [],
166
179
  vars: {},
167
180
  config: {},
@@ -187,6 +200,8 @@ export interface CommitSpec {
187
200
  readonly config?: Readonly<Record<string, string>>
188
201
  readonly cost?: { readonly cost: number; readonly model?: string }
189
202
  readonly judge?: readonly JudgeVerdict[]
203
+ readonly branch?: string
204
+ readonly version?: string
190
205
  }
191
206
 
192
207
  const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string, string][] =>
@@ -207,6 +222,8 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
207
222
  )
208
223
  }
209
224
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
225
+ if (spec.branch !== undefined) lines.push(`Gtd-Branch: ${spec.branch}`)
226
+ if (spec.version !== undefined) lines.push(`Gtd-Version: ${spec.version}`)
210
227
  lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
211
228
  const subject = formatSubject(spec.actor, spec.to, spec.from)
212
229
  return `${subject}\n\n${lines.join("\n")}`
@@ -6,7 +6,9 @@ import type { StateName } from "../workflow/index.js"
6
6
  * that module, which is what makes writing a live git call inside the core
7
7
  * impossible rather than merely discouraged.
8
8
  */
9
- export type GitWrite = { readonly kind: "commitAll"; readonly message: string }
9
+ export type GitWrite =
10
+ | { readonly kind: "commitAll"; readonly message: string }
11
+ | { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
10
12
 
11
13
  /** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
12
14
  export type Outcome =
@@ -11,7 +11,13 @@ export type Landing =
11
11
  | { readonly kind: "noop"; readonly settled: boolean }
12
12
  /** An empty agent turn: recorded, but the process stays at the step. */
13
13
  | { readonly kind: "attempt"; readonly subject: string }
14
- | { readonly kind: "commit"; readonly to: StateName; readonly spec: CommitSpec }
14
+ | {
15
+ readonly kind: "commit"
16
+ readonly to: StateName
17
+ readonly spec: CommitSpec
18
+ /** The full history ref a finishing landing points at its own commit. */
19
+ readonly keepHistory?: string
20
+ }
15
21
 
16
22
  /**
17
23
  * Everything `planStep` needs, already resolved
@@ -108,7 +108,8 @@ describe("planStart", () => {
108
108
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
109
109
  if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
110
110
  const write = plan.steps.find((s) => s.kind === "gitWrite")
111
- if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
111
+ if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
112
+ throw new Error("expected a gitWrite step")
112
113
  expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
113
114
  })
114
115
 
@@ -124,7 +125,8 @@ describe("planStart", () => {
124
125
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
125
126
  if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
126
127
  const write = plan.steps.find((s) => s.kind === "gitWrite")
127
- if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
128
+ if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
129
+ throw new Error("expected a gitWrite step")
128
130
  expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
129
131
  })
130
132
 
@@ -189,7 +191,8 @@ describe("planStart", () => {
189
191
  })
190
192
  if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
191
193
  const write = plan.steps.find((s) => s.kind === "gitWrite")
192
- if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
194
+ if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
195
+ throw new Error("expected a gitWrite step")
193
196
  expect(write.write.message).toBe(
194
197
  "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 1",
195
198
  )
@@ -92,7 +92,8 @@ describe("planStep — commit", () => {
92
92
  })
93
93
  if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
94
94
  const write = outcome.steps.find((step) => step.kind === "gitWrite")
95
- if (write?.kind !== "gitWrite") throw new Error("expected a git write")
95
+ if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
96
+ throw new Error("expected a git write")
96
97
  const trailers = write.write.message.split("\n").slice(2)
97
98
  expect(trailers[0]).toBe("Gtd-Step: a#1")
98
99
  expect(trailers[1]).toBe("Gtd-Cost: 1.5 m")
@@ -102,3 +103,30 @@ describe("planStep — commit", () => {
102
103
  expect(trailers).toHaveLength(5)
103
104
  })
104
105
  })
106
+
107
+ describe("planStep — keepHistory", () => {
108
+ it("writes the history ref right after the commit, before the outcome", () => {
109
+ const s = snapshot({
110
+ state: "building",
111
+ stepDef: {},
112
+ landing: { ...commitTo("building", "done"), keepHistory: "refs/gtd/history/abc" },
113
+ })
114
+ const outcome = planStep(s)
115
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
116
+ expect(outcome.steps.map((step) => step.kind)).toEqual(["gitWrite", "gitWrite", "outcome"])
117
+ expect(outcome.steps[0]).toMatchObject({ write: { kind: "commitAll" } })
118
+ expect(outcome.steps[1]).toEqual({
119
+ kind: "gitWrite",
120
+ write: { kind: "updateRef", ref: "refs/gtd/history/abc", target: "HEAD" },
121
+ })
122
+ })
123
+
124
+ it("without keepHistory no updateRef is emitted", () => {
125
+ const s = snapshot({ state: "building", stepDef: {}, landing: commitTo("building", "done") })
126
+ const outcome = planStep(s)
127
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
128
+ expect(
129
+ outcome.steps.some((step) => step.kind === "gitWrite" && step.write.kind === "updateRef"),
130
+ ).toBe(false)
131
+ })
132
+ })
@@ -83,6 +83,14 @@ export const planStep = (
83
83
  steps: [
84
84
  ...uncheckStep(snapshot),
85
85
  { kind: "gitWrite", write: { kind: "commitAll", message } },
86
+ ...(landing.keepHistory !== undefined
87
+ ? [
88
+ {
89
+ kind: "gitWrite",
90
+ write: { kind: "updateRef", ref: landing.keepHistory, target: "HEAD" },
91
+ } as const,
92
+ ]
93
+ : []),
86
94
  outcome,
87
95
  ],
88
96
  }
@@ -143,7 +143,7 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
143
143
  // quoted argument.
144
144
  const conditionLine = conditionStatement(block)
145
145
 
146
- if (block.startsWith("git add -A &&\n")) {
146
+ if (block.startsWith("git add -A || exit 1\n")) {
147
147
  const [message] = extractQuotedTokens(conditionLine)
148
148
  if (message !== undefined && commitAll(message) === block) {
149
149
  repo.commitAllWithPrefix(message)
@@ -16,11 +16,15 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
16
16
  return subject !== null ? Effect.succeed(subject) : Effect.fail(new Error("No commits"))
17
17
  },
18
18
 
19
- lastCommitMessage: () => {
20
- const message = repo.lastCommitMessage()
19
+ lastCommitMessage: (ref?: string) => {
20
+ const message = repo.lastCommitMessage(ref)
21
21
  return message !== null ? Effect.succeed(message) : Effect.fail(new Error("No commits"))
22
22
  },
23
23
 
24
+ currentBranch: () => Effect.succeed(Option.some(repo.branchName())),
25
+
26
+ listRefs: (prefix: string) => Effect.succeed(repo.listRefs(prefix)),
27
+
24
28
  resolveRef: (ref: string) => {
25
29
  const hash = repo.resolveRef(ref)
26
30
  return hash !== null
@@ -26,6 +26,7 @@ import {
26
26
  hardResetTo,
27
27
  discardPending,
28
28
  updateRef,
29
+ fetchHistoryRefs,
29
30
  deleteRef,
30
31
  } from "../emit/index.js"
31
32
  import { InMemRepo } from "./InMemRepo.js"
@@ -88,6 +89,18 @@ const makeLiveTier = (initialCommit = true): GitTier => {
88
89
  // symlink to `/private/tmp` — `git` reports the resolved path).
89
90
  const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-")))
90
91
  const gitExec = (...args: string[]) => gitExecIn(root, ...args)
92
+ // Distinct, increasing committer dates, so ref ordering by tip date is deterministic.
93
+ let tick = 0
94
+ const commitEnv = () => ({
95
+ ...process.env,
96
+ GIT_COMMITTER_DATE: new Date(Date.UTC(2024, 0, 1, 0, 0, ++tick)).toISOString(),
97
+ })
98
+ const gitCommit = (message: string, allowEmpty = false) =>
99
+ execFileSync("git", ["commit", ...(allowEmpty ? ["--allow-empty"] : []), "-m", message], {
100
+ cwd: root,
101
+ env: commitEnv(),
102
+ stdio: "pipe",
103
+ })
91
104
  gitExec("init")
92
105
  gitExec(`config user.email "test@test.com"`)
93
106
  gitExec(`config user.name "Test"`)
@@ -95,7 +108,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
95
108
  if (initialCommit) {
96
109
  writeFileSync(join(root, "readme.txt"), "hello")
97
110
  gitExec("add", "-A")
98
- gitExec(`commit -m "init: first commit"`)
111
+ gitCommit("init: first commit")
99
112
  }
100
113
 
101
114
  const provide = <A>(eff: Effect.Effect<A, Error, GitService | Narrator>): Promise<A> =>
@@ -142,7 +155,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
142
155
  // --allow-empty: a caller passing `files: {}` (no prior staged
143
156
  // change either) means a deliberate empty commit — the state-entry
144
157
  // shape many test seeders rely on.
145
- gitExec(`commit --allow-empty -m "${message}"`)
158
+ gitCommit(message, true)
146
159
  },
147
160
  writeFile: (path, content) => {
148
161
  mkdirSync(dirname(join(root, path)), { recursive: true })
@@ -259,6 +272,8 @@ const runGitExit = <A>(
259
272
  export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new Set([
260
273
  "lastCommitSubject",
261
274
  "lastCommitMessage",
275
+ "currentBranch",
276
+ "listRefs",
262
277
  "hasCommits",
263
278
  "resolveRef",
264
279
  "readRefOption",
@@ -367,6 +382,73 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
367
382
  })
368
383
  })
369
384
 
385
+ describe("lastCommitMessage with a ref", () => {
386
+ it("reads the given ref's full message, not HEAD's", async () => {
387
+ t.seed.commit("feat: first\n\nGtd-Branch: one")
388
+ t.seed.commit("feat: second", { "extra.txt": "extra" })
389
+ const message = await runGit(t, (g) => g.lastCommitMessage("HEAD~1"))
390
+ expect(message).toBe("feat: first\n\nGtd-Branch: one")
391
+ })
392
+ })
393
+
394
+ describe("currentBranch", () => {
395
+ it("is the checked-out branch's name", async () => {
396
+ const branch = await runGit(t, (g) => g.currentBranch())
397
+ expect(Option.isSome(branch)).toBe(true)
398
+ })
399
+
400
+ it.skipIf(!capabilities.onDiskContent)(
401
+ "is none on a detached HEAD, never a failure",
402
+ async () => {
403
+ gitExecIn(t.root, "checkout", "--detach", "HEAD")
404
+ const branch = await runGit(t, (g) => g.currentBranch())
405
+ expect(Option.isNone(branch)).toBe(true)
406
+ },
407
+ )
408
+ })
409
+
410
+ describe("listRefs", () => {
411
+ it("is [] when no ref lives under the prefix", async () => {
412
+ const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
413
+ expect(refs).toEqual([])
414
+ })
415
+
416
+ it("lists {ref, hash, date} for refs under the prefix only, newest tip first", async () => {
417
+ const first = t.observe.resolveRef("HEAD")
418
+ t.seed.commit("feat: second", { "second.txt": "2" })
419
+ const second = t.observe.resolveRef("HEAD")
420
+ t.seed.updateRef("refs/gtd/history/old", first)
421
+ t.seed.updateRef("refs/gtd/history/new", second)
422
+ t.seed.updateRef("refs/gtd/other/elsewhere", second)
423
+ const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
424
+ expect(refs.map((r) => [r.ref, r.hash])).toEqual([
425
+ ["refs/gtd/history/new", second],
426
+ ["refs/gtd/history/old", first],
427
+ ])
428
+ for (const r of refs) expect(r.date.length).toBeGreaterThan(0)
429
+ })
430
+
431
+ it("reports the date as ISO 8601 strict", async () => {
432
+ t.seed.updateRef("refs/gtd/history/x", t.observe.resolveRef("HEAD"))
433
+ const [entry] = await runGit(t, (g) => g.listRefs("refs/gtd/history/"))
434
+ expect(entry?.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/)
435
+ })
436
+ })
437
+
438
+ describe("history reachable only from a retained ref", () => {
439
+ it("commitHistory and readFileAtRef read it after HEAD moved off those commits", async () => {
440
+ t.seed.commit("feat: second", { "kept.txt": "kept" })
441
+ const tip = t.observe.resolveRef("HEAD")
442
+ t.seed.updateRef("refs/gtd/history/tip", tip)
443
+ t.seed.mixedReset("HEAD~1")
444
+ expect(t.observe.resolveRef("HEAD")).not.toBe(tip)
445
+ const history = await runGit(t, (g) => g.commitHistory(undefined, "refs/gtd/history/tip"))
446
+ expect(history.map((c) => c.message)).toEqual(["init: first commit", "feat: second"])
447
+ const content = await runGit(t, (g) => g.readFileAtRef("refs/gtd/history/tip", "kept.txt"))
448
+ expect(content).toBe("kept")
449
+ })
450
+ })
451
+
370
452
  describe("hasCommits", () => {
371
453
  it("returns false in an empty repo", async () => {
372
454
  const empty = t.emptyRepo()
@@ -1036,6 +1118,12 @@ export const runGitScriptContract = (): void => {
1036
1118
  expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head)
1037
1119
  })
1038
1120
 
1121
+ it("updateRef with HEAD points the ref at the commit just made", () => {
1122
+ execScript(t, commitAll("feat: just made"))
1123
+ execScript(t, updateRef("refs/gtd/history/x", "HEAD"))
1124
+ expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"))
1125
+ })
1126
+
1039
1127
  it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
1040
1128
  const head = t.observe.resolveRef("HEAD")
1041
1129
  execScript(t, updateRef("refs/gtd/marker", head))
@@ -1049,4 +1137,47 @@ export const runGitScriptContract = (): void => {
1049
1137
  expect(t.observe.refExists("refs/gtd/never-created")).toBe(false)
1050
1138
  })
1051
1139
  })
1140
+
1141
+ describe("fetchHistoryRefs", () => {
1142
+ it("pulls every refs/gtd/history/* from a bare remote and prints the count", () => {
1143
+ const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
1144
+ try {
1145
+ gitExecIn(bare, "init", "-q", "--bare")
1146
+ gitExecIn(t.root, "remote", "add", "origin", bare)
1147
+ const head = t.observe.resolveRef("HEAD")
1148
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/one`)
1149
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/two`)
1150
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/other/skip`)
1151
+ const out = execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
1152
+ cwd: t.root,
1153
+ encoding: "utf8",
1154
+ })
1155
+ expect(t.observe.refExists("refs/gtd/history/one")).toBe(true)
1156
+ expect(t.observe.refExists("refs/gtd/history/two")).toBe(true)
1157
+ expect(t.observe.refExists("refs/gtd/other/skip")).toBe(false)
1158
+ expect(out.trim()).toBe("2 history refs arrived")
1159
+ } finally {
1160
+ rmSync(bare, { recursive: true, force: true })
1161
+ }
1162
+ })
1163
+
1164
+ it("fails loudly when a local history ref conflicts with the remote's", () => {
1165
+ const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
1166
+ try {
1167
+ gitExecIn(bare, "init", "-q", "--bare")
1168
+ gitExecIn(t.root, "remote", "add", "origin", bare)
1169
+ const first = t.observe.resolveRef("HEAD")
1170
+ t.seed.commit("feat: second", { "second.txt": "x" })
1171
+ const second = t.observe.resolveRef("HEAD")
1172
+ gitExecIn(t.root, "push", "-q", "origin", `${first}:refs/gtd/history/one`)
1173
+ t.seed.updateRef("refs/gtd/history/one", second)
1174
+ expect(() =>
1175
+ execFileSync("bash", ["-c", fetchHistoryRefs("origin")], { cwd: t.root, stdio: "pipe" }),
1176
+ ).toThrow()
1177
+ expect(t.observe.resolveRef("refs/gtd/history/one")).toBe(second)
1178
+ } finally {
1179
+ rmSync(bare, { recursive: true, force: true })
1180
+ }
1181
+ })
1182
+ })
1052
1183
  }
@@ -9,6 +9,8 @@ interface Commit {
9
9
  message: string
10
10
  files: Map<string, string> // full tree snapshot (path → UTF-8 content)
11
11
  parent: string | null
12
+ /** Creation order — the twin's stand-in for a committer date. */
13
+ seq: number
12
14
  }
13
15
 
14
16
  function sha1(input: string): string {
@@ -35,6 +37,7 @@ export class InMemRepo {
35
37
  private currentBranch: string = "main"
36
38
  private worktree: Map<string, string> = new Map()
37
39
  private index: Map<string, string> = new Map()
40
+ private nextSeq = 1
38
41
 
39
42
  private getCommit(hash: string): Commit | null {
40
43
  return this.commits.get(hash) ?? null
@@ -137,10 +140,27 @@ export class InMemRepo {
137
140
  return c.message.split("\n")[0] ?? null
138
141
  }
139
142
 
140
- lastCommitMessage(): string | null {
141
- const c = this.headCommit()
142
- if (!c) return null
143
- return c.message
143
+ lastCommitMessage(ref?: string): string | null {
144
+ const hash = ref === undefined ? this.head : this.resolveRef(ref)
145
+ const c = hash === null ? null : this.getCommit(hash)
146
+ return c === null ? null : c.message
147
+ }
148
+
149
+ branchName(): string {
150
+ return this.currentBranch
151
+ }
152
+
153
+ /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
154
+ listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
155
+ return [...this.refs.entries()]
156
+ .filter(([ref]) => ref.startsWith(prefix))
157
+ .map(([ref, hash]) => ({ ref, hash, seq: this.getCommit(hash)?.seq ?? 0 }))
158
+ .sort((a, b) => b.seq - a.seq)
159
+ .map(({ ref, hash, seq }) => ({
160
+ ref,
161
+ hash,
162
+ date: new Date(seq * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
163
+ }))
144
164
  }
145
165
 
146
166
  /**
@@ -287,7 +307,7 @@ export class InMemRepo {
287
307
  const tree = new Map(this.index)
288
308
  const parent = this.head
289
309
  const hash = makeHash(message, parent, tree)
290
- const commit: Commit = { hash, message, files: new Map(tree), parent }
310
+ const commit: Commit = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
291
311
  this.commits.set(hash, commit)
292
312
  this.head = hash
293
313
  this.branches.set(this.currentBranch, hash)
@@ -307,7 +327,7 @@ export class InMemRepo {
307
327
  const parent = this.head
308
328
  const hash = makeHash(message, parent, tree)
309
329
 
310
- const commit: Commit = { hash, message, files: new Map(tree), parent }
330
+ const commit: Commit = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
311
331
  this.commits.set(hash, commit)
312
332
  this.head = hash
313
333
  this.branches.set(this.currentBranch, hash)