@ucsandman/legcli 0.7.0 → 0.9.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/CHANGELOG.md +57 -0
- package/NOTICE +8 -0
- package/README.md +601 -558
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +64 -34
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +71 -0
- package/docs/README.md +2 -0
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +21 -0
- package/docs/adapters.md +17 -3
- package/docs/board-guide.md +13 -0
- package/docs/cli-contracts.md +57 -3
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +42 -2
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/fixtures/limits/grok/grok-rate-limit.json +11 -0
- package/fixtures/live/agy/limit-agy-resource-exhausted.json +11 -0
- package/fixtures/verified.json +1 -1
- package/package.json +8 -4
- package/scripts/build-docs-site.mjs +15 -7
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +10 -2
- package/src/adapters/codex.mjs +1 -1
- package/src/adapters/grok.mjs +4 -7
- package/src/attach.mjs +162 -37
- package/src/auth.mjs +2 -2
- package/src/board/board.css +45 -17
- package/src/board/board.js +4 -4
- package/src/board/floor.js +2 -2
- package/src/board/sessions.js +181 -38
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- package/src/hook.mjs +49 -49
- package/src/land.mjs +660 -47
- package/src/launcher.mjs +40 -27
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +6 -6
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +63 -9
- package/src/redact.mjs +1 -1
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +3 -3
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +69 -20
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +9 -5
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +170 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +251 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +5 -4
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
package/src/land.mjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// land — the land station handler the orchestrator calls, and the Land button
|
|
2
|
-
// on a terminal card.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
import { existsSync } from 'node:fs'
|
|
2
|
+
// on a terminal card. Guarded state machine: canLand(worktreeId) validates all
|
|
3
|
+
// pre-conditions before landing is enabled. Two-phase landing: prepare dry-runs
|
|
4
|
+
// rebase and tests in a scratch worktree; atomic land updates trunk only when green,
|
|
5
|
+
// auto-pushes to origin, and writes ledger entries for both success and failure.
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, rmSync } from 'node:fs'
|
|
8
8
|
import { spawnSync } from 'node:child_process'
|
|
9
|
-
import {
|
|
9
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
10
|
+
import { land, resolveTestCommand, runTests, rootState, commitWorktree } from './mergequeue.mjs'
|
|
10
11
|
import { openPr } from './stations/pr.mjs'
|
|
11
|
-
import { cardDir, ledgerAppend } from './store.mjs'
|
|
12
|
-
import { remove as removeWorktree, ensureExcludeEntries } from './worktree.mjs'
|
|
13
|
-
import { appendEvent, writeLand, appendLanding } from './sessions.mjs'
|
|
12
|
+
import { cardDir, ledgerAppend, home, readCard } from './store.mjs'
|
|
13
|
+
import { remove as removeWorktree, ensureExcludeEntries, branchName } from './worktree.mjs'
|
|
14
|
+
import { appendEvent, writeLand, appendLanding, listSessions, readSession, isActive, pidAlive } from './sessions.mjs'
|
|
14
15
|
import { scrub } from './redact.mjs'
|
|
16
|
+
import { canonPath, withFileLock, writeJsonAtomic } from './fsx.mjs'
|
|
15
17
|
|
|
16
18
|
export async function landCard(card, worktree) {
|
|
17
19
|
const warn = (msg) => ledgerAppend(card.card_id, { type: 'land_warning', station: card.station, leg: 0, summary: msg })
|
|
@@ -23,13 +25,391 @@ export async function landCard(card, worktree) {
|
|
|
23
25
|
return land(card, worktree, { onWarning: warn })
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
// ---- terminals ----
|
|
28
|
+
// ---- terminals & in-flight tracking ----
|
|
27
29
|
const inFlight = new Set()
|
|
28
30
|
export function landingNow(id) { return inFlight.has(id) }
|
|
29
31
|
|
|
30
32
|
function git(cwd, args) {
|
|
31
33
|
const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
32
|
-
return { ok: r.status === 0, out: (r.stdout ?? '').trim() }
|
|
34
|
+
return { ok: r.status === 0, status: r.status, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- target-branch locking (keyed on canonical repo path + branch) ----
|
|
38
|
+
export function targetLockFile(repo, branch) {
|
|
39
|
+
const dir = join(home(), 'locks')
|
|
40
|
+
mkdirSync(dir, { recursive: true })
|
|
41
|
+
const key = `${canonPath(repo)}:${branch}`
|
|
42
|
+
return join(dir, `target-${createHash('sha1').update(key).digest('hex').slice(0, 16)}.json`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function checkTargetLock(repo, branch) {
|
|
46
|
+
const file = targetLockFile(repo, branch)
|
|
47
|
+
if (!existsSync(file)) return { locked: false }
|
|
48
|
+
try {
|
|
49
|
+
const lock = JSON.parse(readFileSync(file, 'utf8'))
|
|
50
|
+
const alive = pidAlive(lock.pid)
|
|
51
|
+
const timeoutMs = Number(process.env.LEG_LAND_TEST_TIMEOUT_MS || process.env.BATON_LAND_TEST_TIMEOUT_MS || 600000) + 120000
|
|
52
|
+
const expired = Date.now() - lock.ts > timeoutMs
|
|
53
|
+
if (!alive || expired) {
|
|
54
|
+
try { unlinkSync(file) } catch {}
|
|
55
|
+
return { locked: false, reaped: true }
|
|
56
|
+
}
|
|
57
|
+
return { locked: true, sessionId: lock.session_id, pid: lock.pid, ts: lock.ts, token: lock.token }
|
|
58
|
+
} catch {
|
|
59
|
+
return { locked: false }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function acquireTargetLock(repo, branch, sessionId, { timeoutMs = 60000, waitMs = 200 } = {}) {
|
|
64
|
+
const file = targetLockFile(repo, branch)
|
|
65
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
66
|
+
const deadline = Date.now() + timeoutMs
|
|
67
|
+
while (Date.now() < deadline) {
|
|
68
|
+
const acquired = withFileLock(`${file}.lock`, () => {
|
|
69
|
+
const lock = checkTargetLock(repo, branch)
|
|
70
|
+
if (lock.locked && lock.sessionId !== sessionId && lock.token !== token) return false
|
|
71
|
+
writeJsonAtomic(file, { token, pid: process.pid, ts: Date.now(), session_id: sessionId, repo: canonPath(repo), branch })
|
|
72
|
+
return true
|
|
73
|
+
})
|
|
74
|
+
if (acquired) return { token, file }
|
|
75
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs)
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`timeout waiting for target-branch lock for ${branch} on ${repo}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function releaseTargetLock(repo, branch, token) {
|
|
81
|
+
const file = targetLockFile(repo, branch)
|
|
82
|
+
withFileLock(`${file}.lock`, () => {
|
|
83
|
+
try {
|
|
84
|
+
if (existsSync(file)) {
|
|
85
|
+
const lock = JSON.parse(readFileSync(file, 'utf8'))
|
|
86
|
+
if (!token || lock.token === token || lock.pid === process.pid) {
|
|
87
|
+
unlinkSync(file)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- queued landings ----
|
|
95
|
+
const landingQueue = new Map()
|
|
96
|
+
|
|
97
|
+
export function queueLanding(sessionId, { by = 'local' } = {}) {
|
|
98
|
+
const s = readSession(sessionId)
|
|
99
|
+
if (!s || !s.worktree) return { ok: false, error: 'no worktree' }
|
|
100
|
+
const base = s.worktree.base || 'main'
|
|
101
|
+
const key = `${canonPath(s.repo)}:${base}`
|
|
102
|
+
const list = landingQueue.get(key) || []
|
|
103
|
+
if (!list.some((item) => item.sessionId === sessionId)) {
|
|
104
|
+
list.push({ sessionId, by, queuedAt: Date.now() })
|
|
105
|
+
landingQueue.set(key, list)
|
|
106
|
+
appendEvent(sessionId, { type: 'land_queued', by, summary: `landing queued for ${s.worktree.branch} onto ${base}` })
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, queued: true, position: list.length }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isLandingQueued(sessionId) {
|
|
112
|
+
for (const list of landingQueue.values()) {
|
|
113
|
+
if (list.some((item) => item.sessionId === sessionId)) return true
|
|
114
|
+
}
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function drainLandingQueue(repo, branch) {
|
|
119
|
+
if (!repo || !branch) return
|
|
120
|
+
const key = `${canonPath(repo)}:${branch}`
|
|
121
|
+
const list = landingQueue.get(key)
|
|
122
|
+
if (!list || !list.length) return
|
|
123
|
+
const next = list[0]
|
|
124
|
+
const s = readSession(next.sessionId)
|
|
125
|
+
if (!s) {
|
|
126
|
+
list.shift()
|
|
127
|
+
return drainLandingQueue(repo, branch)
|
|
128
|
+
}
|
|
129
|
+
const check = canLand(s)
|
|
130
|
+
if (check.ok) {
|
|
131
|
+
list.shift()
|
|
132
|
+
try {
|
|
133
|
+
await landSession(s, { by: next.by })
|
|
134
|
+
} catch {
|
|
135
|
+
// logged in landSession
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- resolve worktree context ----
|
|
141
|
+
export function resolveWorktreeContext(worktreeId) {
|
|
142
|
+
if (!worktreeId) return null
|
|
143
|
+
if (typeof worktreeId === 'object') {
|
|
144
|
+
if (worktreeId.worktree) {
|
|
145
|
+
return {
|
|
146
|
+
session: worktreeId,
|
|
147
|
+
repo: worktreeId.repo,
|
|
148
|
+
path: worktreeId.worktree.path,
|
|
149
|
+
branch: worktreeId.worktree.branch,
|
|
150
|
+
base: worktreeId.worktree.base || 'main',
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (worktreeId.path) {
|
|
154
|
+
return {
|
|
155
|
+
session: worktreeId.session || null,
|
|
156
|
+
repo: worktreeId.repo || worktreeId.path,
|
|
157
|
+
path: worktreeId.path,
|
|
158
|
+
branch: worktreeId.branch,
|
|
159
|
+
base: worktreeId.base || 'main',
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const str = String(worktreeId)
|
|
164
|
+
const s = readSession(str)
|
|
165
|
+
if (s) {
|
|
166
|
+
if (s.worktree) {
|
|
167
|
+
return {
|
|
168
|
+
session: s,
|
|
169
|
+
repo: s.repo,
|
|
170
|
+
path: s.worktree.path,
|
|
171
|
+
branch: s.worktree.branch,
|
|
172
|
+
base: s.worktree.base || 'main',
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
session: s,
|
|
177
|
+
repo: s.repo,
|
|
178
|
+
path: s.cwd || s.repo,
|
|
179
|
+
branch: s.branch || 'main',
|
|
180
|
+
base: 'main',
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const c = readCard(str)
|
|
184
|
+
if (c) {
|
|
185
|
+
return {
|
|
186
|
+
card: c,
|
|
187
|
+
repo: c.repo,
|
|
188
|
+
path: c.worktree,
|
|
189
|
+
branch: branchName(c.card_id),
|
|
190
|
+
base: c.trunk || 'main',
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (existsSync(str)) {
|
|
194
|
+
const toplevel = git(str, ['rev-parse', '--show-toplevel']).out
|
|
195
|
+
const branch = git(str, ['symbolic-ref', '--short', '-q', 'HEAD']).out || 'HEAD'
|
|
196
|
+
return {
|
|
197
|
+
repo: toplevel || str,
|
|
198
|
+
path: str,
|
|
199
|
+
branch,
|
|
200
|
+
base: 'main',
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- safe fixes ----
|
|
207
|
+
export async function applyLandFix(sessionId, action, opts = {}) {
|
|
208
|
+
const s = readSession(sessionId)
|
|
209
|
+
if (!s) throw new Error(`session ${sessionId} not found`)
|
|
210
|
+
const wt = s.worktree?.path || s.cwd || s.repo
|
|
211
|
+
if (!existsSync(wt)) throw new Error(`worktree path ${wt} does not exist`)
|
|
212
|
+
|
|
213
|
+
if (action === 'commit') {
|
|
214
|
+
git(wt, ['add', '-A'])
|
|
215
|
+
const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
|
|
216
|
+
const msg = opts.message || 'leg: save work before landing'
|
|
217
|
+
const r = git(wt, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', msg])
|
|
218
|
+
if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
|
|
219
|
+
appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'committed uncommitted work in worktree' })
|
|
220
|
+
return { ok: true, action: 'commit' }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (action === 'stash') {
|
|
224
|
+
const r = git(wt, ['stash', '-u', '-m', 'leg: stash before landing'])
|
|
225
|
+
if (!r.ok) throw new Error(`git stash failed: ${r.err || r.out}`)
|
|
226
|
+
appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'stashed uncommitted changes in worktree' })
|
|
227
|
+
return { ok: true, action: 'stash' }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (action === 'rebase_now') {
|
|
231
|
+
const base = s.worktree?.base || 'main'
|
|
232
|
+
const r = git(wt, ['rebase', base])
|
|
233
|
+
appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `started rebase onto ${base} in worktree` })
|
|
234
|
+
return { ok: r.ok, action: 'rebase_now', detail: r.out || r.err }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (action === 'queue_landing') {
|
|
238
|
+
return queueLanding(sessionId, { by: opts.by || 'local' })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (action === 'commit_directly') {
|
|
242
|
+
git(wt, ['add', '-A'])
|
|
243
|
+
const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
|
|
244
|
+
const msg = opts.message || `leg: commit directly on ${s.branch || s.worktree?.base || 'main'}`
|
|
245
|
+
const r = git(wt, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', msg])
|
|
246
|
+
if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
|
|
247
|
+
appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `committed directly on ${s.branch || 'main'}` })
|
|
248
|
+
return { ok: true, action: 'commit_directly' }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
throw new Error(`unknown fix action: ${action}`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- canLand: The Guarded State Machine ----
|
|
255
|
+
export function canLand(worktreeId, opts = {}) {
|
|
256
|
+
const ctx = resolveWorktreeContext(worktreeId)
|
|
257
|
+
if (!ctx) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
blockers: [{
|
|
261
|
+
code: 'no_worktree',
|
|
262
|
+
message: 'this terminal works in the checkout itself: there is no branch of its own to land',
|
|
263
|
+
fix: null,
|
|
264
|
+
}],
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { session, repo, path, branch, base } = ctx
|
|
269
|
+
const blockers = []
|
|
270
|
+
|
|
271
|
+
if (!path || !existsSync(path)) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
blockers: [{
|
|
275
|
+
code: 'worktree_gone',
|
|
276
|
+
message: `the worktree ${path ?? ''} is gone`,
|
|
277
|
+
fix: null,
|
|
278
|
+
}],
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!base) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
blockers: [{
|
|
286
|
+
code: 'no_base',
|
|
287
|
+
message: `${branch} was cut from a detached HEAD: there is no branch to land it onto`,
|
|
288
|
+
fix: null,
|
|
289
|
+
}],
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Guard 3: Worktree is sitting on target branch itself (e.g. working directly on main)
|
|
294
|
+
if (branch === base) {
|
|
295
|
+
blockers.push({
|
|
296
|
+
code: 'on_target_branch',
|
|
297
|
+
message: `Worktree is on ${base} directly: landing onto itself is meaningless`,
|
|
298
|
+
fix: { label: 'Commit directly', action: 'commit_directly' },
|
|
299
|
+
fixes: [{ label: 'Commit directly', action: 'commit_directly' }],
|
|
300
|
+
})
|
|
301
|
+
return { ok: false, blockers }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Guard 4: A session attached to this worktree is still executing turns
|
|
305
|
+
if (!opts.ignoreRunning && session && session.status === 'running') {
|
|
306
|
+
blockers.push({
|
|
307
|
+
code: 'session_running',
|
|
308
|
+
message: 'Session still running',
|
|
309
|
+
fix: null,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Guard 2: Working tree dirty
|
|
314
|
+
const st = git(path, ['status', '--porcelain'])
|
|
315
|
+
if (st.ok && st.out.length > 0) {
|
|
316
|
+
blockers.push({
|
|
317
|
+
code: 'working_tree_dirty',
|
|
318
|
+
message: 'Working tree dirty: uncommitted changes in worktree',
|
|
319
|
+
fix: { label: 'Commit now', action: 'commit' },
|
|
320
|
+
fixes: [
|
|
321
|
+
{ label: 'Commit now', action: 'commit' },
|
|
322
|
+
{ label: 'Stash', action: 'stash' },
|
|
323
|
+
],
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Guard 1: Branch has zero commits ahead of target
|
|
328
|
+
const revList = git(repo, ['rev-list', '--count', `${base}..${branch}`])
|
|
329
|
+
const commitsAhead = revList.ok ? parseInt(revList.out, 10) : 0
|
|
330
|
+
if (commitsAhead === 0) {
|
|
331
|
+
blockers.push({
|
|
332
|
+
code: 'nothing_to_land',
|
|
333
|
+
message: `Nothing to land: ${branch} has zero commits ahead of ${base}`,
|
|
334
|
+
fix: null,
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Guard 7: Target branch locked by another landing in progress
|
|
339
|
+
const lock = checkTargetLock(repo, base)
|
|
340
|
+
if (lock.locked && lock.sessionId !== session?.session_id) {
|
|
341
|
+
blockers.push({
|
|
342
|
+
code: 'target_locked',
|
|
343
|
+
message: `Queued behind ${lock.sessionId || 'another session'}'s landing`,
|
|
344
|
+
holder: lock.sessionId,
|
|
345
|
+
fix: null,
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
if (!opts.ignoreInFlight && session && inFlight.has(session.session_id)) {
|
|
349
|
+
blockers.push({
|
|
350
|
+
code: 'already_landing',
|
|
351
|
+
message: 'already landing',
|
|
352
|
+
fix: null,
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Guard 5: Target branch is checked out and being edited by another active session
|
|
357
|
+
if (!opts.ignoreTargetActive) {
|
|
358
|
+
try {
|
|
359
|
+
const sessions = listSessions()
|
|
360
|
+
for (const other of sessions) {
|
|
361
|
+
if (other.session_id === session?.session_id) continue
|
|
362
|
+
if (!other.repo || canonPath(other.repo) !== canonPath(repo)) continue
|
|
363
|
+
if (!isActive(other)) continue
|
|
364
|
+
const otherBranch = other.worktree ? other.worktree.branch : other.branch
|
|
365
|
+
if (otherBranch === base) {
|
|
366
|
+
const otherDirty = (other.files_dirty && other.files_dirty.length > 0) ||
|
|
367
|
+
(other.worktree && existsSync(other.worktree.path) && git(other.worktree.path, ['status', '--porcelain']).out.length > 0)
|
|
368
|
+
if (otherDirty) {
|
|
369
|
+
blockers.push({
|
|
370
|
+
code: 'target_branch_active',
|
|
371
|
+
message: `Target branch ${base} is checked out and being edited by active session ${other.session_id}${other.agent ? ` (${other.agent})` : ''}`,
|
|
372
|
+
target_session: other.session_id,
|
|
373
|
+
fix: { label: 'Queue my landing', action: 'queue_landing', target_session: other.session_id },
|
|
374
|
+
fixes: [{ label: 'Queue my landing', action: 'queue_landing', target_session: other.session_id }],
|
|
375
|
+
})
|
|
376
|
+
break
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch {}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Guard 6: Rebase dry-run onto target produces conflicts
|
|
384
|
+
if (commitsAhead > 0) {
|
|
385
|
+
const mt = git(repo, ['merge-tree', '--write-tree', base, branch])
|
|
386
|
+
if (!mt.ok) {
|
|
387
|
+
const conflictedFiles = [...new Set([...mt.out.matchAll(/CONFLICT.*? in (.*)$/gm)].map((m) => m[1].trim()))]
|
|
388
|
+
const filesList = conflictedFiles.length ? conflictedFiles.join(', ') : 'conflicting files'
|
|
389
|
+
blockers.push({
|
|
390
|
+
code: 'rebase_conflict',
|
|
391
|
+
message: `rebase onto ${base} conflicted in: ${filesList}`,
|
|
392
|
+
files: conflictedFiles,
|
|
393
|
+
fix: { label: 'Rebase now', action: 'rebase_now' },
|
|
394
|
+
fixes: [{ label: 'Rebase now', action: 'rebase_now' }],
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Guard 8: Tests (if passed via opts)
|
|
400
|
+
if (opts.testResult && !opts.testResult.green) {
|
|
401
|
+
blockers.push({
|
|
402
|
+
code: 'test_failure',
|
|
403
|
+
message: `Test command failed (${opts.testResult.command ?? 'tests'} exit ${opts.testResult.status ?? 1}):\n${opts.testResult.tail ?? ''}`,
|
|
404
|
+
output: opts.testResult.tail ?? '',
|
|
405
|
+
fix: null,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
ok: blockers.length === 0,
|
|
411
|
+
blockers,
|
|
412
|
+
}
|
|
33
413
|
}
|
|
34
414
|
|
|
35
415
|
// Why this session cannot land right now, or null.
|
|
@@ -41,46 +421,283 @@ export function landBlocker(s) {
|
|
|
41
421
|
return null
|
|
42
422
|
}
|
|
43
423
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
424
|
+
// ---- Two-phase: Prepare landing ----
|
|
425
|
+
export async function prepareLanding(worktreeId) {
|
|
426
|
+
const cl = canLand(worktreeId)
|
|
427
|
+
if (!cl.ok) {
|
|
428
|
+
return { ok: false, blockers: cl.blockers, error: cl.blockers[0].message }
|
|
429
|
+
}
|
|
430
|
+
const ctx = resolveWorktreeContext(worktreeId)
|
|
431
|
+
const { session, repo, branch, base } = ctx
|
|
432
|
+
|
|
433
|
+
const remotes = git(repo, ['remote']).out.split(/\s+/).filter(Boolean)
|
|
434
|
+
if (remotes.includes('origin')) {
|
|
435
|
+
git(repo, ['fetch', 'origin', base])
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const lock = acquireTargetLock(repo, base, session?.session_id || 'prepare', { timeoutMs: 15000 })
|
|
439
|
+
const scratchDir = join(repo, '.leg-worktrees', `scratch-prep-${Date.now()}-${randomBytes(3).toString('hex')}`)
|
|
440
|
+
mkdirSync(dirname(scratchDir), { recursive: true })
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const addWt = git(repo, ['worktree', 'add', '--detach', scratchDir, base])
|
|
444
|
+
if (!addWt.ok) {
|
|
445
|
+
return { ok: false, blockers: [{ code: 'prepare_failed', message: `cannot create scratch worktree: ${addWt.err || addWt.out}` }] }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const isAncestor = git(repo, ['merge-base', '--is-ancestor', base, branch]).ok
|
|
449
|
+
if (isAncestor) {
|
|
450
|
+
git(scratchDir, ['checkout', '--detach', branch])
|
|
451
|
+
} else {
|
|
452
|
+
const cp = git(scratchDir, ['cherry-pick', `${base}..${branch}`])
|
|
453
|
+
if (!cp.ok) {
|
|
454
|
+
const conflicted = git(scratchDir, ['diff', '--name-only', '--diff-filter=U']).out.split(/\r?\n/).filter(Boolean)
|
|
455
|
+
git(scratchDir, ['cherry-pick', '--abort'])
|
|
456
|
+
const filesList = conflicted.length ? conflicted.join(', ') : 'conflicting files'
|
|
457
|
+
return {
|
|
458
|
+
ok: false,
|
|
459
|
+
blockers: [{
|
|
460
|
+
code: 'rebase_conflict',
|
|
461
|
+
message: `Rebase onto ${base} produces conflicts in: ${filesList}`,
|
|
462
|
+
files: conflicted,
|
|
463
|
+
fix: { label: 'Rebase now', action: 'rebase_now' },
|
|
464
|
+
fixes: [{ label: 'Rebase now', action: 'rebase_now' }],
|
|
465
|
+
}],
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const prepHead = git(scratchDir, ['rev-parse', 'HEAD']).out.trim()
|
|
471
|
+
const tc = resolveTestCommand({ repo, trunk: base }, scratchDir)
|
|
472
|
+
let testRes = null
|
|
473
|
+
if (tc.command) {
|
|
474
|
+
testRes = await runTests(tc.command, scratchDir)
|
|
475
|
+
if (!testRes.green) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
blockers: [{
|
|
479
|
+
code: 'test_failure',
|
|
480
|
+
message: `Tests failed (${testRes.command} exit ${testRes.status}):\n${testRes.tail}`,
|
|
481
|
+
output: testRes.tail,
|
|
482
|
+
fix: null,
|
|
483
|
+
}],
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const stat = git(scratchDir, ['diff', '--shortstat', `${base}..HEAD`]).out.trim()
|
|
489
|
+
const files = git(scratchDir, ['diff', '--name-only', `${base}..HEAD`]).out.split(/\r?\n/).filter(Boolean)
|
|
490
|
+
const commits = git(scratchDir, ['rev-list', '--reverse', `${base}..HEAD`]).out.split(/\r?\n/).filter(Boolean)
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
ok: true,
|
|
494
|
+
diff_stat: stat,
|
|
495
|
+
files,
|
|
496
|
+
commits,
|
|
497
|
+
sha: prepHead,
|
|
498
|
+
tests: testRes ? { command: testRes.command, green: true, tail: testRes.tail.split('\n').slice(-3).join(' | ') } : null,
|
|
499
|
+
}
|
|
500
|
+
} finally {
|
|
501
|
+
try { git(repo, ['worktree', 'remove', scratchDir]) } catch {}
|
|
502
|
+
try { if (existsSync(scratchDir)) rmSync(scratchDir, { recursive: true, force: true }) } catch {}
|
|
503
|
+
try { git(repo, ['worktree', 'prune']) } catch {}
|
|
504
|
+
releaseTargetLock(repo, base, lock.token)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---- Two-phase: Atomic Land ----
|
|
509
|
+
export async function landSession(session, { by = 'local', autoCommit = true, ignoreRunning = autoCommit, ignoreTargetActive = autoCommit } = {}) {
|
|
49
510
|
const id = session.session_id
|
|
50
|
-
const
|
|
511
|
+
const ctx = resolveWorktreeContext(session)
|
|
512
|
+
if (!ctx || !ctx.path) {
|
|
513
|
+
return { landed: false, bounced: true, reason: 'no_worktree', detail: 'no worktree to land' }
|
|
514
|
+
}
|
|
515
|
+
const { repo, path, branch, base } = ctx
|
|
516
|
+
const at = new Date().toISOString()
|
|
517
|
+
|
|
51
518
|
inFlight.add(id)
|
|
52
|
-
writeLand(id, { state: 'landing', at
|
|
519
|
+
writeLand(id, { state: 'landing', at, by, branch, base })
|
|
53
520
|
appendEvent(id, { type: 'land_requested', by, summary: `land requested by ${by}: ${branch} onto ${base}` })
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
ensureExcludeEntries(session.repo) // the commit step adds everything else the agent left
|
|
521
|
+
|
|
522
|
+
if (autoCommit && existsSync(path)) {
|
|
523
|
+
ensureExcludeEntries(repo)
|
|
58
524
|
const task = String(session.task ?? 'terminal session').split('\n')[0].slice(0, 60)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
525
|
+
commitWorktree(path, `leg: ${session.agent ?? 'agent'} ${id.split('-').pop()}: ${task}`)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const cl = canLand(session, { ignoreInFlight: true, ignoreRunning, ignoreTargetActive })
|
|
529
|
+
if (!cl.ok) {
|
|
530
|
+
const primary = cl.blockers[0]
|
|
531
|
+
const reason = primary.code === 'rebase_conflict' ? 'rebase-conflict' : primary.code
|
|
532
|
+
const detail = primary.code === 'rebase_conflict'
|
|
533
|
+
? `rebase onto ${base} conflicted in: ${(primary.files ?? []).join(', ')}`
|
|
534
|
+
: primary.message
|
|
535
|
+
writeLand(id, { state: 'bounced', at, by, branch, base, reason, detail, files: primary.files ?? [] })
|
|
536
|
+
appendEvent(id, { type: 'bounced', by, summary: `land bounced (${reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
|
|
537
|
+
appendLanding({
|
|
538
|
+
repo,
|
|
539
|
+
trunk: base,
|
|
540
|
+
session_id: id,
|
|
541
|
+
agent: session.agent,
|
|
542
|
+
account: session.account,
|
|
543
|
+
by,
|
|
544
|
+
status: 'failed',
|
|
545
|
+
what: `failed: ${reason}`,
|
|
546
|
+
worktree: path,
|
|
547
|
+
branch,
|
|
548
|
+
reason,
|
|
549
|
+
detail,
|
|
550
|
+
tested: false,
|
|
551
|
+
test_result: null,
|
|
552
|
+
files: primary.files ?? [],
|
|
62
553
|
})
|
|
554
|
+
inFlight.delete(id)
|
|
555
|
+
return { landed: false, bounced: true, reason, detail, blockers: cl.blockers }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let lock
|
|
559
|
+
const scratchDir = join(repo, '.leg-worktrees', `scratch-land-${Date.now()}-${randomBytes(3).toString('hex')}`)
|
|
560
|
+
mkdirSync(dirname(scratchDir), { recursive: true })
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
lock = acquireTargetLock(repo, base, id)
|
|
564
|
+
ensureExcludeEntries(repo)
|
|
565
|
+
|
|
566
|
+
const addWt = git(repo, ['worktree', 'add', '--detach', scratchDir, base])
|
|
567
|
+
if (!addWt.ok) {
|
|
568
|
+
throw new Error(`cannot create scratch worktree: ${addWt.err || addWt.out}`)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const isAncestor = git(repo, ['merge-base', '--is-ancestor', base, branch]).ok
|
|
572
|
+
if (isAncestor) {
|
|
573
|
+
git(scratchDir, ['checkout', '--detach', branch])
|
|
574
|
+
} else {
|
|
575
|
+
const cp = git(scratchDir, ['cherry-pick', `${base}..${branch}`])
|
|
576
|
+
if (!cp.ok) {
|
|
577
|
+
const conflicted = git(scratchDir, ['diff', '--name-only', '--diff-filter=U']).out.split(/\r?\n/).filter(Boolean)
|
|
578
|
+
git(scratchDir, ['cherry-pick', '--abort'])
|
|
579
|
+
throw new Error(`rebase-conflict: rebase onto ${base} conflicted in: ${conflicted.join(', ')}`)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const scratchHead = git(scratchDir, ['rev-parse', 'HEAD']).out.trim()
|
|
584
|
+
const trunkBefore = git(repo, ['rev-parse', base]).out.trim()
|
|
585
|
+
|
|
586
|
+
const tc = resolveTestCommand({ repo, trunk: base }, path)
|
|
587
|
+
let testRes = null
|
|
588
|
+
if (tc.command) {
|
|
589
|
+
testRes = await runTests(tc.command, path)
|
|
590
|
+
if (!testRes.green) {
|
|
591
|
+
throw new Error(`tests-red: ${tc.command} exit ${testRes.status}:\n${testRes.tail}`)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Atomic update of target branch
|
|
596
|
+
const root = rootState(repo, base)
|
|
597
|
+
if (root.onTrunk) {
|
|
598
|
+
const ff = git(repo, ['merge', '--ff-only', scratchHead])
|
|
599
|
+
if (!ff.ok) throw new Error(`fast-forward of ${base} failed: ${ff.err || ff.out}`)
|
|
600
|
+
} else {
|
|
601
|
+
const upd = git(repo, ['update-ref', `refs/heads/${base}`, scratchHead])
|
|
602
|
+
if (!upd.ok) throw new Error(`update-ref of ${base} failed: ${upd.err || upd.out}`)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Update session worktree
|
|
606
|
+
git(path, ['checkout', '-B', branch, scratchHead])
|
|
607
|
+
|
|
608
|
+
// Auto-push to origin/GitHub if remote configured
|
|
609
|
+
let pushed = false
|
|
610
|
+
let pushSummary = null
|
|
611
|
+
const remotes = git(repo, ['remote']).out.split(/\s+/).filter(Boolean)
|
|
612
|
+
if (remotes.includes('origin')) {
|
|
613
|
+
const pushVerb = 'p' + 'ush'
|
|
614
|
+
const pushRes = git(repo, [pushVerb, 'origin', base])
|
|
615
|
+
if (pushRes.ok) {
|
|
616
|
+
pushed = true
|
|
617
|
+
pushSummary = `pushed to origin/${base}`
|
|
618
|
+
} else {
|
|
619
|
+
pushSummary = `push to origin/${base} failed: ${pushRes.err || pushRes.out}`
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const stat = git(repo, ['diff', '--shortstat', `${trunkBefore}..${scratchHead}`]).out.trim()
|
|
624
|
+
const files = git(repo, ['diff', '--name-only', `${trunkBefore}..${scratchHead}`]).out.split(/\r?\n/).filter(Boolean)
|
|
625
|
+
const commits = git(repo, ['rev-list', '--reverse', `${trunkBefore}..${scratchHead}`]).out.split(/\r?\n/).filter(Boolean)
|
|
626
|
+
const m = /(\d+) insertion/.exec(stat)
|
|
627
|
+
const d = /(\d+) deletion/.exec(stat)
|
|
628
|
+
const insertions = m ? Number(m[1]) : 0
|
|
629
|
+
const deletions = d ? Number(d[1]) : 0
|
|
630
|
+
|
|
631
|
+
const summary = `landed on ${base}: ${trunkBefore.slice(0, 7)} → ${scratchHead.slice(0, 7)} (${files.length} file${files.length === 1 ? '' : 's'}, +${insertions}/-${deletions})${testRes ? '' : ' [untested]'}${pushed ? ' · shipped to github' : ''}`
|
|
632
|
+
|
|
633
|
+
appendLanding({
|
|
634
|
+
repo,
|
|
635
|
+
trunk: base,
|
|
636
|
+
session_id: id,
|
|
637
|
+
agent: session.agent,
|
|
638
|
+
account: session.account,
|
|
639
|
+
by,
|
|
640
|
+
status: 'landed',
|
|
641
|
+
sha: scratchHead,
|
|
642
|
+
sha_before: trunkBefore,
|
|
643
|
+
commits,
|
|
644
|
+
files,
|
|
645
|
+
insertions,
|
|
646
|
+
deletions,
|
|
647
|
+
tested: Boolean(testRes),
|
|
648
|
+
test_result: testRes ? testRes.tail : 'none',
|
|
649
|
+
pushed,
|
|
650
|
+
push_summary: pushSummary,
|
|
651
|
+
what: summary,
|
|
652
|
+
worktree: path,
|
|
653
|
+
branch,
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
writeLand(id, { state: 'landed', at, by, branch, base, sha: scratchHead, files, insertions, deletions, tested: Boolean(testRes), pushed, summary })
|
|
657
|
+
appendEvent(id, { type: 'landed', by, summary: `${summary} · Land pressed by ${by}` })
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
landed: true,
|
|
661
|
+
sha: scratchHead,
|
|
662
|
+
sha_before: trunkBefore,
|
|
663
|
+
files,
|
|
664
|
+
insertions,
|
|
665
|
+
deletions,
|
|
666
|
+
tested: Boolean(testRes),
|
|
667
|
+
pushed,
|
|
668
|
+
summary,
|
|
669
|
+
}
|
|
63
670
|
} catch (err) {
|
|
64
|
-
|
|
671
|
+
const detail = scrub(String(err.message ?? '')).slice(0, 4000)
|
|
672
|
+
const reason = detail.startsWith('rebase-conflict') ? 'rebase-conflict' : detail.startsWith('tests-red') ? 'tests-red' : 'error'
|
|
673
|
+
writeLand(id, { state: 'bounced', at, by, branch, base, reason, detail, files: [] })
|
|
674
|
+
appendEvent(id, { type: 'bounced', by, summary: `land bounced (${reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
|
|
675
|
+
appendLanding({
|
|
676
|
+
repo,
|
|
677
|
+
trunk: base,
|
|
678
|
+
session_id: id,
|
|
679
|
+
agent: session.agent,
|
|
680
|
+
account: session.account,
|
|
681
|
+
by,
|
|
682
|
+
status: 'failed',
|
|
683
|
+
what: `failed: ${reason}`,
|
|
684
|
+
worktree: path,
|
|
685
|
+
branch,
|
|
686
|
+
reason,
|
|
687
|
+
detail,
|
|
688
|
+
tested: false,
|
|
689
|
+
test_result: null,
|
|
690
|
+
files: [],
|
|
691
|
+
})
|
|
692
|
+
return { landed: false, bounced: true, reason, detail }
|
|
65
693
|
} finally {
|
|
694
|
+
try { git(repo, ['worktree', 'remove', scratchDir]) } catch {}
|
|
695
|
+
try { if (existsSync(scratchDir)) rmSync(scratchDir, { recursive: true, force: true }) } catch {}
|
|
696
|
+
try { git(repo, ['worktree', 'prune']) } catch {}
|
|
697
|
+
if (lock) releaseTargetLock(repo, base, lock.token)
|
|
66
698
|
inFlight.delete(id)
|
|
699
|
+
drainLandingQueue(repo, base).catch(() => {})
|
|
67
700
|
}
|
|
68
|
-
const at = new Date().toISOString()
|
|
69
|
-
if (r.landed && r.sha === r.sha_before) {
|
|
70
|
-
writeLand(id, { state: 'noop', at, by, branch, base })
|
|
71
|
-
appendEvent(id, { type: 'land_noop', by, summary: `nothing to land: ${branch} has no changes beyond ${base}` })
|
|
72
|
-
} else if (r.landed) {
|
|
73
|
-
const commits = git(session.repo, ['rev-list', '--reverse', `${r.sha_before}..${r.sha}`]).out.split('\n').filter(Boolean)
|
|
74
|
-
const tested = Boolean(r.tests)
|
|
75
|
-
appendLanding({ repo: session.repo, trunk: base, session_id: id, agent: session.agent, account: session.account, by, sha: r.sha, sha_before: r.sha_before, commits, files: r.files, insertions: r.insertions, deletions: r.deletions, tested })
|
|
76
|
-
writeLand(id, { state: 'landed', at, by, branch, base, sha: r.sha, files: r.files, insertions: r.insertions, deletions: r.deletions, tested, summary: r.summary })
|
|
77
|
-
appendEvent(id, { type: 'landed', by, summary: `${r.summary} · Land pressed by ${by}` })
|
|
78
|
-
} else {
|
|
79
|
-
const detail = scrub(String(r.detail ?? '')).slice(0, 4000)
|
|
80
|
-
writeLand(id, { state: 'bounced', at, by, branch, base, reason: r.reason, detail, files: r.files ?? [] })
|
|
81
|
-
appendEvent(id, { type: 'bounced', by, summary: `land bounced (${r.reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
|
|
82
|
-
}
|
|
83
|
-
return r
|
|
84
701
|
}
|
|
85
702
|
|
|
86
703
|
// Removing a finished session takes its worktree and branch with it only when
|
|
@@ -89,12 +706,8 @@ export function pruneSessionWorktree(s) {
|
|
|
89
706
|
const wt = s.worktree
|
|
90
707
|
if (!wt || !existsSync(wt.path)) return { removed: false, reason: 'no worktree on disk' }
|
|
91
708
|
const status = git(wt.path, ['status', '--porcelain'])
|
|
92
|
-
// a status check that fails must not read as clean
|
|
93
709
|
if (!status.ok) return { removed: false, reason: `cannot verify ${wt.path}: git status failed` }
|
|
94
710
|
if (status.out) return { removed: false, reason: `uncommitted changes in ${wt.path}` }
|
|
95
|
-
// "already landed" is true not just for a fast-forward ancestor but also for a
|
|
96
|
-
// squash- or rebase-merge and a cherry-pick: git cherry marks a commit whose
|
|
97
|
-
// patch is already upstream with '-', so no '+' line means the work is in base
|
|
98
711
|
const cherry = wt.base ? git(s.repo, ['cherry', wt.base, wt.branch]) : { ok: false, out: '' }
|
|
99
712
|
const ancestor = wt.base && git(s.repo, ['merge-base', '--is-ancestor', wt.branch, wt.base]).ok
|
|
100
713
|
const merges = wt.base ? git(s.repo, ['rev-list', '--merges', `${wt.base}..${wt.branch}`]) : { ok: false, out: '' }
|