/** * Rewrite plan: developer-editable format describing per-split git ops (cherry-pick or commit-from-sha). * WHY separate from parse-plan: Group plan (.split-plan.md) vs rewrite plan (.split-rewrite-plan.md/.yaml) are different artifacts; parser is forgiving for human/LLM edits. */ import { readFileSync, existsSync } from 'fs'; import { dirname, join } from 'path'; import { parse as parseYaml } from 'yaml'; import { assertValidGitBranchName } from './parse-plan.js'; /** * Single operation in a split's sequence. * WHY discriminated union: Executor switches on op.type; cherry-pick has no paths, commit-from-sha requires non-empty paths. */ export type RewritePlanOp = | { type: 'cherry-pick'; sha: string } | { type: 'commit-from-sha'; sha: string; paths: string[]; message?: string }; export interface RewritePlanSplit { branchName: string; splitIndex: number; ops: RewritePlanOp[]; } export interface RewritePlan { source_branch: string; source_tip_sha: string; target_branch: string; /** ISO timestamp when the plan was generated. WHY: Lets users see plan age; executor uses source_tip_sha for staleness check. */ generated_at: string; splits: RewritePlanSplit[]; } const COMMIT_SHA_REGEX = /^[0-9a-f]{7,40}$/i; function isValidSha(sha: string): boolean { return typeof sha === 'string' && COMMIT_SHA_REGEX.test(sha.trim()); } /** WHY: Developer-editable format may use comma-separated or list; we accept both and trim so parser is forgiving. */ function normalizePaths(v: unknown): string[] { if (Array.isArray(v)) return v.map((p) => String(p).trim()).filter(Boolean); if (typeof v === 'string') return v.split(/[\s,]+/).map((p) => p.trim()).filter(Boolean); return []; } function parseOp(raw: unknown): RewritePlanOp { if (!raw || typeof raw !== 'object') throw new Error('Invalid rewrite plan: op must be an object'); const o = raw as Record; const type = o.type; if (type === 'cherry-pick') { const sha = o.sha; if (!sha || !isValidSha(String(sha))) throw new Error('Invalid rewrite plan: cherry-pick op must have valid sha'); return { type: 'cherry-pick', sha: String(sha).trim() }; } if (type === 'commit-from-sha') { const sha = o.sha; if (!sha || !isValidSha(String(sha))) throw new Error('Invalid rewrite plan: commit-from-sha op must have valid sha'); const paths = normalizePaths(o.paths); if (paths.length === 0) throw new Error('Invalid rewrite plan: commit-from-sha op must have non-empty paths'); // WHY: Empty paths would checkout nothing; executor would commit no change or wrong scope. return { type: 'commit-from-sha', sha: String(sha).trim(), paths, message: o.message != null ? String(o.message) : undefined, }; } throw new Error(`Invalid rewrite plan: unknown op type "${type}"`); } function parseSplit(raw: unknown): RewritePlanSplit { if (!raw || typeof raw !== 'object') throw new Error('Invalid rewrite plan: split must be an object'); const s = raw as Record; const branchName = s.branchName; const splitIndex = s.splitIndex; if (!branchName || typeof branchName !== 'string' || !branchName.trim()) throw new Error('Invalid rewrite plan: split must have branchName'); const idx = typeof splitIndex === 'number' ? splitIndex : parseInt(String(splitIndex ?? 1), 10); const opsRaw = s.ops; const ops: RewritePlanOp[] = Array.isArray(opsRaw) ? opsRaw.map((op, i) => { try { return parseOp(op); } catch (e) { throw new Error(`Invalid rewrite plan: split "${branchName}" op ${i}: ${e instanceof Error ? e.message : e}`); } }) : []; const trimmedBranch = String(branchName).trim(); assertValidGitBranchName(trimmedBranch, `rewrite plan split ${idx}`); return { branchName: trimmedBranch, splitIndex: idx, ops }; } function parsePayload(data: Record): RewritePlan { const source_branch = data.source_branch; const source_tip_sha = data.source_tip_sha; const target_branch = data.target_branch; const generated_at = data.generated_at; if (!source_branch || typeof source_branch !== 'string') throw new Error('Invalid rewrite plan: missing or invalid source_branch'); if (!source_tip_sha || !isValidSha(String(source_tip_sha))) throw new Error('Invalid rewrite plan: missing or invalid source_tip_sha'); if (!target_branch || typeof target_branch !== 'string') throw new Error('Invalid rewrite plan: missing or invalid target_branch'); const splitsRaw = data.splits; if (!Array.isArray(splitsRaw)) throw new Error('Invalid rewrite plan: splits must be an array'); const splits = splitsRaw.map((s, i) => { try { return parseSplit(s); } catch (e) { throw new Error(`Invalid rewrite plan: split ${i}: ${e instanceof Error ? e.message : e}`); } }); const srcBranch = String(source_branch).trim(); const tgtBranch = String(target_branch).trim(); assertValidGitBranchName(srcBranch, 'rewrite plan source_branch'); assertValidGitBranchName(tgtBranch, 'rewrite plan target_branch'); return { source_branch: srcBranch, source_tip_sha: String(source_tip_sha).trim(), target_branch: tgtBranch, generated_at: generated_at != null ? String(generated_at) : '', splits, }; } const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?/; /** * Parse a rewrite plan file (JSON, YAML, or Markdown with YAML frontmatter). Developer-editable; parser is forgiving (trim, normalize paths). * WHY .md: Plan format allows Markdown + YAML frontmatter; we extract the first --- block and parse as YAML. */ export function parseRewritePlanFile(path: string): RewritePlan { if (!existsSync(path)) throw new Error(`Rewrite plan file not found: ${path}`); const raw = readFileSync(path, 'utf-8'); const trimmed = raw.trim(); let data: Record; if (path.endsWith('.json') || trimmed.startsWith('{')) { try { data = JSON.parse(trimmed) as Record; } catch (e) { throw new Error(`Invalid rewrite plan JSON in ${path}: ${e instanceof Error ? e.message : e}`); } } else if (path.endsWith('.md')) { const frontMatch = trimmed.match(FRONTMATTER_REGEX); const yamlBlock = frontMatch ? frontMatch[1] : trimmed; try { data = parseYaml(yamlBlock) as Record; } catch (e) { throw new Error(`Invalid rewrite plan (markdown frontmatter) in ${path}: ${e instanceof Error ? e.message : e}`); } } else { try { data = parseYaml(trimmed) as Record; } catch (e) { throw new Error(`Invalid rewrite plan YAML in ${path}: ${e instanceof Error ? e.message : e}`); } } if (!data || typeof data !== 'object') throw new Error(`Invalid rewrite plan: root must be an object in ${path}`); return parsePayload(data); } /** * Resolve rewrite plan path: explicit path, or beside group plan as .split-rewrite-plan.md / .yaml / .json. * WHY beside group plan: Common workflow is "split-rewrite-plan then split-exec" in the same dir; one plan file next to the other keeps paths simple. */ export function resolveRewritePlanPath(groupPlanPath: string, explicitPath?: string): string | null { if (explicitPath?.trim()) return explicitPath.trim(); const dir = dirname(groupPlanPath); const candidates = [ join(dir, '.split-rewrite-plan.md'), join(dir, '.split-rewrite-plan.yaml'), join(dir, '.split-rewrite-plan.yml'), join(dir, '.split-rewrite-plan.json'), ]; for (const p of candidates) { if (existsSync(p)) return p; } return null; }