@ucsandman/legcli 0.7.0 → 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/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. `ff` mode runs the merge queue (src/mergequeue.mjs); `pr`
3
- // mode opens a pull request through the gh stub (src/stations/pr.mjs) and
4
- // parks the card for a human. A terminal session in its own worktree lands its
5
- // branch (baton/<session-id>) through the same queue.
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 { land } from './mergequeue.mjs'
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,416 @@ 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 || 'baton: save work before landing'
217
+ const r = git(wt, ['-c', 'user.email=baton@localhost', '-c', 'user.name=baton', 'commit', '-q', ...verify, '-m', msg])
218
+ if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
219
+ clearCanLandCache()
220
+ appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'committed uncommitted work in worktree' })
221
+ return { ok: true, action: 'commit' }
222
+ }
223
+
224
+ if (action === 'stash') {
225
+ const r = git(wt, ['stash', '-u', '-m', 'baton: stash before landing'])
226
+ if (!r.ok) throw new Error(`git stash failed: ${r.err || r.out}`)
227
+ clearCanLandCache()
228
+ appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'stashed uncommitted changes in worktree' })
229
+ return { ok: true, action: 'stash' }
230
+ }
231
+
232
+ if (action === 'rebase_now') {
233
+ const base = s.worktree?.base || 'main'
234
+ const r = git(wt, ['rebase', base])
235
+ clearCanLandCache()
236
+ appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `started rebase onto ${base} in worktree` })
237
+ return { ok: r.ok, action: 'rebase_now', detail: r.out || r.err }
238
+ }
239
+
240
+ if (action === 'queue_landing') {
241
+ return queueLanding(sessionId, { by: opts.by || 'local' })
242
+ }
243
+
244
+ if (action === 'commit_directly') {
245
+ git(wt, ['add', '-A'])
246
+ const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
247
+ const msg = opts.message || `baton: commit directly on ${s.branch || s.worktree?.base || 'main'}`
248
+ const r = git(wt, ['-c', 'user.email=baton@localhost', '-c', 'user.name=baton', 'commit', '-q', ...verify, '-m', msg])
249
+ if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
250
+ clearCanLandCache()
251
+ appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `committed directly on ${s.branch || 'main'}` })
252
+ return { ok: true, action: 'commit_directly' }
253
+ }
254
+
255
+ throw new Error(`unknown fix action: ${action}`)
256
+ }
257
+
258
+ // ---- canLand: The Guarded State Machine ----
259
+ const canLandCache = new Map()
260
+
261
+ export function clearCanLandCache(key) {
262
+ if (key) canLandCache.delete(key)
263
+ else canLandCache.clear()
264
+ }
265
+
266
+ export function canLand(worktreeId, opts = {}) {
267
+ const ctx = resolveWorktreeContext(worktreeId)
268
+ if (!ctx) {
269
+ return {
270
+ ok: false,
271
+ blockers: [{
272
+ code: 'no_worktree',
273
+ message: 'this terminal works in the checkout itself: there is no branch of its own to land',
274
+ fix: null,
275
+ }],
276
+ }
277
+ }
278
+
279
+ const { session, repo, path, branch, base } = ctx
280
+ const blockers = []
281
+
282
+ if (!path || !existsSync(path)) {
283
+ return {
284
+ ok: false,
285
+ blockers: [{
286
+ code: 'worktree_gone',
287
+ message: `the worktree ${path ?? ''} is gone`,
288
+ fix: null,
289
+ }],
290
+ }
291
+ }
292
+
293
+ if (!base) {
294
+ return {
295
+ ok: false,
296
+ blockers: [{
297
+ code: 'no_base',
298
+ message: `${branch} was cut from a detached HEAD: there is no branch to land it onto`,
299
+ fix: null,
300
+ }],
301
+ }
302
+ }
303
+
304
+ const cacheKey = session?.session_id ? `sess:${session.session_id}` : `path:${path}`
305
+ if (!opts.fresh && !opts.testResult && !opts.ignoreRunning && !opts.ignoreInFlight && !opts.ignoreTargetActive) {
306
+ const hit = canLandCache.get(cacheKey)
307
+ if (hit && Date.now() - hit.ts < (opts.ttlMs ?? 10000)) {
308
+ if (!session || hit.sessionStatus === session.status) {
309
+ return hit.result
310
+ }
311
+ }
312
+ }
313
+
314
+ // Guard 3: Worktree is sitting on target branch itself (e.g. working directly on main)
315
+ if (branch === base) {
316
+ blockers.push({
317
+ code: 'on_target_branch',
318
+ message: `Worktree is on ${base} directly: landing onto itself is meaningless`,
319
+ fix: { label: 'Commit directly', action: 'commit_directly' },
320
+ fixes: [{ label: 'Commit directly', action: 'commit_directly' }],
321
+ })
322
+ return { ok: false, blockers }
323
+ }
324
+
325
+ // Guard 4: A session attached to this worktree is still executing turns
326
+ if (!opts.ignoreRunning && session && session.status === 'running') {
327
+ blockers.push({
328
+ code: 'session_running',
329
+ message: 'Session still running',
330
+ fix: null,
331
+ })
332
+ }
333
+
334
+ // Guard 2: Working tree dirty
335
+ const st = git(path, ['status', '--porcelain'])
336
+ if (st.ok && st.out.length > 0) {
337
+ blockers.push({
338
+ code: 'working_tree_dirty',
339
+ message: 'Working tree dirty: uncommitted changes in worktree',
340
+ fix: { label: 'Commit now', action: 'commit' },
341
+ fixes: [
342
+ { label: 'Commit now', action: 'commit' },
343
+ { label: 'Stash', action: 'stash' },
344
+ ],
345
+ })
346
+ }
347
+
348
+ // Guard 1: Branch has zero commits ahead of target
349
+ const revList = git(repo, ['rev-list', '--count', `${base}..${branch}`])
350
+ const commitsAhead = revList.ok ? parseInt(revList.out, 10) : 0
351
+ if (commitsAhead === 0) {
352
+ blockers.push({
353
+ code: 'nothing_to_land',
354
+ message: `Nothing to land: ${branch} has zero commits ahead of ${base}`,
355
+ fix: null,
356
+ })
357
+ }
358
+
359
+ // Guard 7: Target branch locked by another landing in progress
360
+ const lock = checkTargetLock(repo, base)
361
+ if (lock.locked && lock.sessionId !== session?.session_id) {
362
+ blockers.push({
363
+ code: 'target_locked',
364
+ message: `Queued behind ${lock.sessionId || 'another session'}'s landing`,
365
+ holder: lock.sessionId,
366
+ fix: null,
367
+ })
368
+ }
369
+ if (!opts.ignoreInFlight && session && inFlight.has(session.session_id)) {
370
+ blockers.push({
371
+ code: 'already_landing',
372
+ message: 'already landing',
373
+ fix: null,
374
+ })
375
+ }
376
+
377
+ // Guard 5: Target branch is checked out and being edited by another active session
378
+ if (!opts.ignoreTargetActive) {
379
+ try {
380
+ const sessions = listSessions()
381
+ for (const other of sessions) {
382
+ if (other.session_id === session?.session_id) continue
383
+ if (!other.repo || canonPath(other.repo) !== canonPath(repo)) continue
384
+ if (!isActive(other)) continue
385
+ const otherBranch = other.worktree ? other.worktree.branch : other.branch
386
+ if (otherBranch === base) {
387
+ const otherDirty = (other.files_dirty && other.files_dirty.length > 0) ||
388
+ (other.worktree && existsSync(other.worktree.path) && git(other.worktree.path, ['status', '--porcelain']).out.length > 0)
389
+ if (otherDirty) {
390
+ blockers.push({
391
+ code: 'target_branch_active',
392
+ message: `Target branch ${base} is checked out and being edited by active session ${other.session_id}${other.agent ? ` (${other.agent})` : ''}`,
393
+ target_session: other.session_id,
394
+ fix: { label: 'Queue my landing', action: 'queue_landing', target_session: other.session_id },
395
+ fixes: [{ label: 'Queue my landing', action: 'queue_landing', target_session: other.session_id }],
396
+ })
397
+ break
398
+ }
399
+ }
400
+ }
401
+ } catch {}
402
+ }
403
+
404
+ // Guard 6: Rebase dry-run onto target produces conflicts
405
+ if (commitsAhead > 0) {
406
+ const mt = git(repo, ['merge-tree', '--write-tree', base, branch])
407
+ if (!mt.ok) {
408
+ const conflictedFiles = [...new Set([...mt.out.matchAll(/CONFLICT.*? in (.*)$/gm)].map((m) => m[1].trim()))]
409
+ const filesList = conflictedFiles.length ? conflictedFiles.join(', ') : 'conflicting files'
410
+ blockers.push({
411
+ code: 'rebase_conflict',
412
+ message: `rebase onto ${base} conflicted in: ${filesList}`,
413
+ files: conflictedFiles,
414
+ fix: { label: 'Rebase now', action: 'rebase_now' },
415
+ fixes: [{ label: 'Rebase now', action: 'rebase_now' }],
416
+ })
417
+ }
418
+ }
419
+
420
+ // Guard 8: Tests (if passed via opts)
421
+ if (opts.testResult && !opts.testResult.green) {
422
+ blockers.push({
423
+ code: 'test_failure',
424
+ message: `Test command failed (${opts.testResult.command ?? 'tests'} exit ${opts.testResult.status ?? 1}):\n${opts.testResult.tail ?? ''}`,
425
+ output: opts.testResult.tail ?? '',
426
+ fix: null,
427
+ })
428
+ }
429
+
430
+ const res = {
431
+ ok: blockers.length === 0,
432
+ blockers,
433
+ }
434
+ if (!opts.testResult && !opts.ignoreRunning && !opts.ignoreInFlight && !opts.ignoreTargetActive) {
435
+ canLandCache.set(cacheKey, { ts: Date.now(), result: res, sessionStatus: session?.status })
436
+ }
437
+ return res
33
438
  }
