@try-works/dsh-recursive-mode 0.1.18 → 0.2.1

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.
@@ -0,0 +1,29 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from '@deepseek-ai/dsh-tools'
3
+ import type { RecursiveRuntime } from './runtime.ts'
4
+
5
+ /**
6
+ * recursive_phase (refined LIVE BUG 6): the canonical on-demand home of the
7
+ * current phase's lint rules + instructions. Reads the same structured source
8
+ * (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
9
+ * reminder, so the agent can re-ask for the rules without re-injecting them on
10
+ * every step. Returns { error } when no active phase is found.
11
+ */
12
+ export function createRecursivePhaseTool(recursive: RecursiveRuntime) {
13
+ return defineTool({
14
+ name: 'recursive_phase',
15
+ description: 'Return the lint rules + instructions for the current recursive-mode phase (required sections, gates, TDD/QA notes). Call once when entering a new phase; the same rules are also auto-injected once per phase transition.',
16
+ parameters: {
17
+ runId: { type: 'string', description: 'Optional run id (defaults to the latest run by mtime)' },
18
+ },
19
+ output: {
20
+ schema: { type: 'json' },
21
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
22
+ },
23
+ async execute(args: { runId?: string }, exec) {
24
+ const result = await recursive.phaseRules(args.runId, exec.agent as { session?: { header?: { cwd?: string } } } | null)
25
+ if (!result) return { error: 'no recursive phase found' } as const
26
+ return result as unknown as JsonValue
27
+ },
28
+ })
29
+ }
@@ -0,0 +1,54 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from '@deepseek-ai/dsh-tools'
3
+ import type { RecursiveRuntime } from './runtime.ts'
4
+
5
+ /**
6
+ * `recursive_worktree` — create a linked git worktree for a run and/or
7
+ * promote a branch up the dev/stage/main chain. Workspace-scoped: the
8
+ * operations run under the SESSION's control-plane root only.
9
+ */
10
+ export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
11
+ return defineTool({
12
+ name: 'recursive_worktree',
13
+ description: 'Create a linked git worktree for a recursive-mode run and/or promote a branch up the dev/stage/main chain. Workspace-scoped under the current session workspace.',
14
+ parameters: {
15
+ runId: { type: 'string', description: 'Run id the worktree is created for (e.g. 03-something). Required for create.' },
16
+ action: { type: 'string', description: 'create | promote | status. Default: create.' },
17
+ fromBranch: { type: 'string', description: 'Source branch for a promote action (the branch holding the new commits).' },
18
+ toBranch: { type: 'string', description: 'Promotion target branch for a promote action (feature -> dev -> stage -> main).' },
19
+ baseBranch: { type: 'string', description: 'Base branch the worktree branch is cut from (default: current HEAD branch).' },
20
+ },
21
+ output: {
22
+ schema: { type: 'json' },
23
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
24
+ },
25
+ async execute(args: { action?: string; runId?: string; fromBranch?: string; toBranch?: string; baseBranch?: string }, exec) {
26
+ const action = args.action ?? 'create'
27
+ // Resolve the control-plane root: agent cwd first, repoRoot fallback (B4),
28
+ // so tests and headless callers work without a live session header.
29
+ const root = await recursive.resolveRootFor(exec.agent as { session?: { header?: { cwd?: string } } } | null)
30
+ if (!root) {
31
+ return { error: 'session is not attached to a registered workspace (cannot resolve control-plane root)' } as const
32
+ }
33
+ if (action === 'create') {
34
+ if (!args.runId || args.runId.trim() === '') {
35
+ return { error: 'runId is required for create' } as const
36
+ }
37
+ const result = recursive.createRunWorktree(root, args.runId.trim(), args.baseBranch?.trim() || undefined)
38
+ return result as unknown as JsonValue
39
+ }
40
+ if (action === 'promote') {
41
+ if (!args.fromBranch || !args.toBranch) {
42
+ return { error: 'fromBranch and toBranch are required for promote' } as const
43
+ }
44
+ const result = recursive.promoteRunBranch(root, args.fromBranch.trim(), args.toBranch.trim())
45
+ return result as unknown as JsonValue
46
+ }
47
+ if (action === 'status') {
48
+ const result = recursive.worktreeStatus(root)
49
+ return result as unknown as JsonValue
50
+ }
51
+ return { error: 'action must be create | promote | status' } as const
52
+ },
53
+ })
54
+ }
package/src/runtime.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from './lock.ts'
16
16
  import type { RecursiveStatusResult } from './types.ts'
