@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import type { AutoMergeResult, ClosePrResult, Forge, PrRef, PrState } from '../types'
|
|
6
|
+
import { bunExec, type Exec } from './github'
|
|
7
|
+
|
|
8
|
+
const objectId = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/)
|
|
9
|
+
const pendingLanding = z.strictObject({
|
|
10
|
+
baseSha: objectId,
|
|
11
|
+
headSha: objectId,
|
|
12
|
+
treeSha: objectId,
|
|
13
|
+
commitSha: objectId,
|
|
14
|
+
})
|
|
15
|
+
const localPrRecord = z.strictObject({
|
|
16
|
+
version: z.literal(1),
|
|
17
|
+
number: z.number().int().positive(),
|
|
18
|
+
head: z.string().min(1),
|
|
19
|
+
base: z.string().min(1),
|
|
20
|
+
title: z.string(),
|
|
21
|
+
body: z.string(),
|
|
22
|
+
mergeMessage: z.string(),
|
|
23
|
+
openedHeadSha: objectId,
|
|
24
|
+
status: z.enum(['open', 'closed', 'merged']),
|
|
25
|
+
mergedSha: objectId.optional(),
|
|
26
|
+
pendingLanding: pendingLanding.optional(),
|
|
27
|
+
comments: z.array(z.string()),
|
|
28
|
+
})
|
|
29
|
+
type LocalPrRecord = z.infer<typeof localPrRecord>
|
|
30
|
+
|
|
31
|
+
const PR_PREFIX = 'refs/autobuild/local-git/prs/'
|
|
32
|
+
|
|
33
|
+
function detail(error: unknown): string {
|
|
34
|
+
return error instanceof Error ? error.message : String(error)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Offline Forge backed only by refs and objects in one repository's shared
|
|
38
|
+
* Git database. No method invokes a shell, remote command, network client, or
|
|
39
|
+
* `gh`; every subprocess is an argv-form `git` command. */
|
|
40
|
+
export class LocalGitForge implements Forge {
|
|
41
|
+
readonly name = 'local-git'
|
|
42
|
+
private readonly exec: Exec
|
|
43
|
+
|
|
44
|
+
constructor(opts: { exec?: Exec } = {}) {
|
|
45
|
+
this.exec = opts.exec ?? bunExec
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async command(args: string[], cwd: string, allowFailure = false) {
|
|
49
|
+
const result = await this.exec(['git', ...args], { cwd })
|
|
50
|
+
if (!allowFailure && result.exitCode !== 0) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`local-git command failed (exit ${result.exitCode}): git ${args.join(' ')}\n` +
|
|
53
|
+
`${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async sha(ref: string, cwd: string): Promise<string> {
|
|
60
|
+
const result = await this.command(['rev-parse', '--verify', `${ref}^{commit}`], cwd)
|
|
61
|
+
const sha = result.stdout.trim()
|
|
62
|
+
if (!objectId.safeParse(sha).success) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`local-git expected ${ref} to resolve to one commit, found ${JSON.stringify(sha)}`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return sha
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async assertRef(ref: string, cwd: string): Promise<void> {
|
|
71
|
+
const result = await this.command(['check-ref-format', ref], cwd, true)
|
|
72
|
+
if (result.exitCode !== 0)
|
|
73
|
+
throw new Error(`local-git rejected invalid ref ${JSON.stringify(ref)}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private recordRef(number: number): string {
|
|
77
|
+
return `${PR_PREFIX}${number}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async readRecord(
|
|
81
|
+
cwd: string,
|
|
82
|
+
number: number,
|
|
83
|
+
): Promise<{ record: LocalPrRecord; blob: string }> {
|
|
84
|
+
const ref = this.recordRef(number)
|
|
85
|
+
const resolved = await this.command(['rev-parse', '--verify', ref], cwd, true)
|
|
86
|
+
if (resolved.exitCode !== 0) throw new Error(`local-git: unknown PR #${number}`)
|
|
87
|
+
const blob = resolved.stdout.trim()
|
|
88
|
+
if (!objectId.safeParse(blob).success) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`local-git PR #${number} ref contains malformed object id ${JSON.stringify(blob)}`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
const source = await this.command(['cat-file', 'blob', blob], cwd)
|
|
94
|
+
let value: unknown
|
|
95
|
+
try {
|
|
96
|
+
value = JSON.parse(source.stdout)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new Error(`local-git PR #${number} record is not JSON: ${detail(error)}`)
|
|
99
|
+
}
|
|
100
|
+
const parsed = localPrRecord.safeParse(value)
|
|
101
|
+
if (!parsed.success || parsed.data.number !== number) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`local-git PR #${number} record is invalid: ` +
|
|
104
|
+
(parsed.success ? `record names PR #${parsed.data.number}` : parsed.error.message),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return { record: parsed.data, blob }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async writeBlob(cwd: string, record: LocalPrRecord): Promise<string> {
|
|
111
|
+
const dir = await mkdtemp(join(tmpdir(), 'ab-local-git-'))
|
|
112
|
+
try {
|
|
113
|
+
const path = join(dir, 'record.json')
|
|
114
|
+
await writeFile(path, `${JSON.stringify(record)}\n`, 'utf8')
|
|
115
|
+
const result = await this.command(['hash-object', '-w', path], cwd)
|
|
116
|
+
const sha = result.stdout.trim()
|
|
117
|
+
if (!objectId.safeParse(sha).success)
|
|
118
|
+
throw new Error('local-git failed to write PR record blob')
|
|
119
|
+
return sha
|
|
120
|
+
} finally {
|
|
121
|
+
await rm(dir, { recursive: true, force: true })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async objectZeros(cwd: string): Promise<string> {
|
|
126
|
+
const result = await this.command(['rev-parse', '--show-object-format'], cwd)
|
|
127
|
+
return result.stdout.trim() === 'sha256' ? '0'.repeat(64) : '0'.repeat(40)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async createRecord(cwd: string, record: LocalPrRecord): Promise<boolean> {
|
|
131
|
+
const blob = await this.writeBlob(cwd, record)
|
|
132
|
+
const result = await this.command(
|
|
133
|
+
['update-ref', this.recordRef(record.number), blob, await this.objectZeros(cwd)],
|
|
134
|
+
cwd,
|
|
135
|
+
true,
|
|
136
|
+
)
|
|
137
|
+
return result.exitCode === 0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async updateRecord(
|
|
141
|
+
cwd: string,
|
|
142
|
+
number: number,
|
|
143
|
+
mutate: (record: LocalPrRecord) => LocalPrRecord,
|
|
144
|
+
): Promise<LocalPrRecord> {
|
|
145
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
146
|
+
const current = await this.readRecord(cwd, number)
|
|
147
|
+
const next = localPrRecord.parse(mutate(current.record))
|
|
148
|
+
const blob = await this.writeBlob(cwd, next)
|
|
149
|
+
const result = await this.command(
|
|
150
|
+
['update-ref', this.recordRef(number), blob, current.blob],
|
|
151
|
+
cwd,
|
|
152
|
+
true,
|
|
153
|
+
)
|
|
154
|
+
if (result.exitCode === 0) return next
|
|
155
|
+
}
|
|
156
|
+
throw new Error(`local-git PR #${number} record changed concurrently too many times`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async records(cwd: string): Promise<LocalPrRecord[]> {
|
|
160
|
+
const listed = await this.command(['for-each-ref', '--format=%(refname)', PR_PREFIX], cwd)
|
|
161
|
+
const records: LocalPrRecord[] = []
|
|
162
|
+
for (const ref of listed.stdout.split('\n').filter(Boolean)) {
|
|
163
|
+
const suffix = ref.slice(PR_PREFIX.length)
|
|
164
|
+
if (!/^[1-9][0-9]*$/.test(suffix)) {
|
|
165
|
+
throw new Error(`local-git found malformed private PR ref ${JSON.stringify(ref)}`)
|
|
166
|
+
}
|
|
167
|
+
records.push((await this.readRecord(cwd, Number(suffix))).record)
|
|
168
|
+
}
|
|
169
|
+
return records.sort((a, b) => a.number - b.number)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async pushBranch(workspacePath: string, branch: string): Promise<void> {
|
|
173
|
+
const ref = `refs/heads/${branch}`
|
|
174
|
+
await this.assertRef(ref, workspacePath)
|
|
175
|
+
const symbolic = await this.command(
|
|
176
|
+
['symbolic-ref', '--quiet', '--short', 'HEAD'],
|
|
177
|
+
workspacePath,
|
|
178
|
+
)
|
|
179
|
+
if (symbolic.stdout.trim() !== branch) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`local-git push requires workspace HEAD on ${JSON.stringify(branch)}, found ${JSON.stringify(symbolic.stdout.trim())}`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
const [head, published] = await Promise.all([
|
|
185
|
+
this.sha('HEAD', workspacePath),
|
|
186
|
+
this.sha(ref, workspacePath),
|
|
187
|
+
])
|
|
188
|
+
if (head !== published) {
|
|
189
|
+
throw new Error(`local-git branch ${ref} does not resolve to workspace HEAD ${head}`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async snapshotBase(opts: {
|
|
194
|
+
workspacePath: string
|
|
195
|
+
base: string
|
|
196
|
+
destinationRef: string
|
|
197
|
+
}): Promise<string> {
|
|
198
|
+
const source = `refs/heads/${opts.base}`
|
|
199
|
+
await this.assertRef(source, opts.workspacePath)
|
|
200
|
+
if (!opts.destinationRef.startsWith('refs/autobuild/')) {
|
|
201
|
+
throw new Error('local-git base snapshot destination must be a private refs/autobuild ref')
|
|
202
|
+
}
|
|
203
|
+
await this.assertRef(opts.destinationRef, opts.workspacePath)
|
|
204
|
+
const sha = await this.sha(source, opts.workspacePath)
|
|
205
|
+
await this.command(['update-ref', opts.destinationRef, sha], opts.workspacePath)
|
|
206
|
+
return sha
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async openPr(opts: {
|
|
210
|
+
workspacePath: string
|
|
211
|
+
head: string
|
|
212
|
+
base: string
|
|
213
|
+
title: string
|
|
214
|
+
body: string
|
|
215
|
+
mergeMessage?: string
|
|
216
|
+
}): Promise<PrRef> {
|
|
217
|
+
const headRef = `refs/heads/${opts.head}`
|
|
218
|
+
const baseRef = `refs/heads/${opts.base}`
|
|
219
|
+
await this.assertRef(headRef, opts.workspacePath)
|
|
220
|
+
await this.assertRef(baseRef, opts.workspacePath)
|
|
221
|
+
const headSha = await this.sha(headRef, opts.workspacePath)
|
|
222
|
+
await this.sha(baseRef, opts.workspacePath)
|
|
223
|
+
|
|
224
|
+
for (;;) {
|
|
225
|
+
const records = await this.records(opts.workspacePath)
|
|
226
|
+
const adopted = records.find(
|
|
227
|
+
(record) => record.status === 'open' && record.head === opts.head,
|
|
228
|
+
)
|
|
229
|
+
if (adopted !== undefined) {
|
|
230
|
+
return { number: adopted.number, url: headRef, headSha }
|
|
231
|
+
}
|
|
232
|
+
const number = (records.at(-1)?.number ?? 0) + 1
|
|
233
|
+
const created: LocalPrRecord = {
|
|
234
|
+
version: 1,
|
|
235
|
+
number,
|
|
236
|
+
head: opts.head,
|
|
237
|
+
base: opts.base,
|
|
238
|
+
title: opts.title,
|
|
239
|
+
body: opts.body,
|
|
240
|
+
mergeMessage: opts.mergeMessage ?? [opts.title, opts.body].filter(Boolean).join('\n\n'),
|
|
241
|
+
openedHeadSha: headSha,
|
|
242
|
+
status: 'open',
|
|
243
|
+
comments: [],
|
|
244
|
+
}
|
|
245
|
+
if (await this.createRecord(opts.workspacePath, created)) {
|
|
246
|
+
return { number, url: headRef, headSha }
|
|
247
|
+
}
|
|
248
|
+
// Another process allocated this number. Re-scan so an equivalent
|
|
249
|
+
// creation is adopted before considering the next number.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async mergeTree(cwd: string, baseSha: string, headSha: string): Promise<string | null> {
|
|
254
|
+
const result = await this.command(['merge-tree', '--write-tree', baseSha, headSha], cwd, true)
|
|
255
|
+
if (result.exitCode === 1) return null
|
|
256
|
+
if (result.exitCode !== 0) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`local-git mergeability check failed (exit ${result.exitCode}): ` +
|
|
259
|
+
`${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
const tree = result.stdout.split('\n', 1)[0]!.trim()
|
|
263
|
+
if (!objectId.safeParse(tree).success) {
|
|
264
|
+
throw new Error(`local-git merge-tree returned malformed tree ${JSON.stringify(tree)}`)
|
|
265
|
+
}
|
|
266
|
+
return tree
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async isAncestor(cwd: string, ancestor: string, descendant: string): Promise<boolean> {
|
|
270
|
+
const result = await this.command(
|
|
271
|
+
['merge-base', '--is-ancestor', ancestor, descendant],
|
|
272
|
+
cwd,
|
|
273
|
+
true,
|
|
274
|
+
)
|
|
275
|
+
if (result.exitCode === 0) return true
|
|
276
|
+
if (result.exitCode === 1) return false
|
|
277
|
+
throw new Error(`local-git could not compare landing ancestry: ${result.stderr.trim()}`)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Apply Git's two-tree fast-forward operation. It carries index/worktree
|
|
281
|
+
* changes on paths unchanged by the landing and refuses any tracked or
|
|
282
|
+
* untracked overwrite. `dryRun` exercises those exact checks without writing. */
|
|
283
|
+
private async transitionCheckout(
|
|
284
|
+
checkout: string,
|
|
285
|
+
from: string,
|
|
286
|
+
to: string,
|
|
287
|
+
dryRun: boolean,
|
|
288
|
+
): Promise<{ ok: true } | { ok: false; detail: string }> {
|
|
289
|
+
const result = await this.command(
|
|
290
|
+
['read-tree', '-m', '-u', ...(dryRun ? ['-n'] : []), `${from}^{tree}`, `${to}^{tree}`],
|
|
291
|
+
checkout,
|
|
292
|
+
true,
|
|
293
|
+
)
|
|
294
|
+
if (result.exitCode === 0) return { ok: true }
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
detail:
|
|
298
|
+
result.stderr.trim() ||
|
|
299
|
+
result.stdout.trim() ||
|
|
300
|
+
`git read-tree refused the checkout transition (exit ${result.exitCode})`,
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Repair the crash window after the base ref moved but before its checked-out
|
|
305
|
+
* index/worktree was updated. A collision remains retryable and untouched. */
|
|
306
|
+
private async repairObservedCheckout(
|
|
307
|
+
cwd: string,
|
|
308
|
+
baseRef: string,
|
|
309
|
+
pending: z.infer<typeof pendingLanding>,
|
|
310
|
+
): Promise<boolean> {
|
|
311
|
+
const checkout = await this.checkedOutBase(cwd, baseRef)
|
|
312
|
+
if (checkout === undefined) return true
|
|
313
|
+
return (await this.transitionCheckout(checkout, pending.baseSha, pending.commitSha, false)).ok
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async getPrState(workspacePath: string, number: number): Promise<PrState> {
|
|
317
|
+
let { record } = await this.readRecord(workspacePath, number)
|
|
318
|
+
if (record.status === 'merged') return { state: 'merged', sha: record.mergedSha! }
|
|
319
|
+
if (record.status === 'closed') return { state: 'closed' }
|
|
320
|
+
|
|
321
|
+
const baseRef = `refs/heads/${record.base}`
|
|
322
|
+
const baseSha = await this.sha(baseRef, workspacePath)
|
|
323
|
+
if (
|
|
324
|
+
record.pendingLanding !== undefined &&
|
|
325
|
+
(await this.isAncestor(workspacePath, record.pendingLanding.commitSha, baseSha))
|
|
326
|
+
) {
|
|
327
|
+
const landingSha = record.pendingLanding.commitSha
|
|
328
|
+
// At the exact ref-move crash boundary, merged observation waits until
|
|
329
|
+
// the checkout transition succeeds. A later descendant means another Git
|
|
330
|
+
// operation already advanced the landed base, so there is no old checkout
|
|
331
|
+
// left for this pending transition to repair.
|
|
332
|
+
if (
|
|
333
|
+
baseSha === landingSha &&
|
|
334
|
+
!(await this.repairObservedCheckout(workspacePath, baseRef, record.pendingLanding))
|
|
335
|
+
) {
|
|
336
|
+
return { state: 'open', mergeable: true }
|
|
337
|
+
}
|
|
338
|
+
record = await this.updateRecord(workspacePath, number, (current) => ({
|
|
339
|
+
...current,
|
|
340
|
+
status: 'merged',
|
|
341
|
+
mergedSha: landingSha,
|
|
342
|
+
pendingLanding: undefined,
|
|
343
|
+
}))
|
|
344
|
+
return { state: 'merged', sha: record.mergedSha! }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const headRef = `refs/heads/${record.head}`
|
|
348
|
+
const head = await this.command(
|
|
349
|
+
['rev-parse', '--verify', `${headRef}^{commit}`],
|
|
350
|
+
workspacePath,
|
|
351
|
+
true,
|
|
352
|
+
)
|
|
353
|
+
if (head.exitCode !== 0) {
|
|
354
|
+
await this.updateRecord(workspacePath, number, (current) => ({
|
|
355
|
+
...current,
|
|
356
|
+
status: 'closed',
|
|
357
|
+
}))
|
|
358
|
+
return { state: 'closed' }
|
|
359
|
+
}
|
|
360
|
+
const headSha = head.stdout.trim()
|
|
361
|
+
if (!objectId.safeParse(headSha).success) {
|
|
362
|
+
throw new Error(`local-git PR #${number} head ref is malformed`)
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
state: 'open',
|
|
366
|
+
mergeable: (await this.mergeTree(workspacePath, baseSha, headSha)) !== null,
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async closePr(workspacePath: string, number: number): Promise<ClosePrResult> {
|
|
371
|
+
const current = await this.getPrState(workspacePath, number)
|
|
372
|
+
if (current.state === 'merged') return current
|
|
373
|
+
if (current.state === 'closed') return current
|
|
374
|
+
const record = await this.updateRecord(workspacePath, number, (value) =>
|
|
375
|
+
value.status === 'merged' ? value : { ...value, status: 'closed', pendingLanding: undefined },
|
|
376
|
+
)
|
|
377
|
+
return record.status === 'merged'
|
|
378
|
+
? { state: 'merged', sha: record.mergedSha! }
|
|
379
|
+
: { state: 'closed' }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async deleteBranch(workspacePath: string, branch: string): Promise<void> {
|
|
383
|
+
const ref = `refs/heads/${branch}`
|
|
384
|
+
await this.assertRef(ref, workspacePath)
|
|
385
|
+
await this.command(['update-ref', '-d', ref], workspacePath)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async setAutoMerge(
|
|
389
|
+
workspacePath: string,
|
|
390
|
+
number: number,
|
|
391
|
+
enabled: boolean,
|
|
392
|
+
): Promise<AutoMergeResult> {
|
|
393
|
+
const { record } = await this.readRecord(workspacePath, number)
|
|
394
|
+
if (!enabled) return { kind: 'applied' }
|
|
395
|
+
if (record.status !== 'open') return { kind: 'deferred' }
|
|
396
|
+
const headSha = await this.sha(`refs/heads/${record.head}`, workspacePath)
|
|
397
|
+
const baseRef = `refs/heads/${record.base}`
|
|
398
|
+
const baseSha = await this.sha(baseRef, workspacePath)
|
|
399
|
+
|
|
400
|
+
let transitionFrom = baseSha
|
|
401
|
+
let transitionTo: string
|
|
402
|
+
const pending = record.pendingLanding
|
|
403
|
+
if (
|
|
404
|
+
pending !== undefined &&
|
|
405
|
+
pending.headSha === headSha &&
|
|
406
|
+
(baseSha === pending.baseSha || baseSha === pending.commitSha)
|
|
407
|
+
) {
|
|
408
|
+
// This includes the post-ref checkout-repair window. Always inspect the
|
|
409
|
+
// recorded old-to-landed transition; current-base-to-head is a no-op there
|
|
410
|
+
// and would conceal the operator path that still blocks repair.
|
|
411
|
+
transitionFrom = pending.baseSha
|
|
412
|
+
transitionTo = pending.commitSha
|
|
413
|
+
} else {
|
|
414
|
+
const treeSha = await this.mergeTree(workspacePath, baseSha, headSha)
|
|
415
|
+
if (treeSha === null) return { kind: 'deferred' }
|
|
416
|
+
transitionTo = treeSha
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const checkout = await this.checkedOutBase(workspacePath, baseRef)
|
|
420
|
+
if (checkout !== undefined) {
|
|
421
|
+
const preflight = await this.transitionCheckout(checkout, transitionFrom, transitionTo, true)
|
|
422
|
+
if (!preflight.ok) {
|
|
423
|
+
return {
|
|
424
|
+
kind: 'deferred',
|
|
425
|
+
reason: { code: 'local-base-checkout-dirty', detail: preflight.detail },
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const identityFailures: string[] = []
|
|
431
|
+
for (const variable of ['GIT_AUTHOR_IDENT', 'GIT_COMMITTER_IDENT']) {
|
|
432
|
+
const result = await this.command(['var', variable], workspacePath, true)
|
|
433
|
+
if (result.exitCode !== 0) {
|
|
434
|
+
identityFailures.push(
|
|
435
|
+
`${variable}: ${result.stderr.trim() || result.stdout.trim() || `git var failed (exit ${result.exitCode})`}`,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (identityFailures.length > 0) {
|
|
440
|
+
return {
|
|
441
|
+
kind: 'deferred',
|
|
442
|
+
reason: {
|
|
443
|
+
code: 'local-git-identity-missing',
|
|
444
|
+
detail:
|
|
445
|
+
'Configure the repository identity with `git config user.name "Your Name"` and ' +
|
|
446
|
+
'`git config user.email "you@example.com"`. ' +
|
|
447
|
+
identityFailures.join(' | '),
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return { kind: 'ungated', headSha }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private async checkedOutBase(cwd: string, baseRef: string): Promise<string | undefined> {
|
|
455
|
+
const result = await this.command(['worktree', 'list', '--porcelain'], cwd)
|
|
456
|
+
let path: string | undefined
|
|
457
|
+
for (const line of `${result.stdout}\n`.split('\n')) {
|
|
458
|
+
if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
|
|
459
|
+
if (line === `branch ${baseRef}`) return path
|
|
460
|
+
if (line === '') path = undefined
|
|
461
|
+
}
|
|
462
|
+
return undefined
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async commitTree(
|
|
466
|
+
cwd: string,
|
|
467
|
+
treeSha: string,
|
|
468
|
+
baseSha: string,
|
|
469
|
+
message: string,
|
|
470
|
+
): Promise<string> {
|
|
471
|
+
const dir = await mkdtemp(join(tmpdir(), 'ab-local-git-commit-'))
|
|
472
|
+
try {
|
|
473
|
+
const path = join(dir, 'message.md')
|
|
474
|
+
await writeFile(path, message, 'utf8')
|
|
475
|
+
const result = await this.command(
|
|
476
|
+
['-c', 'commit.gpgSign=false', 'commit-tree', treeSha, '-p', baseSha, '-F', path],
|
|
477
|
+
cwd,
|
|
478
|
+
)
|
|
479
|
+
const sha = result.stdout.trim()
|
|
480
|
+
if (!objectId.safeParse(sha).success)
|
|
481
|
+
throw new Error('local-git commit-tree returned no commit')
|
|
482
|
+
return sha
|
|
483
|
+
} finally {
|
|
484
|
+
await rm(dir, { recursive: true, force: true })
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async squashMerge(workspacePath: string, number: number, expectedHeadSha: string): Promise<void> {
|
|
489
|
+
let { record } = await this.readRecord(workspacePath, number)
|
|
490
|
+
if (record.status === 'merged') return
|
|
491
|
+
const headRef = `refs/heads/${record.head}`
|
|
492
|
+
const baseRef = `refs/heads/${record.base}`
|
|
493
|
+
const headSha = await this.sha(headRef, workspacePath)
|
|
494
|
+
if (headSha !== expectedHeadSha) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`local-git PR #${number} head changed (expected ${expectedHeadSha}, found ${headSha})`,
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
let baseSha = await this.sha(baseRef, workspacePath)
|
|
500
|
+
|
|
501
|
+
if (record.pendingLanding !== undefined) {
|
|
502
|
+
if (await this.isAncestor(workspacePath, record.pendingLanding.commitSha, baseSha)) return
|
|
503
|
+
if (record.pendingLanding.baseSha === baseSha && record.pendingLanding.headSha === headSha) {
|
|
504
|
+
// Resume the crash window after pending evidence but before ref update.
|
|
505
|
+
} else {
|
|
506
|
+
record = await this.updateRecord(workspacePath, number, (current) => ({
|
|
507
|
+
...current,
|
|
508
|
+
pendingLanding: undefined,
|
|
509
|
+
}))
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let pending = record.pendingLanding
|
|
514
|
+
if (pending === undefined) {
|
|
515
|
+
const treeSha = await this.mergeTree(workspacePath, baseSha, headSha)
|
|
516
|
+
if (treeSha === null) throw new Error(`local-git PR #${number} is not mergeable`)
|
|
517
|
+
const commitSha = await this.commitTree(workspacePath, treeSha, baseSha, record.mergeMessage)
|
|
518
|
+
pending = { baseSha, headSha, treeSha, commitSha }
|
|
519
|
+
record = await this.updateRecord(workspacePath, number, (current) => ({
|
|
520
|
+
...current,
|
|
521
|
+
pendingLanding: pending,
|
|
522
|
+
}))
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
baseSha = await this.sha(baseRef, workspacePath)
|
|
526
|
+
if (baseSha !== pending.baseSha) {
|
|
527
|
+
if (await this.isAncestor(workspacePath, pending.commitSha, baseSha)) return
|
|
528
|
+
throw new Error(`local-git base ${baseRef} moved during guarded squash inspection`)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const checkout = await this.checkedOutBase(workspacePath, baseRef)
|
|
532
|
+
if (checkout !== undefined) {
|
|
533
|
+
// Close the inspection-to-landing race at the last safe point. A refusal
|
|
534
|
+
// is a condition, not a failed dispatcher tick; pending evidence remains
|
|
535
|
+
// reusable and the next poll reports the typed deferral.
|
|
536
|
+
const preflight = await this.transitionCheckout(
|
|
537
|
+
checkout,
|
|
538
|
+
pending.baseSha,
|
|
539
|
+
pending.commitSha,
|
|
540
|
+
true,
|
|
541
|
+
)
|
|
542
|
+
if (!preflight.ok) return
|
|
543
|
+
}
|
|
544
|
+
const moved = await this.command(
|
|
545
|
+
['update-ref', baseRef, pending.commitSha, pending.baseSha],
|
|
546
|
+
workspacePath,
|
|
547
|
+
true,
|
|
548
|
+
)
|
|
549
|
+
if (moved.exitCode !== 0) {
|
|
550
|
+
throw new Error(`local-git base ${baseRef} moved before squash landing`)
|
|
551
|
+
}
|
|
552
|
+
if (checkout !== undefined) {
|
|
553
|
+
// A racing operator edit can still appear after preflight. Never roll the
|
|
554
|
+
// ref back or overwrite it: leave the pending landing for getPrState and
|
|
555
|
+
// setAutoMerge to repair/report on a later poll.
|
|
556
|
+
await this.transitionCheckout(checkout, pending.baseSha, pending.commitSha, false)
|
|
557
|
+
}
|
|
558
|
+
// Deliberately do not mark the record merged here. The next getPrState
|
|
559
|
+
// independently observes the landed commit and records that fact.
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async commentOnPr(workspacePath: string, number: number, body: string): Promise<void> {
|
|
563
|
+
await this.updateRecord(workspacePath, number, (record) => ({
|
|
564
|
+
...record,
|
|
565
|
+
comments: record.comments.includes(body) ? record.comments : [...record.comments, body],
|
|
566
|
+
}))
|
|
567
|
+
}
|
|
568
|
+
}
|