@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/replay/Trailers.ts
CHANGED
|
@@ -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")}`
|
package/src/step/LandStep.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
package/src/step/RepoSnapshot.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
+
})
|
package/src/step/planStep.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/testkit/GitTiers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/testkit/InMemRepo.ts
CHANGED
|
@@ -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
|
|
142
|
-
|
|
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)
|