import * as git from '../lib/git.js' import * as branch from '../lib/branch.js' import * as config from '../lib/config.js' import * as provider from '../lib/provider.js' import { alignDevelopWithRemote } from '../lib/worktree.js' import { checkSeedDeltaGate } from '../lib/seed-delta-gate.js' import { checkMigrationParityGate } from '../lib/migration-parity-gate.js' import { decidePrMigrationGate } from '../lib/efcore.js' import type { PrResult, PrOptions } from './types.js' /** Migration .cs files in a diff (excludes Designer + ModelSnapshot). */ function migrationsInDiff(files: string[]): string[] { return files.filter( (f) => /\/Migrations\//i.test(f) && f.endsWith('.cs') && !/\.Designer\.cs$/i.test(f) && !/ModelSnapshot\.cs$/i.test(f), ) } export async function execute( opts: PrOptions, cwd?: string, ): Promise { try { // Get current branch const currentBranch = await git.getCurrentBranch(cwd) // Detect type const branchType = branch.detectBranchType(currentBranch) // Block if on main/develop if (branchType === 'main' || branchType === 'develop') { return { success: false, provider: 'unknown', title: '', source: currentBranch, target: '', draft: opts.draft, error: `Cannot create PR from ${branchType} branch`, } } // Read config — resolve from the worktree cwd (= --workdir), NOT process.cwd(). // readConfigForPlatform() used to ignore cwd and resolve from process.cwd(), so running // the CLI from a directory other than the worktree loaded the wrong/empty config // (→ provider '' → "Unsupported provider"). const cfgPath = await config.resolveConfigPath(cwd) if (!cfgPath) { return { success: false, provider: 'unknown', title: '', source: currentBranch, target: '', draft: opts.draft, error: `GitFlow config not found from '${cwd ?? process.cwd()}'. Run gitflow init first.`, } } const cfg = await config.readConfigForPlatform(cfgPath) // Resolve target + enforce the hard branch-target invariant FIRST: a feature // can never target main, develop must be set and ≠ main. (Throws → caught // below → reported as a failure, before any PR is created.) const targetBranch = branch.getTargetBranch(branchType, cfg) branch.assertBranchTarget(branchType, targetBranch, cfg) // Simon's rule: align local develop with origin BEFORE opening a feature PR // onto it (so the PR base + the squash-diff vs origin/develop are current). let developAligned: boolean | undefined const warnings: string[] = [] if (targetBranch === cfg.git.branches.develop) { const dev = cfg.worktrees?.structure?.develop if (dev) { const align = await alignDevelopWithRemote(dev, cfg.git.branches.develop) if (!align.ok) { return { success: false, provider: cfg.git.provider, title: '', source: currentBranch, target: targetBranch, draft: opts.draft, error: `Pre-PR alignment failed: ${align.error}`, } } developAligned = true } else { warnings.push('No develop worktree in config — skipped local develop alignment.') } } // Non-blocking freshness check: warn when a feature lags origin/develop — // the merge will be against a base it has never seen. /gitflow update fixes it. if (branchType === 'feature') { try { const baseRef = await git.resolveBaseRef(targetBranch, cwd) const vsBase = await git.getAheadBehind(currentBranch, baseRef, cwd) if (vsBase.behind > 0) { warnings.push( `'${currentBranch}' is ${vsBase.behind} commit(s) behind ${baseRef} — consider running /gitflow update before merging`, ) } } catch { /* freshness is advisory — it must never block PR creation */ } } // Push unpushed commits const aheadBehind = await git.getAheadBehind(currentBranch, `origin/${currentBranch}`, cwd) if (aheadBehind.ahead > 0) { const pushResult = await git.push(currentBranch, cwd, false, false) if (pushResult.exitCode !== 0) { return { success: false, provider: cfg.git.provider, title: '', source: currentBranch, target: targetBranch, draft: opts.draft, error: `Failed to push commits: ${pushResult.stderr || pushResult.stdout}`, } } } // Check for existing PR — verify its target matches before reporting success. const existingPR = await provider.findPR(currentBranch, cfg.git.provider, cwd) if (existingPR) { if (existingPR.target && existingPR.target !== targetBranch) { return { success: false, provider: cfg.git.provider, prNumber: existingPR.number, prUrl: existingPR.url, title: existingPR.title, source: currentBranch, target: existingPR.target, draft: opts.draft, error: `Existing PR #${existingPR.number} targets '${existingPR.target}' but a ${branchType} branch must target '${targetBranch}'. Abandon or retarget it before continuing.`, } } return { success: true, provider: cfg.git.provider, prNumber: existingPR.number, prUrl: existingPR.url, title: existingPR.title, source: currentBranch, target: existingPR.target, draft: opts.draft, developAligned, warnings: warnings.length ? warnings : undefined, } } // EF Core squash-before-PR (4.x §4.2): a feature must carry ≤1 migration // (hard block); a release/hotfix bringing >1 migration to main gets an // ADVISORY warning (N migrations — one per squashed feature — is // legitimate there, but the count deserves eyes before prod). One diff, // one pure decision (decidePrMigrationGate, lib/efcore.ts). if (cfg.efcore?.squashBeforePR && (branchType === 'feature' || branchType === 'release' || branchType === 'hotfix')) { const diffFiles = await git.getDiffFiles(`origin/${targetBranch}...HEAD`, cwd) const migrations = migrationsInDiff(diffFiles).map((m) => m.split(/[/\\]/).pop() || m) const gate = decidePrMigrationGate(branchType, migrations, cfg.efcore.squashBeforePR) if (gate.error) { return { success: false, provider: cfg.git.provider, title: '', source: currentBranch, target: targetBranch, draft: opts.draft, error: gate.error, } } if (gate.warning) warnings.push(gate.warning) } // Core-seed delta gate (PRs to MAIN only — release/hotfix): a changed // .smartstack/core-seed/*.state.json requires the matching committed SQL // delta script (derive-seed-delta) — the boot seed of generated apps is // strictly additive, so without the script prod nav/RBAC data would // silently desynchronise. Inert for repos without state files. if (targetBranch === cfg.git.branches.main) { // EF Core migration parity — the prod-regression gate. Every migration // already on main must still be present at HEAD. A release cut BEFORE a // hotfix landed on main carries an older ModelSnapshot.cs; the merge // (release/hotfix → main never squashes) can drop the hotfix's model and // its migration with NO conflict. Same signal that hard-gates // /efcore squash, moved onto the path to production. const parity = await checkMigrationParityGate(targetBranch, cwd, opts.confirmRebaseline) warnings.push(...parity.warnings) if (!parity.ok) { return { success: false, provider: cfg.git.provider, title: '', source: currentBranch, target: targetBranch, draft: opts.draft, warnings: warnings.length ? warnings : undefined, error: parity.error, } } const seedGate = await checkSeedDeltaGate(targetBranch, cwd) warnings.push(...seedGate.warnings) if (!seedGate.ok) { return { success: false, provider: cfg.git.provider, title: '', source: currentBranch, target: targetBranch, draft: opts.draft, error: seedGate.error, } } } // Generate title from git log const logs = await git.getLog(1, '%s', cwd) let title = logs[0] || 'PR from ' + currentBranch // Add prefix based on branch type if (branchType === 'release' && !title.startsWith('Release ')) { const versionMatch = currentBranch.match(/release\/(.+)/) title = versionMatch ? `Release ${versionMatch[1]}` : title } else if (branchType === 'hotfix' && !title.startsWith('Hotfix:')) { title = `Hotfix: ${title}` } // Generate body from recent commits const recentLogs = await git.getLog(5, '%h %s', cwd) const body = recentLogs.join('\n') // Create PR via provider. Resolve the Azure DevOps org/project/repo ONCE so both the // create call and a potential auto-abandon pass them explicitly (no `az` cwd detection). const azureInfo = cfg.git.provider === 'azuredevops' ? (provider.parseAzureDevOpsUrl(cfg.repository.remoteUrl) ?? undefined) : undefined const prOptions = { title, body, source: currentBranch, target: targetBranch, draft: opts.draft, provider: cfg.git.provider, azure: azureInfo, } const createResult = await provider.createPR(prOptions, cwd) if (!createResult.success) { return { success: false, provider: cfg.git.provider, title, source: currentBranch, target: targetBranch, draft: opts.draft, error: createResult.error, } } // VERIFY the created PR actually targets the intended branch — do NOT trust // the request. This is the check that would have caught PR #451 / #467 // (feature→main): read the PR back from the provider and compare. const created = await provider.getPR(createResult.pr!.number, cfg.git.provider, cwd) if (created && created.target && created.target !== targetBranch) { // Auto-abandon so a mis-targeted (e.g. feature→main) PR never lingers on the server // waiting for a human to clean it up. const abandon = await provider.abandonPR(created.number, cfg.git.provider, cwd, azureInfo) // Honest diagnosis: only blame a missing develop AFTER actually checking the remote. const developRef = `refs/heads/${cfg.git.branches.develop}` const developOnRemote = await git.remoteRefExists(developRef, cwd) const cause = developOnRemote ? `the provider did not honor --target-branch '${targetBranch}' (it fell back to the repository's default branch) — a CLI/provider bug, not your config` : `'${cfg.git.branches.develop}' is missing on the remote — push it first` return { success: false, provider: cfg.git.provider, prNumber: created.number, prUrl: created.url, title, source: currentBranch, target: created.target, draft: opts.draft, error: `PR #${created.number} was created targeting '${created.target}', not '${targetBranch}' ` + `(a ${branchType} branch must never merge to '${created.target}'). ` + `${abandon.success ? 'It was auto-abandoned.' : `Auto-abandon FAILED (${abandon.error ?? 'unknown'}) — abandon it manually.`} ` + `Cause: ${cause}.`, } } return { success: true, provider: cfg.git.provider, prNumber: createResult.pr!.number, prUrl: createResult.pr!.url, title: createResult.pr!.title, source: currentBranch, target: targetBranch, draft: opts.draft, buildPassed: true, developAligned, warnings: warnings.length ? warnings : undefined, } } catch (err) { const message = err instanceof Error ? err.message : String(err) return { success: false, provider: 'unknown', title: '', source: '', target: '', draft: opts.draft, error: message, } } }