@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.
- package/lib/git-context.d.ts +53 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +756 -26
- package/lib/init-templates.d.ts +4 -0
- package/lib/phase-rules.d.ts +28 -3
- package/lib/recursive_phase.tool.d.ts +9 -0
- package/lib/recursive_worktree.tool.d.ts +7 -0
- package/lib/runtime.d.ts +46 -1
- package/lib/ts-lint.d.ts +8 -0
- package/lib/worktree.d.ts +59 -0
- package/package.json +1 -1
- package/src/commands.ts +46 -0
- package/src/git-context.ts +132 -0
- package/src/index.ts +14 -3
- package/src/init-templates.ts +15 -2
- package/src/phase-rules.ts +49 -6
- package/src/policy.ts +1 -0
- package/src/recursive_init.tool.ts +9 -3
- package/src/recursive_phase.tool.ts +29 -0
- package/src/recursive_worktree.tool.ts +54 -0
- package/src/runtime.ts +75 -6
- package/src/ts-lint.ts +31 -0
- package/src/worktree.ts +229 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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) {
|
package/src/worktree.ts
ADDED
|
@@ -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
|
+
}
|