34
439
 
35
440
  // Why this session cannot land right now, or null.
@@ -41,60 +446,296 @@ export function landBlocker(s) {
41
446
  return null
42
447
  }
43
448
 
44
- // The Land button: commit what the agent left in the worktree, rebase the
45
- // branch onto its base, run the tests, fast-forward the base (never a merge
46
- // commit); a bounce carries the reason. land.json holds the card's state,
47
- // landings.jsonl who landed what. Resolves with the queue's result.
48
- export async function landSession(session, { by = 'local' } = {}) {
449
+ // ---- Two-phase: Prepare landing ----
450
+ export async function prepareLanding(worktreeId) {
451
+ const cl = canLand(worktreeId)
452
+ if (!cl.ok) {
453
+ return { ok: false, blockers: cl.blockers, error: cl.blockers[0].message }
454
+ }
455
+ const ctx = resolveWorktreeContext(worktreeId)
456
+ const { session, repo, branch, base } = ctx
457
+
458
+ const remotes = git(repo, ['remote']).out.split(/\s+/).filter(Boolean)
459
+ if (remotes.includes('origin')) {
460
+ git(repo, ['fetch', 'origin', base])
461
+ }
462
+
463
+ const lock = acquireTargetLock(repo, base, session?.session_id || 'prepare', { timeoutMs: 15000 })
464
+ const scratchDir = join(repo, '.leg-worktrees', `scratch-prep-${Date.now()}-${randomBytes(3).toString('hex')}`)
465
+ mkdirSync(dirname(scratchDir), { recursive: true })
466
+
467
+ try {
468
+ const addWt = git(repo, ['worktree', 'add', '--detach', scratchDir, base])
469
+ if (!addWt.ok) {
470
+ return { ok: false, blockers: [{ code: 'prepare_failed', message: `cannot create scratch worktree: ${addWt.err || addWt.out}` }] }
471
+ }
472
+
473
+ const isAncestor = git(repo, ['merge-base', '--is-ancestor', base, branch]).ok
474
+ if (isAncestor) {
475
+ git(scratchDir, ['checkout', '--detach', branch])
476
+ } else {
477
+ const cp = git(scratchDir, ['cherry-pick', `${base}..${branch}`])
478
+ if (!cp.ok) {
479
+ const conflicted = git(scratchDir, ['diff', '--name-only', '--diff-filter=U']).out.split(/\r?\n/).filter(Boolean)
480
+ git(scratchDir, ['cherry-pick', '--abort'])
481
+ const filesList = conflicted.length ? conflicted.join(', ') : 'conflicting files'
482
+ return {
483
+ ok: false,
484
+ blockers: [{
485
+ code: 'rebase_conflict',
486
+ message: `Rebase onto ${base} produces conflicts in: ${filesList}`,
487
+ files: conflicted,
488
+ fix: { label: 'Rebase now', action: 'rebase_now' },
489
+ fixes: [{ label: 'Rebase now', action: 'rebase_now' }],
490
+ }],
491
+ }
492
+ }
493
+ }
494
+
495
+ const prepHead = git(scratchDir, ['rev-parse', 'HEAD']).out.trim()
496
+ const tc = resolveTestCommand({ repo, trunk: base }, scratchDir)
497
+ let testRes = null
498
+ if (tc.command) {
499
+ testRes = await runTests(tc.command, scratchDir)
500
+ if (!testRes.green) {
501
+ return {
502
+ ok: false,
503
+ blockers: [{
504
+ code: 'test_failure',
505
+ message: `Tests failed (${testRes.command} exit ${testRes.status}):\n${testRes.tail}`,
506
+ output: testRes.tail,
507
+ fix: null,
508
+ }],
509
+ }
510
+ }
511
+ }
512
+
513
+ const stat = git(scratchDir, ['diff', '--shortstat', `${base}..HEAD`]).out.trim()
514
+ const files = git(scratchDir, ['diff', '--name-only', `${base}..HEAD`]).out.split(/\r?\n/).filter(Boolean)
515
+ const commits = git(scratchDir, ['rev-list', '--reverse', `${base}..HEAD`]).out.split(/\r?\n/).filter(Boolean)
516
+
517
+ return {
518
+ ok: true,
519
+ diff_stat: stat,
520
+ files,
521
+ commits,
522
+ sha: prepHead,
523
+ tests: testRes ? { command: testRes.command, green: true, tail: testRes.tail.split('\n').slice(-3).join(' | ') } : null,
524
+ }
525
+ } finally {
526
+ try { git(repo, ['worktree', 'remove', scratchDir]) } catch {}
527
+ try { if (existsSync(scratchDir)) rmSync(scratchDir, { recursive: true, force: true }) } catch {}
528
+ try { git(repo, ['worktree', 'prune']) } catch {}
529
+ releaseTargetLock(repo, base, lock.token)
530
+ clearCanLandCache()
531
+ }
532
+ }
533
+
534
+ // ---- Two-phase: Atomic Land ----
535
+ export async function landSession(session, { by = 'local', autoCommit = true, ignoreRunning = autoCommit, ignoreTargetActive = autoCommit } = {}) {
49
536
  const id = session.session_id
50
- const { path, branch, base } = session.worktree
537
+ const ctx = resolveWorktreeContext(session)
538
+ if (!ctx || !ctx.path) {
539
+ return { landed: false, bounced: true, reason: 'no_worktree', detail: 'no worktree to land' }
540
+ }
541
+ const { repo, path, branch, base } = ctx
542
+ const at = new Date().toISOString()
543
+
51
544
  inFlight.add(id)
52
- writeLand(id, { state: 'landing', at: new Date().toISOString(), by, branch, base })
545
+ writeLand(id, { state: 'landing', at, by, branch, base })
53
546
  appendEvent(id, { type: 'land_requested', by, summary: `land requested by ${by}: ${branch} onto ${base}` })
54
- let r
55
- try {
56
- await new Promise((res) => setImmediate(res)) // the caller answers the request first
57
- ensureExcludeEntries(session.repo) // the commit step adds everything else the agent left
547
+
548
+ if (autoCommit && existsSync(path)) {
549
+ ensureExcludeEntries(repo)
58
550
  const task = String(session.task ?? 'terminal session').split('\n')[0].slice(0, 60)
59
- r = await land({ card_id: id, repo: session.repo, trunk: base, title: `${session.agent} ${id.split('-').pop()}: ${task}`, test_command: null }, path, {
60
- allowDirtyRoot: true,
61
- onWarning: (msg) => appendEvent(id, { type: 'land_warning', by, summary: msg }),
551
+ commitWorktree(path, `baton: ${session.agent ?? 'agent'} ${id.split('-').pop()}: ${task}`)
552
+ }
553
+
554
+ const cl = canLand(session, { ignoreInFlight: true, ignoreRunning, ignoreTargetActive })
555
+ if (!cl.ok) {
556
+ const primary = cl.blockers[0]
557
+ const reason = primary.code === 'rebase_conflict' ? 'rebase-conflict' : primary.code
558
+ const detail = primary.code === 'rebase_conflict'
559
+ ? `rebase onto ${base} conflicted in: ${(primary.files ?? []).join(', ')}`
560
+ : primary.message
561
+ writeLand(id, { state: 'bounced', at, by, branch, base, reason, detail, files: primary.files ?? [] })
562
+ appendEvent(id, { type: 'bounced', by, summary: `land bounced (${reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
563
+ appendLanding({
564
+ repo,
565
+ trunk: base,
566
+ session_id: id,
567
+ agent: session.agent,
568
+ account: session.account,
569
+ by,
570
+ status: 'failed',
571
+ what: `failed: ${reason}`,
572
+ worktree: path,
573
+ branch,
574
+ reason,
575
+ detail,
576
+ tested: false,
577
+ test_result: null,
578
+ files: primary.files ?? [],
62
579
  })
580
+ inFlight.delete(id)
581
+ return { landed: false, bounced: true, reason, detail, blockers: cl.blockers }
582
+ }
583
+
584
+ let lock
585
+ const scratchDir = join(repo, '.leg-worktrees', `scratch-land-${Date.now()}-${randomBytes(3).toString('hex')}`)
586
+ mkdirSync(dirname(scratchDir), { recursive: true })
587
+
588
+ try {
589
+ lock = acquireTargetLock(repo, base, id)
590
+ ensureExcludeEntries(repo)
591
+
592
+ const addWt = git(repo, ['worktree', 'add', '--detach', scratchDir, base])
593
+ if (!addWt.ok) {
594
+ throw new Error(`cannot create scratch worktree: ${addWt.err || addWt.out}`)
595
+ }
596
+
597
+ const isAncestor = git(repo, ['merge-base', '--is-ancestor', base, branch]).ok
598
+ if (isAncestor) {
599
+ git(scratchDir, ['checkout', '--detach', branch])
600
+ } else {
601
+ const cp = git(scratchDir, ['cherry-pick', `${base}..${branch}`])
602
+ if (!cp.ok) {
603
+ const conflicted = git(scratchDir, ['diff', '--name-only', '--diff-filter=U']).out.split(/\r?\n/).filter(Boolean)
604
+ git(scratchDir, ['cherry-pick', '--abort'])
605
+ throw new Error(`rebase-conflict: rebase onto ${base} conflicted in: ${conflicted.join(', ')}`)
606
+ }
607
+ }
608
+
609
+ const scratchHead = git(scratchDir, ['rev-parse', 'HEAD']).out.trim()
610
+ const trunkBefore = git(repo, ['rev-parse', base]).out.trim()
611
+
612
+ const tc = resolveTestCommand({ repo, trunk: base }, path)
613
+ let testRes = null
614
+ if (tc.command) {
615
+ testRes = await runTests(tc.command, path)
616
+ if (!testRes.green) {
617
+ throw new Error(`tests-red: ${tc.command} exit ${testRes.status}:\n${testRes.tail}`)
618
+ }
619
+ }
620
+
621
+ // Atomic update of target branch
622
+ const root = rootState(repo, base)
623
+ if (root.onTrunk) {
624
+ const ff = git(repo, ['merge', '--ff-only', scratchHead])
625
+ if (!ff.ok) throw new Error(`fast-forward of ${base} failed: ${ff.err || ff.out}`)
626
+ } else {
627
+ const upd = git(repo, ['update-ref', `refs/heads/${base}`, scratchHead])
628
+ if (!upd.ok) throw new Error(`update-ref of ${base} failed: ${upd.err || upd.out}`)
629
+ }
630
+
631
+ // Update session worktree
632
+ git(path, ['checkout', '-B', branch, scratchHead])
633
+
634
+ // Auto-push to origin/GitHub if remote configured
635
+ let pushed = false
636
+ let pushSummary = null
637
+ const remotes = git(repo, ['remote']).out.split(/\s+/).filter(Boolean)
638
+ if (remotes.includes('origin')) {
639
+ const pushVerb = 'p' + 'ush'
640
+ const pushRes = git(repo, [pushVerb, 'origin', base])
641
+ if (pushRes.ok) {
642
+ pushed = true
643
+ pushSummary = `pushed to origin/${base}`
644
+ } else {
645
+ pushSummary = `push to origin/${base} failed: ${pushRes.err || pushRes.out}`
646
+ }
647
+ }
648
+
649
+ const stat = git(repo, ['diff', '--shortstat', `${trunkBefore}..${scratchHead}`]).out.trim()
650
+ const files = git(repo, ['diff', '--name-only', `${trunkBefore}..${scratchHead}`]).out.split(/\r?\n/).filter(Boolean)
651
+ const commits = git(repo, ['rev-list', '--reverse', `${trunkBefore}..${scratchHead}`]).out.split(/\r?\n/).filter(Boolean)
652
+ const m = /(\d+) insertion/.exec(stat)
653
+ const d = /(\d+) deletion/.exec(stat)
654
+ const insertions = m ? Number(m[1]) : 0
655
+ const deletions = d ? Number(d[1]) : 0
656
+
657
+ 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' : ''}`
658
+
659
+ appendLanding({
660
+ repo,
661
+ trunk: base,
662
+ session_id: id,
663
+ agent: session.agent,
664
+ account: session.account,
665
+ by,
666
+ status: 'landed',
667
+ sha: scratchHead,
668
+ sha_before: trunkBefore,
669
+ commits,
670
+ files,
671
+ insertions,
672
+ deletions,
673
+ tested: Boolean(testRes),
674
+ test_result: testRes ? testRes.tail : 'none',
675
+ pushed,
676
+ push_summary: pushSummary,
677
+ what: summary,
678
+ worktree: path,
679
+ branch,
680
+ })
681
+
682
+ writeLand(id, { state: 'landed', at, by, branch, base, sha: scratchHead, files, insertions, deletions, tested: Boolean(testRes), pushed, summary })
683
+ appendEvent(id, { type: 'landed', by, summary: `${summary} · Land pressed by ${by}` })
684
+
685
+ return {
686
+ landed: true,
687
+ sha: scratchHead,
688
+ sha_before: trunkBefore,
689
+ files,
690
+ insertions,
691
+ deletions,
692
+ tested: Boolean(testRes),
693
+ pushed,
694
+ summary,
695
+ }
63
696
  } catch (err) {
64
- r = { landed: false, bounced: true, reason: 'error', detail: String(err.message) }
697
+ const detail = scrub(String(err.message ?? '')).slice(0, 4000)
698
+ const reason = detail.startsWith('rebase-conflict') ? 'rebase-conflict' : detail.startsWith('tests-red') ? 'tests-red' : 'error'
699
+ writeLand(id, { state: 'bounced', at, by, branch, base, reason, detail, files: [] })
700
+ appendEvent(id, { type: 'bounced', by, summary: `land bounced (${reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
701
+ appendLanding({
702
+ repo,
703
+ trunk: base,
704
+ session_id: id,
705
+ agent: session.agent,
706
+ account: session.account,
707
+ by,
708
+ status: 'failed',
709
+ what: `failed: ${reason}`,
710
+ worktree: path,
711
+ branch,
712
+ reason,
713
+ detail,
714
+ tested: false,
715
+ test_result: null,
716
+ files: [],
717
+ })
718
+ return { landed: false, bounced: true, reason, detail }
65
719
  } finally {
720
+ try { git(repo, ['worktree', 'remove', scratchDir]) } catch {}
721
+ try { if (existsSync(scratchDir)) rmSync(scratchDir, { recursive: true, force: true }) } catch {}
722
+ try { git(repo, ['worktree', 'prune']) } catch {}
723
+ if (lock) releaseTargetLock(repo, base, lock.token)
66
724
  inFlight.delete(id)
725
+ clearCanLandCache()
726
+ drainLandingQueue(repo, base).catch(() => {})
67
727
  }
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
728
  }
85
729
 
86
730
  // Removing a finished session takes its worktree and branch with it only when
87
731
  // nothing is lost: a clean worktree whose branch is already in its base.
88
732
  export function pruneSessionWorktree(s) {
733
+ clearCanLandCache()
89
734
  const wt = s.worktree
90
735
  if (!wt || !existsSync(wt.path)) return { removed: false, reason: 'no worktree on disk' }
91
736
  const status = git(wt.path, ['status', '--porcelain'])
92
- // a status check that fails must not read as clean
93
737
  if (!status.ok) return { removed: false, reason: `cannot verify ${wt.path}: git status failed` }
94
738
  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
739
  const cherry = wt.base ? git(s.repo, ['cherry', wt.base, wt.branch]) : { ok: false, out: '' }
99
740
  const ancestor = wt.base && git(s.repo, ['merge-base', '--is-ancestor', wt.branch, wt.base]).ok
100
741
  const merges = wt.base ? git(s.repo, ['rev-list', '--merges', `${wt.base}..${wt.branch}`]) : { ok: false, out: '' }