17
17
  import { resolveControlPlaneRoot, type WorkspaceRegistryLike } from './workspace.ts'
18
+ import { phaseRulesFor, type PhaseRules } from './phase-rules.ts'
18
19
  import { closeoutPhase } from './closeout.ts'
19
20
  import { readScratch, writeScratch, appendScratch, type ScratchTarget } from './scratch.ts'
20
21
  import { buildReviewBundle, type ReviewBundleInput } from './review.ts'
@@ -26,6 +27,8 @@ import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluatePreStepGate, eva
26
27
  import type { Session } from '@deepseek-ai/dsh-session'
27
28
  import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
28
29
  import { snapshotWorkspace } from './snapshot.ts'
30
+ import { createLinkedWorktree, promoteBranch, listWorktrees, defaultWorktreeBranch, type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts'
31
+ import { gitFacts } from './git-context.ts'
29
32
 
30
33
  declare module '@deepseek-ai/cordis' {
31
34
  interface Context {
@@ -336,6 +339,23 @@ export class RecursiveRuntime extends Service {
336
339
  return foldRun(resolved.runDir, resolved.runId)
337
340
  }
338
341
 
342
+ /**
343
+ * LIVE BUG 6 refined: structured phase rules for the CURRENT phase. Resolves
344
+ * the workspace root (same as status/lock), finds the latest run (or the
345
+ * given runId), advances via getNextLegalPhase, and returns the phase's lint
346
+ * rules + instructions. Returns null when no active phase exists. This is the
347
+ * canonical data source for the recursive_phase tool.
348
+ */
349
+ async phaseRules(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<(PhaseRules & { runId: string; phase: string }) | null> {
350
+ const root = await this.resolveRootFor(agent)
351
+ if (!root) return null
352
+ const resolved = resolveRunDir(root, runId)
353
+ if (!resolved) return null
354
+ const phase = getNextLegalPhase(resolved.runDir)
355
+ if (!phase) return null
356
+ return { runId: resolved.runId, phase, ...phaseRulesFor(phase) }
357
+ }
358
+
339
359
  /**
340
360
  * Scaffold a run directory with FULL per-phase templates (no-op if exists).
341
361
  * 00-requirements.md + 00-worktree.md are byte-identical to canonical
@@ -343,11 +363,24 @@ export class RecursiveRuntime extends Service {
343
363
  * required section (get_artifact_required_sections) + TODO + FAIL gates.
344
364
  * Also scaffolds addenda/subagents/router-prompts/evidence dirs. Returns the
345
365
  * run dir + created artifacts.
366
+ *
367
+ * When `opts.createWorktree` is true, a linked worktree is first created at
368
+ * `.worktrees/<runId>/` and the run is scaffolded INSIDE it (per the
369
+ * "all subsequent phases execute in worktree context" rule). The worktree
370
+ * branch defaults to `recursive/<runId>` and is cut from `opts.baseBranch`
371
+ * (default: current HEAD branch of the root checkout).
346
372
  */
347
- async initRun(runId: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<{ runDir: string; runId: string; created: string[]; existing: string[] }> {
373
+ async initRun(runId: string, agent?: { session?: { header?: { cwd?: string } } } | null, opts?: { createWorktree?: boolean; baseBranch?: string }): Promise<{ runDir: string; runId: string; created: string[]; existing: string[]; worktree?: CreateWorktreeResult }> {
348
374
  const root = await this.resolveRootFor(agent)
349
375
  if (!root) throw new Error('cannot resolve workspace control-plane root for this session')
350
- const runDir = join(root, '.recursive', 'run', runId)
376
+ let scaffoldRoot = root
377
+ let worktree: CreateWorktreeResult | undefined
378
+ if (opts?.createWorktree) {
379
+ worktree = createLinkedWorktree({ repoRoot: root, runId, baseBranch: opts.baseBranch })
380
+ if (!worktree.ok) throw new Error(worktree.error ?? 'worktree create failed')
381
+ scaffoldRoot = worktree.worktreeDir
382
+ }
383
+ const runDir = join(scaffoldRoot, '.recursive', 'run', runId)
351
384
  mkdirSync(runDir, { recursive: true })
352
385
  const created: string[] = []
353
386
  const existing: string[] = []
@@ -360,13 +393,14 @@ export class RecursiveRuntime extends Service {
360
393
  created.push(dir + '/')
361
394
  }
362
395
 
363
- // Git context for the Phase 0 diff-basis prefill (canonical parity).
364
- const { context: gitContext, error: prefillError } = detectGitContext(root)
396
+ // Git context for the Phase 0 diff-basis prefill (canonical parity). When a
397
+ // worktree was created, the git context + Phase 0 record the WORKTREE.
398
+ const { context: gitContext, error: prefillError } = detectGitContext(scaffoldRoot)
365
399
 
366
400
  // Phase 0 templates: byte-identical to canonical recursive-init.py.
367
401
  const phase0: Array<[string, string]> = [
368
402
  ['00-requirements.md', requirementsContent(runId, 'feature', '')],
369
- ['00-worktree.md', worktreeContent(runId, root, gitContext, prefillError)],
403
+ ['00-worktree.md', worktreeContent(runId, scaffoldRoot, gitContext, prefillError)],
370
404
  ]
371
405
  for (const [file, content] of phase0) {
372
406
  const path = join(runDir, file)
@@ -395,7 +429,42 @@ export class RecursiveRuntime extends Service {
395
429
  created.push(file)
396
430
  }
397
431
 
398
- return { runDir, runId, created, existing }
432
+ const result: { runDir: string; runId: string; created: string[]; existing: string[]; worktree?: CreateWorktreeResult } = { runDir, runId, created, existing }
433
+ if (worktree) result.worktree = worktree
434
+ return result
435
+ }
436
+
437
+ /**
438
+ * Create a linked worktree for a run under the given workspace root. The
439
+ * worktree branch defaults to `recursive/<runId>` and is cut from the given
440
+ * base branch (default: the current HEAD branch of the root checkout).
441
+ * Refuses to create over an existing run directory. Workspace-scoped.
442
+ */
443
+ createRunWorktree(root: string, runId: string, baseBranch?: string): CreateWorktreeResult {
444
+ return createLinkedWorktree({ repoRoot: root, runId, baseBranch })
445
+ }
446
+
447
+ /**
448
+ * Promote a branch up the dev/stage/main chain (fast-forward). Workspace-scoped.
449
+ */
450
+ promoteRunBranch(root: string, fromBranch: string, toBranch: string): PromoteBranchResult {
451
+ return promoteBranch({ repoRoot: root, fromBranch, toBranch })
452
+ }
453
+
454
+ /**
455
+ * Worktree + branch status for a workspace root: the linked worktrees,
456
+ * which branch each is on, and the current checkout's base/upstream context.
457
+ */
458
+ worktreeStatus(root: string): Record<string, unknown> {
459
+ const facts = gitFacts(root)
460
+ const worktrees = listWorktrees(root)
461
+ return {
462
+ root,
463
+ isWorktree: facts.isWorktree,
464
+ branch: facts.branch,
465
+ upstreamBranch: facts.upstreamBranch,
466
+ worktrees,
467
+ }
399
468
  }
400
469
 
401
470
  /**
package/src/ts-lint.ts CHANGED
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
10
10
  import { join, basename, dirname } from 'node:path'
11
11
  import { createHash } from 'node:crypto'
12
12
  import { execFileSync } from 'node:child_process'
13
+ import { verifyBranchBase, verifyWorktreeBranch } from './git-context.ts'
13
14
  import {
14
15
  CURRENT_WORKFLOW_PROFILE,
15
16
  STRICT_WORKFLOW_PROFILE,
@@ -441,6 +442,26 @@ export function getRunDiffBasis(runDir: string): Record<string, string | null> {
441
442
  }
442
443
  }
443
444
 
445
+ /**
446
+ * verify_recorded_branches: worktree + branch awareness at lint time. Compares
447
+ * the branches recorded in 00-worktree.md against live git state. Returns a
448
+ * list of FAIL messages (empty when consistent). Both the base branch and the
449
+ * worktree branch must resolve; the worktree branch must match the live HEAD
450
+ * branch, and it must actually be based on the recorded base branch.
451
+ */
452
+ export function verifyRecordedBranches(repoRoot: string, diffBasis: Record<string, string | null>, runDir: string): string[] {
453
+ const fails: string[] = []
454
+ const baseBranch = trimMdValue(diffBasis.base_branch ?? '')
455
+ const worktreeBranch = trimMdValue(diffBasis.worktree_branch ?? '')
456
+ // Nothing recorded -> nothing to verify (defer, consistent with missing fields).
457
+ if (!baseBranch && !worktreeBranch) return fails
458
+ const wtCheck = verifyWorktreeBranch(repoRoot, worktreeBranch || null)
459
+ if (!wtCheck.ok && wtCheck.reason) fails.push(wtCheck.reason)
460
+ const baseCheck = verifyBranchBase(repoRoot, baseBranch || null, worktreeBranch || null)
461
+ if (!baseCheck.ok && baseCheck.reason) fails.push(baseCheck.reason)
462
+ return fails
463
+ }
464
+
444
465
  /** normalize_diff_basis: validate + compute the executable diff basis. */
445
466
  export function normalizeDiffBasis(repoRoot: string, diffBasis: Record<string, string | null>): [Record<string, string> | null, string | null] {
446
467
  const baselineType = normalizeBaselineType(diffBasis.baseline_type)
@@ -2007,6 +2028,16 @@ export function lintRun(repoRoot: string, runId: string): LintResult {
2007
2028
  let diffBasisError: string | null = null
2008
2029
  if (STRICT_WORKFLOW_PROFILES.has(workflowProfile)) {
2009
2030
  const diffBasis = getRunDiffBasis(runDir)
2031
+ // Worktree + branch awareness: verify the recorded base/worktree branch
2032
+ // context against live git before trusting the diff basis. Emits FAIL when
2033
+ // the run's recorded checkout/branch no longer matches reality (e.g. lint
2034
+ // running from the main checkout instead of the run's worktree, or the
2035
+ // worktree branch changed after Phase 0 locked).
2036
+ const branchFails = verifyRecordedBranches(root, diffBasis, runDir)
2037
+ for (const message of branchFails) {
2038
+ totalFail += 1
2039
+ writeIssue('FAIL', runDir, message)
2040
+ }
2010
2041
  if (diffBasis.baseline_reference || diffBasis.normalized_baseline) {
2011
2042
  const [rawChanged, gitError] = getGitChangedFiles(root, diffBasis)
2012
2043
  if (gitError) {
@@ -0,0 +1,229 @@
1
+ /**
2
+ * worktree.ts — drive linked-worktree creation and branch promotion.
3
+ *
4
+ * Worktree + branch awareness, operational half (R?): after detection
5
+ * (git-context.ts) and lint validation (ts-lint.ts), this module performs the
6
+ * git operations that actually realize the dev/stage/main worktree workflow:
7
+ *
8
+ * - `createLinkedWorktree`: create a linked worktree at
9
+ * `<repoRoot>/.worktrees/<runId>/` on a worktree branch based on a base
10
+ * branch (default: the upstream/promotion source branch).
11
+ * - `promoteBranch`: fast-forward one branch into a target promotion stage
12
+ * (feature -> dev -> stage -> main). The promotion is verified to be a
13
+ * fast-forward first (the target must be an ancestor of the source) and
14
+ * then applied where it is safe:
15
+ * - if the target branch is checked out in a worktree, `git merge
16
+ * --ff-only` runs THERE (safe: moves the branch and its working tree
17
+ * forward together, never a merge commit);
18
+ * - otherwise the branch ref is updated directly.
19
+ * It never rewrites history and never creates a merge commit.
20
+ *
21
+ * Both operations are explicit, workspace-scoped, and total: they return
22
+ * { ok, ... } rather than corrupting git state. No operation ever touches a
23
+ * branch other than the explicitly requested one.
24
+ */
25
+ import { execFileSync } from 'node:child_process'
26
+ import { existsSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+
29
+ export interface CreateWorktreeOptions {
30
+ repoRoot: string
31
+ runId: string
32
+ /** Base branch the worktree branch is cut from (default: current HEAD branch). */
33
+ baseBranch?: string
34
+ /** Worktree branch name (default: `recursive/<runId>`). */
35
+ worktreeBranch?: string
36
+ }
37
+
38
+ export interface CreateWorktreeResult {
39
+ ok: boolean
40
+ worktreeDir: string
41
+ worktreeBranch: string
42
+ baseBranch: string
43
+ error?: string
44
+ }
45
+
46
+ export interface PromoteBranchOptions {
47
+ repoRoot: string
48
+ /** Branch being promoted (the source of the new commits). */
49
+ fromBranch: string
50
+ /** Promotion target branch (feature -> dev -> stage -> main). */
51
+ toBranch: string
52
+ }
53
+
54
+ export interface PromoteBranchResult {
55
+ ok: boolean
56
+ fromBranch: string
57
+ toBranch: string
58
+ action: 'fast-forward' | 'created'
59
+ error?: string
60
+ }
61
+
62
+ /** Run git; throw on failure (callers catch to turn into a result). */
63
+ function gitThrow(repoRoot: string, ...args: string[]): string {
64
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
65
+ }
66
+
67
+ /** Resolve the current branch of a repo dir (short name or null when detached). */
68
+ function currentBranch(repoRoot: string): string | null {
69
+ try {
70
+ return gitThrow(repoRoot, 'symbolic-ref', '--quiet', '--short', 'HEAD')
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ /** Default worktree branch name for a run. */
77
+ export function defaultWorktreeBranch(runId: string): string {
78
+ return 'recursive/' + runId
79
+ }
80
+
81
+ /** One row of `git worktree list --porcelain` (path + branch when attached). */
82
+ export interface WorktreeInfo {
83
+ path: string
84
+ branch: string | null
85
+ detached: boolean
86
+ }
87
+
88
+ /** List linked worktrees (path + branch) for a repo root. */
89
+ export function listWorktrees(repoRoot: string): WorktreeInfo[] {
90
+ let out: string
91
+ try {
92
+ out = gitThrow(repoRoot, 'worktree', 'list', '--porcelain')
93
+ } catch {
94
+ return []
95
+ }
96
+ const result: WorktreeInfo[] = []
97
+ let current: WorktreeInfo | null = null
98
+ for (const line of out.split(/\r?\n/)) {
99
+ const trimmed = line.trim()
100
+ if (!trimmed) { if (current) { result.push(current); current = null } continue }
101
+ if (trimmed.startsWith('worktree ')) {
102
+ current = { path: trimmed.slice('worktree '.length), branch: null, detached: false }
103
+ } else if (trimmed.startsWith('branch refs/heads/') && current) {
104
+ current.branch = trimmed.slice('branch refs/heads/'.length)
105
+ } else if (trimmed === 'detached' && current) {
106
+ current.detached = true
107
+ }
108
+ }
109
+ if (current) result.push(current)
110
+ return result
111
+ }
112
+
113
+ /**
114
+ * Find the worktree path where `branch` is currently checked out, or null.
115
+ * Parses `git worktree list --porcelain` (block per worktree with a
116
+ * `branch refs/heads/<name>` line).
117
+ */
118
+ export function findWorktreeForBranch(repoRoot: string, branch: string): string | null {
119
+ let out: string
120
+ try {
121
+ out = gitThrow(repoRoot, 'worktree', 'list', '--porcelain')
122
+ } catch {
123
+ return null
124
+ }
125
+ let currentPath: string | null = null
126
+ for (const line of out.split(/\r?\n/)) {
127
+ const trimmed = line.trim()
128
+ if (!trimmed) { currentPath = null; continue }
129
+ if (trimmed.startsWith('worktree ')) {
130
+ currentPath = trimmed.slice('worktree '.length)
131
+ } else if (trimmed.startsWith('branch refs/heads/')) {
132
+ const name = trimmed.slice('branch refs/heads/'.length)
133
+ if (name === branch && currentPath) return currentPath
134
+ }
135
+ }
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Create a linked worktree at `.worktrees/<runId>/` for a run. The worktree
141
+ * branch is cut from `baseBranch` (default: the current HEAD branch). Returns
142
+ * the created worktree dir + branch. No-op-safe: if the worktree dir already
143
+ * exists, returns ok with the existing dir. Refuses to create a worktree when
144
+ * the run directory already exists (prevents clobbering an in-progress run).
145
+ */
146
+ export function createLinkedWorktree(opts: CreateWorktreeOptions): CreateWorktreeResult {
147
+ const { repoRoot, runId } = opts
148
+ const baseBranch = opts.baseBranch ?? currentBranch(repoRoot) ?? 'main'
149
+ const worktreeBranch = opts.worktreeBranch ?? defaultWorktreeBranch(runId)
150
+ const worktreeDir = join(repoRoot, '.worktrees', runId)
151
+
152
+ if (existsSync(worktreeDir)) {
153
+ return { ok: true, worktreeDir, worktreeBranch, baseBranch, error: undefined }
154
+ }
155
+ // Guard: never create a worktree over an in-progress run directory.
156
+ const runDir = join(repoRoot, '.recursive', 'run', runId)
157
+ if (existsSync(runDir)) {
158
+ return { ok: false, worktreeDir, worktreeBranch, baseBranch, error: 'run directory already exists: ' + runDir + ' (refusing to create a worktree for an existing run)' }
159
+ }
160
+ // Base branch must resolve.
161
+ try {
162
+ gitThrow(repoRoot, 'rev-parse', '--verify', baseBranch + '^{commit}')
163
+ } catch {
164
+ return { ok: false, worktreeDir, worktreeBranch, baseBranch, error: 'base branch does not resolve: ' + baseBranch }
165
+ }
166
+ try {
167
+ gitThrow(repoRoot, 'worktree', 'add', '-b', worktreeBranch, worktreeDir, baseBranch)
168
+ } catch (err) {
169
+ return { ok: false, worktreeDir, worktreeBranch, baseBranch, error: 'git worktree add failed: ' + (err as Error).message }
170
+ }
171
+ return { ok: true, worktreeDir, worktreeBranch, baseBranch }
172
+ }
173
+
174
+ /**
175
+ * Fast-forward `toBranch` to `fromBranch` (promotion up the dev/stage/main
176
+ * chain). Returns ok:false when the promotion is not a pure fast-forward
177
+ * (would require a merge commit) or when the from/to branches are missing.
178
+ */
179
+ export function promoteBranch(opts: PromoteBranchOptions): PromoteBranchResult {
180
+ const { repoRoot, fromBranch, toBranch } = opts
181
+ if (fromBranch === toBranch) {
182
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'from and to branches are identical: ' + fromBranch }
183
+ }
184
+ // Resolve the source commit; the target may or may not exist yet.
185
+ let fromSha: string | null = null
186
+ try { fromSha = gitThrow(repoRoot, 'rev-parse', '--verify', fromBranch + '^{commit}') } catch { /* missing */ }
187
+ if (!fromSha) {
188
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'from branch does not resolve: ' + fromBranch }
189
+ }
190
+ let toSha: string | null = null
191
+ try { toSha = gitThrow(repoRoot, 'rev-parse', '--verify', toBranch + '^{commit}') } catch { /* missing */ }
192
+
193
+ if (!toSha) {
194
+ // Target doesn't exist yet — create it at the source commit (first promotion).
195
+ try {
196
+ gitThrow(repoRoot, 'branch', toBranch, fromSha)
197
+ return { ok: true, fromBranch, toBranch, action: 'created' }
198
+ } catch (err) {
199
+ return { ok: false, fromBranch, toBranch, action: 'created', error: 'git branch failed: ' + (err as Error).message }
200
+ }
201
+ }
202
+
203
+ // Verify the target is an ancestor of the source (a pure fast-forward).
204
+ try {
205
+ execFileSync('git', ['-C', repoRoot, 'merge-base', '--is-ancestor', toBranch, fromBranch], { stdio: ['ignore', 'pipe', 'pipe'] })
206
+ } catch {
207
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'promotion is not a fast-forward: ' + toBranch + ' is not an ancestor of ' + fromBranch }
208
+ }
209
+
210
+ // Apply the fast-forward where it is safe:
211
+ // - if the target branch is checked out in a worktree, merge --ff-only there
212
+ // (moves the branch AND its working tree forward together, never a merge);
213
+ // - otherwise update the branch ref directly.
214
+ const checkedOutIn = findWorktreeForBranch(repoRoot, toBranch)
215
+ if (checkedOutIn) {
216
+ try {
217
+ gitThrow(checkedOutIn, 'merge', '--ff-only', fromBranch)
218
+ return { ok: true, fromBranch, toBranch, action: 'fast-forward' }
219
+ } catch (err) {
220
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'fast-forward merge in ' + checkedOutIn + ' failed: ' + (err as Error).message }
221
+ }
222
+ }
223
+ try {
224
+ gitThrow(repoRoot, 'update-ref', 'refs/heads/' + toBranch, fromSha)
225
+ return { ok: true, fromBranch, toBranch, action: 'fast-forward' }
226
+ } catch (err) {
227
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'git update-ref failed: ' + (err as Error).message }
228
+ }
229
+ }