/** * commit CLI execute — Execution logic, no validation */ import type { CommitSpec, CommitResult } from './types.js'; import { getCurrentBranch, getStatus, addFiles, commit, push, getLog, getDiffFiles, getStagedFiles } from '../lib/git.js'; import { detectBranchType } from '../lib/branch.js'; import { validateEfCore, decideEfcorePolicy, planStaging } from '../lib/efcore.js'; import { readConfig, resolveConfigPath } from '../lib/config.js'; import { detectVersion } from '../lib/version.js'; function generateCommitMessage(branch: string, version?: string): string { const type = detectBranchType(branch); let scope = branch.replace(/^(feature|release|hotfix)\//, ''); scope = scope.split('-')[0]; const tag = version ? `[v${version}] ` : ''; switch (type) { case 'feature': return `feat(${scope}): ${tag}update files`; case 'hotfix': return `fix(${scope}): ${tag}update files`; case 'release': return `chore(${scope}): ${tag}update files`; default: return `chore: update files`; } } export async function execute(spec: CommitSpec, cwd?: string): Promise { try { const branch = await getCurrentBranch(cwd); if (branch === 'main' || branch === 'master') { return { success: false, error: 'Cannot commit directly to main branch', branch, filesChanged: 0, pushed: false, }; } if (branch === 'develop' || branch === 'development') { // warning, not blocker } const status = await getStatus(cwd); if (!status.dirty) { return { success: false, error: 'No changes to commit', branch, filesChanged: 0, pushed: false, }; } // Staging scope: explicit files (workdir-relative) or everything. let stagingPathspecs: string[] = spec.files?.length ? [...spec.files] : ['.']; let excludedFiles: string[] | undefined; let efcoreValidation = undefined; let efcoreWarnings: string[] = []; if (!spec.noEfcore) { // Scoped commit → validate ONLY the files being committed. const changedFiles = spec.files?.length ? [...spec.files] : [...status.staged, ...status.modified, ...status.untracked]; const validation = validateEfCore(changedFiles); efcoreValidation = validation; // Config-driven policy (restores 4.x): when a gitflow config exists its // efcore block drives blocking/confirmation (4.x defaults: all on). With NO // config we stay warn-only — don't impose gates outside a gitflow project. let efc: { enabled?: boolean; validateOnCommit?: boolean; blockDestructive?: boolean } = { enabled: false, validateOnCommit: false, blockDestructive: false }; try { const cfg = await readConfig((await resolveConfigPath(cwd)) ?? undefined); if (cfg.efcore) efc = cfg.efcore; } catch { // no gitflow config → warn-only defaults above } const policy = decideEfcorePolicy(validation, efc); efcoreWarnings = policy.warnings; // Incomplete migration changeset → SOFT-EXCLUDE (was a 4.x all-or-nothing // STOP): commit everything else, keep the whole Migrations/ content out, // surface it via excludedFiles. Falls back to the hard block when the // exclusion is impossible (migration files already staged) — and, below, // when nothing BUT the migration files changed. if (policy.block) { const plan = planStaging(stagingPathspecs, changedFiles, status.staged); if (!plan.ok) { return { success: false, error: `${policy.error} Cannot soft-exclude: ${plan.reason}.`, warnings: policy.warnings.length ? policy.warnings : undefined, branch, filesChanged: changedFiles.length, efcore: validation, pushed: false, }; } stagingPathspecs = plan.pathspecs; excludedFiles = plan.excludedFiles; efcoreWarnings = [ ...efcoreWarnings, `EF Core: incomplete migration changeset (missing ${validation.missing.join(', ')}) — Migrations/ files EXCLUDED from this commit (${plan.excludedFiles.join(', ') || 'none detected in scope'}). Regenerate the migration, then commit it separately.`, ]; } // Destructive ops → ask for confirmation (4.x AskUserQuestion), NOT a silent // hard block. The skill prompts, then re-runs commit with confirmDestructive. if (policy.requiresConfirmation && !spec.confirmDestructive) { return { success: false, requiresConfirmation: true, error: policy.error, warnings: policy.warnings.length ? policy.warnings : undefined, branch, filesChanged: changedFiles.length, efcore: validation, pushed: false, }; } if (policy.requiresConfirmation && spec.confirmDestructive) { efcoreWarnings = [ ...efcoreWarnings, `EF Core: destructive operations confirmed — proceeding (${validation.destructiveOps.join(', ')}).`, ]; } } let filesChanged = status.staged.length + status.modified.length + status.untracked.length; await addFiles(stagingPathspecs, cwd); // Scoped or soft-excluded staging → report what is ACTUALLY in the index, // not the whole dirty tree. If the soft-exclude left the index empty, the // changeset was the incomplete migration alone — restore the hard block. if (excludedFiles || spec.files?.length) { const staged = await getStagedFiles(cwd); if (excludedFiles && staged.length === 0) { return { success: false, error: `Incomplete migration changeset and nothing else to commit — missing: ${efcoreValidation?.missing.join(', ')}. Required: Migration.cs + Designer.cs + ModelSnapshot.cs.`, warnings: efcoreWarnings.length ? efcoreWarnings : undefined, branch, filesChanged: 0, efcore: efcoreValidation, pushed: false, }; } filesChanged = staged.length; } let version: string | undefined; if (!spec.message) { const projectDir = cwd || process.cwd(); const versionInfo = await detectVersion(projectDir); if (versionInfo.current !== '0.0.0') { version = versionInfo.current; } } const message = spec.message || generateCommitMessage(branch, version); const commitResult = await commit(message, cwd); if (commitResult.exitCode !== 0) { return { success: false, error: `Commit failed: ${commitResult.stderr || commitResult.stdout}`, branch, filesChanged, efcore: efcoreValidation, pushed: false, }; } let hash = ''; try { const logs = await getLog(1, '%H', cwd); hash = logs[0] || ''; } catch { hash = 'unknown'; } let pushed = false; if (spec.push) { const pushResult = await push(branch, cwd); pushed = pushResult.exitCode === 0; } return { success: true, branch, commit: { hash, message, }, filesChanged, efcore: efcoreValidation, warnings: efcoreWarnings.length ? efcoreWarnings : undefined, excludedFiles, pushed, }; } catch (err: unknown) { return { success: false, error: (err as Error).message, branch: '', filesChanged: 0, pushed: false, }; } }