@vladpazych/dexter 0.1.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/bin/dexter +6 -0
- package/package.json +43 -0
- package/src/claude/index.ts +6 -0
- package/src/cli.ts +39 -0
- package/src/env/define.ts +190 -0
- package/src/env/index.ts +10 -0
- package/src/env/loader.ts +61 -0
- package/src/env/print.ts +98 -0
- package/src/env/validate.ts +46 -0
- package/src/index.ts +16 -0
- package/src/meta/adapters/fs.ts +22 -0
- package/src/meta/adapters/git.ts +29 -0
- package/src/meta/adapters/glob.ts +14 -0
- package/src/meta/adapters/index.ts +24 -0
- package/src/meta/adapters/process.ts +40 -0
- package/src/meta/cli.ts +340 -0
- package/src/meta/domain/bisect.ts +126 -0
- package/src/meta/domain/blame.ts +136 -0
- package/src/meta/domain/commit.ts +135 -0
- package/src/meta/domain/commits.ts +23 -0
- package/src/meta/domain/constraints/registry.ts +49 -0
- package/src/meta/domain/constraints/types.ts +30 -0
- package/src/meta/domain/diff.ts +34 -0
- package/src/meta/domain/eval.ts +57 -0
- package/src/meta/domain/format.ts +34 -0
- package/src/meta/domain/lint.ts +88 -0
- package/src/meta/domain/pickaxe.ts +99 -0
- package/src/meta/domain/quality.ts +145 -0
- package/src/meta/domain/rules.ts +21 -0
- package/src/meta/domain/scope-context.ts +63 -0
- package/src/meta/domain/service.ts +68 -0
- package/src/meta/domain/setup.ts +34 -0
- package/src/meta/domain/test.ts +72 -0
- package/src/meta/domain/transcripts.ts +88 -0
- package/src/meta/domain/typecheck.ts +41 -0
- package/src/meta/domain/workspace.ts +78 -0
- package/src/meta/errors.ts +19 -0
- package/src/meta/hooks/on-post-read.ts +61 -0
- package/src/meta/hooks/on-post-write.ts +65 -0
- package/src/meta/hooks/on-pre-bash.ts +69 -0
- package/src/meta/hooks/stubs.ts +51 -0
- package/src/meta/index.ts +36 -0
- package/src/meta/lib/actor.ts +53 -0
- package/src/meta/lib/eslint.ts +58 -0
- package/src/meta/lib/format.ts +55 -0
- package/src/meta/lib/paths.ts +36 -0
- package/src/meta/lib/present.ts +231 -0
- package/src/meta/lib/spec-links.ts +83 -0
- package/src/meta/lib/stdin.ts +56 -0
- package/src/meta/ports.ts +50 -0
- package/src/meta/types.ts +113 -0
- package/src/output/build.ts +56 -0
- package/src/output/index.ts +24 -0
- package/src/output/output.test.ts +374 -0
- package/src/output/render-cli.ts +55 -0
- package/src/output/render-json.ts +80 -0
- package/src/output/render-md.ts +43 -0
- package/src/output/render-xml.ts +55 -0
- package/src/output/render.ts +23 -0
- package/src/output/types.ts +44 -0
- package/src/pipe/format.ts +167 -0
- package/src/pipe/index.ts +4 -0
- package/src/pipe/parse.ts +131 -0
- package/src/pipe/spawn.ts +205 -0
- package/src/pipe/types.ts +27 -0
- package/src/terminal/colors.ts +95 -0
- package/src/terminal/index.ts +16 -0
- package/src/version.ts +1 -0
package/src/meta/cli.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createCLI — the extension API for consumer repos.
|
|
3
|
+
*
|
|
4
|
+
* Consumer repos import this factory and compose their own CLI
|
|
5
|
+
* with custom commands and hook extensions on top of dexter's core.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { render } from "../output/index.ts"
|
|
9
|
+
import type { OutputMode } from "../output/types.ts"
|
|
10
|
+
|
|
11
|
+
import { isControlError } from "./errors.ts"
|
|
12
|
+
import { findRepoRoot } from "./lib/paths.ts"
|
|
13
|
+
import { parseFormat } from "./lib/format.ts"
|
|
14
|
+
import {
|
|
15
|
+
presentQuery,
|
|
16
|
+
presentGit,
|
|
17
|
+
presentCommit,
|
|
18
|
+
presentEval,
|
|
19
|
+
presentPackages,
|
|
20
|
+
presentSetup,
|
|
21
|
+
presentTranscripts,
|
|
22
|
+
presentError,
|
|
23
|
+
} from "./lib/present.ts"
|
|
24
|
+
import { createControlPorts } from "./adapters/index.ts"
|
|
25
|
+
import { createControlService, type ControlService } from "./domain/service.ts"
|
|
26
|
+
|
|
27
|
+
import { onPreBash } from "./hooks/on-pre-bash.ts"
|
|
28
|
+
import { onPostWrite } from "./hooks/on-post-write.ts"
|
|
29
|
+
import { onPostRead } from "./hooks/on-post-read.ts"
|
|
30
|
+
import {
|
|
31
|
+
onSessionStart,
|
|
32
|
+
onPostBash,
|
|
33
|
+
onStop,
|
|
34
|
+
onPromptSubmit,
|
|
35
|
+
onNotification,
|
|
36
|
+
onPreCompact,
|
|
37
|
+
onToolFailure,
|
|
38
|
+
onSubagentStart,
|
|
39
|
+
onSubagentStop,
|
|
40
|
+
onSessionEnd,
|
|
41
|
+
onPermissionRequest,
|
|
42
|
+
} from "./hooks/stubs.ts"
|
|
43
|
+
|
|
44
|
+
import type { HookInput } from "./lib/stdin.ts"
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Public types
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export type HookContext = {
|
|
51
|
+
root: string
|
|
52
|
+
service: ControlService
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type HookOutput = {
|
|
56
|
+
additionalContext?: string
|
|
57
|
+
} | void
|
|
58
|
+
|
|
59
|
+
export type CLIConfig = {
|
|
60
|
+
commands?: Record<string, (args: string[], ctx: HookContext) => Promise<void> | void>
|
|
61
|
+
hooks?: {
|
|
62
|
+
"pre-bash"?: { deny?: Array<{ pattern: RegExp; hint: string }> }
|
|
63
|
+
"post-read"?: (input: HookInput, ctx: HookContext) => HookOutput | Promise<HookOutput>
|
|
64
|
+
"post-write"?: (input: HookInput, ctx: HookContext) => HookOutput | Promise<HookOutput>
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Core hook dispatch
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const CORE_HOOKS: Record<string, () => Promise<void>> = {
|
|
73
|
+
"on-pre-bash": onPreBash,
|
|
74
|
+
"on-post-write": onPostWrite,
|
|
75
|
+
"on-post-read": onPostRead,
|
|
76
|
+
"on-session-start": onSessionStart,
|
|
77
|
+
"on-post-bash": onPostBash,
|
|
78
|
+
"on-stop": onStop,
|
|
79
|
+
"on-prompt-submit": onPromptSubmit,
|
|
80
|
+
"on-notification": onNotification,
|
|
81
|
+
"on-pre-compact": onPreCompact,
|
|
82
|
+
"on-tool-failure": onToolFailure,
|
|
83
|
+
"on-subagent-start": onSubagentStart,
|
|
84
|
+
"on-subagent-stop": onSubagentStop,
|
|
85
|
+
"on-session-end": onSessionEnd,
|
|
86
|
+
"on-permission-request": onPermissionRequest,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Output helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function output(text: string, mode: OutputMode): void {
|
|
94
|
+
if (mode === "cli") {
|
|
95
|
+
console.log(text)
|
|
96
|
+
} else {
|
|
97
|
+
console.log(text)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function outputError(err: unknown, mode: OutputMode): void {
|
|
102
|
+
if (isControlError(err)) {
|
|
103
|
+
output(render(presentError(err), mode), mode)
|
|
104
|
+
} else {
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
106
|
+
console.error(message)
|
|
107
|
+
}
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Factory
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
export function createCLI(config: CLIConfig = {}) {
|
|
116
|
+
return {
|
|
117
|
+
async run(): Promise<void> {
|
|
118
|
+
const [, , cmd, ...rawArgs] = process.argv
|
|
119
|
+
|
|
120
|
+
// Hook commands — delegate to core handler
|
|
121
|
+
if (cmd?.startsWith("on-")) {
|
|
122
|
+
const handler = CORE_HOOKS[cmd]
|
|
123
|
+
if (handler) {
|
|
124
|
+
await handler()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
// Unknown hook — silent exit
|
|
128
|
+
process.exit(0)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Everything below needs repo root + service
|
|
132
|
+
let root: string
|
|
133
|
+
try {
|
|
134
|
+
root = findRepoRoot()
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
137
|
+
process.exit(1)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const ports = createControlPorts(root)
|
|
141
|
+
const service = createControlService(ports)
|
|
142
|
+
const ctx: HookContext = { root, service }
|
|
143
|
+
|
|
144
|
+
// Parse format flags
|
|
145
|
+
const { mode, rest: args } = parseFormat(rawArgs)
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// Custom commands first (override built-in)
|
|
149
|
+
if (cmd && config.commands?.[cmd]) {
|
|
150
|
+
await config.commands[cmd](args, ctx)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Built-in domain commands
|
|
155
|
+
switch (cmd) {
|
|
156
|
+
case "commit": {
|
|
157
|
+
const [message, ...files] = args
|
|
158
|
+
if (!message) {
|
|
159
|
+
console.error('Usage: commit "message" file1 file2 ...')
|
|
160
|
+
process.exit(1)
|
|
161
|
+
}
|
|
162
|
+
const result = await service.commit({ message, files })
|
|
163
|
+
output(render(presentCommit(result), mode), mode)
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case "rules": {
|
|
168
|
+
const scopes = args.length > 0 ? args : ["."]
|
|
169
|
+
const result = service.rules(scopes)
|
|
170
|
+
output(render(presentQuery(result), mode), mode)
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case "diff": {
|
|
175
|
+
const scopes = args.length > 0 ? args : ["."]
|
|
176
|
+
const result = service.diff(scopes)
|
|
177
|
+
output(render(presentQuery(result), mode), mode)
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case "commits": {
|
|
182
|
+
const scopes = args.length > 0 ? args : ["."]
|
|
183
|
+
const result = service.commits(scopes)
|
|
184
|
+
output(render(presentQuery(result), mode), mode)
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "lint": {
|
|
189
|
+
const changed = args.includes("--changed")
|
|
190
|
+
const scopes = args.filter((a) => a !== "--changed")
|
|
191
|
+
const result = await service.lint(scopes, { changed })
|
|
192
|
+
output(render(presentQuery(result), mode), mode)
|
|
193
|
+
if (result.what === "lint" && result.data.errorCount > 0) process.exit(1)
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case "typecheck": {
|
|
198
|
+
const result = await service.typecheck(args)
|
|
199
|
+
output(render(presentQuery(result), mode), mode)
|
|
200
|
+
if (result.what === "typecheck" && result.data.errorCount > 0) process.exit(1)
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case "test": {
|
|
205
|
+
const result = await service.test(args)
|
|
206
|
+
output(render(presentQuery(result), mode), mode)
|
|
207
|
+
if (result.what === "test" && result.data.errorCount > 0) process.exit(1)
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case "blame": {
|
|
212
|
+
const file = args[0]
|
|
213
|
+
if (!file) {
|
|
214
|
+
console.error("Usage: blame <file> [startLine:endLine]")
|
|
215
|
+
process.exit(1)
|
|
216
|
+
}
|
|
217
|
+
let lines: [number, number] | undefined
|
|
218
|
+
if (args[1]) {
|
|
219
|
+
const parts = args[1].split(":")
|
|
220
|
+
if (parts.length === 2) {
|
|
221
|
+
lines = [parseInt(parts[0]!, 10), parseInt(parts[1]!, 10)]
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const result = service.blame(file, lines)
|
|
225
|
+
output(render(presentGit(result), mode), mode)
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case "pickaxe": {
|
|
230
|
+
const pattern = args[0]
|
|
231
|
+
if (!pattern) {
|
|
232
|
+
console.error("Usage: pickaxe <pattern> [--regex] [scopes...]")
|
|
233
|
+
process.exit(1)
|
|
234
|
+
}
|
|
235
|
+
const regex = args.includes("--regex")
|
|
236
|
+
const scopes = args.slice(1).filter((a) => a !== "--regex")
|
|
237
|
+
const result = service.pickaxe(pattern, { regex, scopes: scopes.length > 0 ? scopes : undefined })
|
|
238
|
+
output(render(presentGit(result), mode), mode)
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case "bisect": {
|
|
243
|
+
const testCmd = args[0]
|
|
244
|
+
const goodIdx = args.indexOf("--good")
|
|
245
|
+
const badIdx = args.indexOf("--bad")
|
|
246
|
+
const good = goodIdx >= 0 ? args[goodIdx + 1] : undefined
|
|
247
|
+
const bad = badIdx >= 0 ? args[badIdx + 1] : undefined
|
|
248
|
+
if (!testCmd || !good) {
|
|
249
|
+
console.error("Usage: bisect <test-cmd> --good <ref> [--bad <ref>]")
|
|
250
|
+
process.exit(1)
|
|
251
|
+
}
|
|
252
|
+
const result = await service.bisect(testCmd, good, bad)
|
|
253
|
+
output(render(presentGit(result), mode), mode)
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
case "eval": {
|
|
258
|
+
const code = args.join(" ")
|
|
259
|
+
const result = await service.eval({ code })
|
|
260
|
+
output(render(presentEval(result), mode), mode)
|
|
261
|
+
if (!result.ok) process.exit(1)
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case "setup": {
|
|
266
|
+
const result = service.setup()
|
|
267
|
+
output(render(presentSetup(result), mode), mode)
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case "transcripts": {
|
|
272
|
+
const skillIdx = args.indexOf("--skill")
|
|
273
|
+
const minutesIdx = args.indexOf("--minutes")
|
|
274
|
+
const skill = skillIdx >= 0 ? args[skillIdx + 1] : undefined
|
|
275
|
+
const minutes = minutesIdx >= 0 ? parseInt(args[minutesIdx + 1]!, 10) : undefined
|
|
276
|
+
const result = service.transcripts({ skill, minutes })
|
|
277
|
+
output(render(presentTranscripts(result), mode), mode)
|
|
278
|
+
break
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case "packages": {
|
|
282
|
+
const packages = service.discoverPackages()
|
|
283
|
+
output(render(presentPackages(packages), mode), mode)
|
|
284
|
+
break
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
case "format": {
|
|
288
|
+
// Prettier formatting
|
|
289
|
+
const scopes = args.length > 0 ? args : ["."]
|
|
290
|
+
const formatResult = await service.lint(scopes) // HACK: reuses lint, should be separate
|
|
291
|
+
output(render(presentQuery(formatResult), mode), mode)
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case "--help":
|
|
296
|
+
case "-h":
|
|
297
|
+
case "help":
|
|
298
|
+
case undefined: {
|
|
299
|
+
const commands = [
|
|
300
|
+
"commit — quality-gated atomic commit",
|
|
301
|
+
"rules — CLAUDE.md cascade for scopes",
|
|
302
|
+
"diff — git status + diff for scopes",
|
|
303
|
+
"commits — recent commit history",
|
|
304
|
+
"lint — ESLint across workspace",
|
|
305
|
+
"typecheck — TypeScript checking",
|
|
306
|
+
"test — run tests",
|
|
307
|
+
"blame — structured git blame",
|
|
308
|
+
"pickaxe — find commits by pattern",
|
|
309
|
+
"bisect — binary search for bad commit",
|
|
310
|
+
"eval — sandboxed TypeScript REPL",
|
|
311
|
+
"setup — configure .claude/settings",
|
|
312
|
+
"transcripts — list subagent transcripts",
|
|
313
|
+
"packages — list workspace packages",
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
if (config.commands) {
|
|
317
|
+
for (const name of Object.keys(config.commands)) {
|
|
318
|
+
commands.push(`${name} — (custom)`)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
console.log(`dexter meta — agentic development toolkit\n`)
|
|
323
|
+
console.log(`Commands:`)
|
|
324
|
+
for (const c of commands) {
|
|
325
|
+
console.log(` ${c}`)
|
|
326
|
+
}
|
|
327
|
+
console.log(`\nFlags: --format cli|json|xml|md --json --xml --md`)
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
default:
|
|
332
|
+
console.error(`Unknown command: ${cmd}`)
|
|
333
|
+
process.exit(1)
|
|
334
|
+
}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
outputError(err, mode)
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git bisect — automated binary search for the first bad commit.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type { BisectMatch, GitResult } from "../types.ts"
|
|
7
|
+
import { ControlError } from "../errors.ts"
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TIMEOUT = 5 * 60 * 1000
|
|
10
|
+
|
|
11
|
+
/** Parse the first-bad-commit output from git bisect run. */
|
|
12
|
+
function parseFirstBad(output: string, ports: ControlPorts): BisectMatch {
|
|
13
|
+
const match = output.match(/([0-9a-f]{40}) is the first bad commit/)
|
|
14
|
+
if (!match) {
|
|
15
|
+
throw new ControlError("bisect_inconclusive", "bisect did not identify a bad commit", [
|
|
16
|
+
"The test command may not distinguish good from bad correctly",
|
|
17
|
+
"Ensure the command exits 0 for good, non-zero for bad",
|
|
18
|
+
])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fullHash = match[1]!
|
|
22
|
+
const hash = fullHash.slice(0, 8)
|
|
23
|
+
|
|
24
|
+
const show = ports.git.run(["show", "--format=%an%n%ai%n%s", "--stat", fullHash])
|
|
25
|
+
const showLines = show.success ? show.stdout.split("\n").filter(Boolean) : []
|
|
26
|
+
const author = showLines[0] ?? ""
|
|
27
|
+
const rawDate = showLines[1] ?? ""
|
|
28
|
+
const message = showLines[2] ?? ""
|
|
29
|
+
|
|
30
|
+
let date: string
|
|
31
|
+
try {
|
|
32
|
+
date = new Date(rawDate).toISOString().slice(0, 10)
|
|
33
|
+
} catch {
|
|
34
|
+
date = rawDate
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const diffResult = ports.git.run(["show", "--format=", "--patch", fullHash])
|
|
38
|
+
const diff = diffResult.success ? diffResult.stdout.trim() : ""
|
|
39
|
+
|
|
40
|
+
return { hash, author, date, message, diff }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function bisect(
|
|
44
|
+
ports: ControlPorts,
|
|
45
|
+
test: string,
|
|
46
|
+
good: string,
|
|
47
|
+
bad?: string,
|
|
48
|
+
timeout?: number,
|
|
49
|
+
): Promise<GitResult> {
|
|
50
|
+
if (!test) {
|
|
51
|
+
throw new ControlError("empty_test", "bisect requires a test command", [
|
|
52
|
+
"Usage: bisect <test-cmd> --good <ref>",
|
|
53
|
+
"The command should exit 0 for good commits, non-zero for bad",
|
|
54
|
+
])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!good) {
|
|
58
|
+
throw new ControlError("no_good_ref", "bisect requires a good (known-working) ref", [
|
|
59
|
+
"Usage: bisect <test-cmd> --good <ref> [--bad <ref>]",
|
|
60
|
+
'Example: bisect "bun test test/foo.test.ts" --good HEAD~20',
|
|
61
|
+
])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const badRef = bad ?? "HEAD"
|
|
65
|
+
|
|
66
|
+
const goodCheck = ports.git.run(["rev-parse", "--verify", good])
|
|
67
|
+
if (!goodCheck.success) {
|
|
68
|
+
throw new ControlError("bad_ref", `invalid good ref: ${good}`, [
|
|
69
|
+
`git rev-parse --verify ${good} failed`,
|
|
70
|
+
"Use a commit hash, branch name, tag, or relative ref like HEAD~10",
|
|
71
|
+
])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (bad) {
|
|
75
|
+
const badCheck = ports.git.run(["rev-parse", "--verify", bad])
|
|
76
|
+
if (!badCheck.success) {
|
|
77
|
+
throw new ControlError("bad_ref", `invalid bad ref: ${bad}`, [`git rev-parse --verify ${bad} failed`])
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const bisectCheck = ports.git.run(["bisect", "log"])
|
|
82
|
+
if (bisectCheck.success && bisectCheck.stdout.includes("git bisect start")) {
|
|
83
|
+
throw new ControlError("bisect_active", "a bisect session is already in progress", [
|
|
84
|
+
"Run `git bisect reset` to end the current session first",
|
|
85
|
+
])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const start = ports.git.run(["bisect", "start"])
|
|
90
|
+
if (!start.success) {
|
|
91
|
+
throw new ControlError("bisect_start_failed", `git bisect start failed: ${start.stderr.trim()}`, [])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const markBad = ports.git.run(["bisect", "bad", badRef])
|
|
95
|
+
if (!markBad.success) {
|
|
96
|
+
throw new ControlError("bisect_mark_failed", `git bisect bad ${badRef} failed: ${markBad.stderr.trim()}`, [])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const markGood = ports.git.run(["bisect", "good", good])
|
|
100
|
+
if (!markGood.success) {
|
|
101
|
+
throw new ControlError("bisect_mark_failed", `git bisect good ${good} failed: ${markGood.stderr.trim()}`, [])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const output = await new Promise<string>((resolve) => {
|
|
105
|
+
const lines: string[] = []
|
|
106
|
+
const handle = ports.process.spawn({
|
|
107
|
+
cmd: "git",
|
|
108
|
+
args: ["bisect", "run", "sh", "-c", test],
|
|
109
|
+
cwd: ports.root,
|
|
110
|
+
timeout: timeout ?? DEFAULT_TIMEOUT,
|
|
111
|
+
})
|
|
112
|
+
handle.onLine("stdout", (line) => lines.push(line))
|
|
113
|
+
handle.onLine("stderr", (line) => lines.push(line))
|
|
114
|
+
handle.wait().then((code) => {
|
|
115
|
+
if (code === null) {
|
|
116
|
+
lines.push("bisect timed out")
|
|
117
|
+
}
|
|
118
|
+
resolve(lines.join("\n"))
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return { what: "bisect", match: parseFirstBad(output, ports) }
|
|
123
|
+
} finally {
|
|
124
|
+
ports.git.run(["bisect", "reset"])
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git blame — structured per-line attribution with range grouping.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type { BlameRange, GitResult } from "../types.ts"
|
|
7
|
+
import { ControlError } from "../errors.ts"
|
|
8
|
+
|
|
9
|
+
type PorcelainEntry = {
|
|
10
|
+
commit: string
|
|
11
|
+
author: string
|
|
12
|
+
date: string
|
|
13
|
+
message: string
|
|
14
|
+
line: number
|
|
15
|
+
content: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Parse git blame --line-porcelain output into individual entries. */
|
|
19
|
+
function parsePorcelain(raw: string): PorcelainEntry[] {
|
|
20
|
+
const entries: PorcelainEntry[] = []
|
|
21
|
+
const lines = raw.split("\n")
|
|
22
|
+
let i = 0
|
|
23
|
+
|
|
24
|
+
while (i < lines.length) {
|
|
25
|
+
const header = lines[i]!
|
|
26
|
+
const match = header.match(/^([0-9a-f]{40})\s+\d+\s+(\d+)/)
|
|
27
|
+
if (!match) {
|
|
28
|
+
i++
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const commit = match[1]!.slice(0, 8)
|
|
33
|
+
const line = parseInt(match[2]!, 10)
|
|
34
|
+
let author = ""
|
|
35
|
+
let date = ""
|
|
36
|
+
let message = ""
|
|
37
|
+
let content = ""
|
|
38
|
+
|
|
39
|
+
i++
|
|
40
|
+
while (i < lines.length) {
|
|
41
|
+
const cur = lines[i]!
|
|
42
|
+
if (cur.startsWith("\t")) {
|
|
43
|
+
content = cur.slice(1)
|
|
44
|
+
i++
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
if (cur.startsWith("author ")) author = cur.slice(7)
|
|
48
|
+
else if (cur.startsWith("author-time ")) {
|
|
49
|
+
const ts = parseInt(cur.slice(12), 10)
|
|
50
|
+
date = new Date(ts * 1000).toISOString().slice(0, 10)
|
|
51
|
+
} else if (cur.startsWith("summary ")) message = cur.slice(8)
|
|
52
|
+
i++
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
entries.push({ commit, author, date, message, line, content })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return entries
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Group consecutive entries from the same commit into ranges. */
|
|
62
|
+
function groupRanges(entries: PorcelainEntry[]): BlameRange[] {
|
|
63
|
+
if (entries.length === 0) return []
|
|
64
|
+
|
|
65
|
+
const ranges: BlameRange[] = []
|
|
66
|
+
let current: BlameRange = {
|
|
67
|
+
commit: entries[0]!.commit,
|
|
68
|
+
author: entries[0]!.author,
|
|
69
|
+
date: entries[0]!.date,
|
|
70
|
+
message: entries[0]!.message,
|
|
71
|
+
startLine: entries[0]!.line,
|
|
72
|
+
endLine: entries[0]!.line,
|
|
73
|
+
content: [entries[0]!.content],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (let i = 1; i < entries.length; i++) {
|
|
77
|
+
const entry = entries[i]!
|
|
78
|
+
if (entry.commit === current.commit && entry.line === current.endLine + 1) {
|
|
79
|
+
current.endLine = entry.line
|
|
80
|
+
current.content.push(entry.content)
|
|
81
|
+
} else {
|
|
82
|
+
ranges.push(current)
|
|
83
|
+
current = {
|
|
84
|
+
commit: entry.commit,
|
|
85
|
+
author: entry.author,
|
|
86
|
+
date: entry.date,
|
|
87
|
+
message: entry.message,
|
|
88
|
+
startLine: entry.line,
|
|
89
|
+
endLine: entry.line,
|
|
90
|
+
content: [entry.content],
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
ranges.push(current)
|
|
95
|
+
return ranges
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function blame(ports: ControlPorts, file: string, lines?: [number, number]): GitResult {
|
|
99
|
+
const fullPath = `${ports.root}/${file}`
|
|
100
|
+
if (!ports.fs.exists(fullPath)) {
|
|
101
|
+
throw new ControlError("file_not_found", `file not found: ${file}`, [
|
|
102
|
+
"Provide a path relative to repo root",
|
|
103
|
+
"Example: blame meta/src/types.ts",
|
|
104
|
+
])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const args = ["blame", "--line-porcelain"]
|
|
108
|
+
|
|
109
|
+
if (lines) {
|
|
110
|
+
args.push(`-L`, `${lines[0]},${lines[1]}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ignoreRevsPath = `${ports.root}/.git-blame-ignore-revs`
|
|
114
|
+
if (ports.fs.exists(ignoreRevsPath)) {
|
|
115
|
+
args.push("--ignore-revs-file", ".git-blame-ignore-revs")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
args.push("--", file)
|
|
119
|
+
|
|
120
|
+
const result = ports.git.run(args)
|
|
121
|
+
if (!result.success) {
|
|
122
|
+
throw new ControlError(
|
|
123
|
+
"blame_failed",
|
|
124
|
+
`git blame failed: ${result.stderr.trim()}`,
|
|
125
|
+
[
|
|
126
|
+
"Check that the file is tracked by git",
|
|
127
|
+
lines ? `Line range ${lines[0]}:${lines[1]} may be out of bounds` : "",
|
|
128
|
+
].filter(Boolean),
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const entries = parsePorcelain(result.stdout)
|
|
133
|
+
const ranges = groupRanges(entries)
|
|
134
|
+
|
|
135
|
+
return { what: "blame", file, ranges }
|
|
136
|
+
}
|