import * as git from '../lib/git.js' import * as branch from '../lib/branch.js' import * as config from '../lib/config.js' import * as worktree from '../lib/worktree.js' import * as version from '../lib/version.js' import * as provider from '../lib/provider.js' import type { FinishResult, FinishOptions } from './types.js' /** * Resolve a working directory in which `branchName` is checked out, so we can * operate on it (merge-back, version bump) WITHOUT a fragile `git checkout` * that fails when the branch lives in another worktree. * - worktree repo → that branch's worktree path (no checkout) * - flat repo → check the branch out in `cwd` and use `cwd` * Returns undefined when the branch can be neither located nor checked out. */ async function resolveBranchWorkdir(branchName: string, cwd?: string): Promise { const worktrees = await worktree.listWorktrees(cwd) const wt = worktrees.find((w) => w.branch === branchName) if (wt?.path) return wt.path try { await git.checkout(branchName, cwd) return cwd ?? process.cwd() } catch { return undefined } } export async function execute( opts: FinishOptions, cwd?: string, ): Promise { try { // Get current or specified branch const currentBranch = opts.branch || await git.getCurrentBranch(cwd) // Detect type const branchType = branch.detectBranchType(currentBranch) // Block if main/develop/other if (branchType === 'main' || branchType === 'develop' || branchType === 'other') { return { success: false, branch: currentBranch, branchType: 'feature', error: `Cannot finish ${branchType} branch`, } } // Read config — resolve from the worktree cwd (= --workdir), NOT process.cwd(). const cfg = await config.readConfigForPlatform((await config.resolveConfigPath(cwd)) ?? undefined) const warnings: string[] = [] // Correct target by type (feature→develop, release/hotfix→main) + invariant. const targetBranch = branch.getTargetBranch(branchType, cfg) branch.assertBranchTarget(branchType, targetBranch, cfg) // Confirm the branch is merged. Prefer the provider's PR state (robust to // squash-merges, which `git branch --merged` cannot detect); fall back to git // against the CORRECT target. let merged = false try { const pr = await provider.findPR(currentBranch, cfg.git.provider, cwd) if (pr && /merged|completed|closed/i.test(pr.state)) merged = true } catch { // provider/CLI unavailable — fall through to the git check } if (!merged) { try { merged = await git.isMerged(currentBranch, targetBranch, cwd) } catch { // ignore — treated as "not confirmed" } } if (!merged) { // 4.x behaviour: WARN, do not hard-block. But skip every destructive step // (tag, merge-back, branch deletion) so we never tag prematurely or destroy // unmerged work. The user merges the PR, then runs finish again. warnings.push( `Could not confirm ${currentBranch} is merged into ${targetBranch}. ` + `Skipped tag / merge-back / branch deletion — merge the PR first, then run finish again.`, ) return { success: true, branch: currentBranch, branchType: branchType as 'feature' | 'release' | 'hotfix', warnings, } } let tag: string | undefined let mergedBack = false let versionBumped = false let newVersion: string | undefined // For release/hotfix: tag the released main commit, update develop FROM main // (merge-back), then bump develop to the next dev version and COMMIT + PUSH // it. Mirrors the 4.x finish (step-finish + finish-version-bumping) exactly. if (branchType === 'release' || branchType === 'hotfix') { const versionStr = (currentBranch.match(/(?:release|hotfix)\/(.+)/)?.[1] || '').trim() if (!/^\d+\.\d+\.\d+/.test(versionStr)) { warnings.push( `Could not parse a semantic version from "${currentBranch}" — skipped tag / merge-back / version bump.`, ) } else { await git.fetch('origin', cwd) const mainBranch = cfg.git.branches.main const developBranch = cfg.git.branches.develop const tagName = `${cfg.versioning.tagPrefix}${versionStr}` // 1. Tag the RELEASED main commit (origin/main) — no checkout (worktree-safe). if (await git.tagExists(tagName, cwd)) { tag = tagName warnings.push(`Tag ${tagName} already exists — not re-created.`) } else if (await git.branchExists(mainBranch, true, cwd)) { await git.createTagAt(tagName, `origin/${mainBranch}`, `Release ${versionStr} (${branchType})`, cwd) await git.pushTag(tagName, cwd) tag = tagName } else { warnings.push(`Remote ${mainBranch} not found — skipped tagging (merge the PR into ${mainBranch} first).`) } // 2. Update develop FROM main (merge origin/main → develop) in develop's // own worktree, so no fragile checkout is needed. const devDir = await resolveBranchWorkdir(developBranch, cwd) if (!devDir) { warnings.push( `Could not locate a "${developBranch}" worktree/checkout — skipped merge-back and version bump. ` + `Update ${developBranch} from ${mainBranch} manually.`, ) } else { await git.pull('origin', developBranch, devDir) const merge = await git.mergeRefNoFf( `origin/${mainBranch}`, `Merge ${mainBranch} into ${developBranch} after ${versionStr}`, devDir, ) if (!merge.ok) { await git.abortMerge(devDir) warnings.push( `Merge ${mainBranch} → ${developBranch} hit conflicts (${merge.conflicts.join(', ') || 'unknown'}) — ` + `aborted. Resolve manually, then re-run finish.`, ) } else { const pushDev = await git.push(developBranch, devDir) mergedBack = pushDev.exitCode === 0 if (!mergedBack) { warnings.push(`Merge-back ${mainBranch} → ${developBranch} committed but push failed: ${pushDev.stderr || pushDev.stdout}`) } // 3. Bump develop to the next dev version, then COMMIT + PUSH (4.x). // // A bump NEVER goes backwards. `nextDevVersion` derives the next dev // version from the RELEASED one, which is behind develop whenever a // hotfix ships after develop already moved on (hotfix 5.19.2 → 5.19.4 // while develop sits at 5.20.0). Writing that unconditionally would // rewind develop's version and make the next release publish an // already-published number. newVersion = version.nextDevVersion(versionStr, branchType) const devCurrent = (await version.detectVersion(devDir)).current if (!version.isNewerVersion(newVersion, devCurrent)) { warnings.push( `${developBranch} is already at ${devCurrent}, ahead of the ${newVersion} this ${branchType} would set — ` + `version bump SKIPPED (a bump never goes backwards).`, ) newVersion = devCurrent } else { const bump = await version.writeAndCommitVersion( devDir, newVersion, cfg.versioning.sources, `chore: bump version to ${newVersion} for development`, ) if (bump.warning) warnings.push(bump.warning) if (bump.committed) { const pushBump = await git.push(developBranch, devDir) versionBumped = pushBump.exitCode === 0 if (!versionBumped) { warnings.push(`Version bump committed but push failed: ${pushBump.stderr || pushBump.stdout}`) } } else if (bump.error) { warnings.push(`Version bump commit failed: ${bump.error}`) } else { warnings.push(`${developBranch} already at ${newVersion} — no version bump needed.`) } } } } } } // Cleanup worktree if enabled let worktreeCleaned = false if (cfg.worktrees.enabled) { const wtPath = worktree.getWorktreePath(currentBranch, cfg, cwd) if (wtPath) { try { await worktree.removeWorktree(wtPath, cwd) worktreeCleaned = true } catch { // Ignore worktree cleanup errors } } } // Delete branch locally and remotely try { await git.deleteBranch(currentBranch, false, false, cwd) } catch { // Ignore local deletion errors } try { await git.deleteBranch(currentBranch, true, false, cwd) } catch { // Ignore remote deletion errors } return { success: true, branch: currentBranch, branchType: branchType as 'feature' | 'release' | 'hotfix', tag, mergedBack, versionBumped, version: newVersion, worktreeCleaned, warnings: warnings.length > 0 ? warnings : undefined, } } catch (err) { const message = err instanceof Error ? err.message : String(err) return { success: false, branch: opts.branch || '', branchType: 'feature', error: message, } } }