// this file contains code to create commits in a consistent way import * as plugins from "./mod.plugins.js"; import * as paths from "../paths.js"; import { logger } from "../gitzone.logging.js"; import * as ui from "./mod.ui.js"; import type { ICliMode } from "../helpers.climode.js"; import { getCliMode, printJson, runWithSuppressedOutput } from "../helpers.climode.js"; import { appendPendingChangelogEntry } from "../helpers.changelog.js"; import { resolveCommitWorkflow, type IResolvedCommitWorkflow } from "../helpers.workflow.js"; export const run = async (argvArg: any) => { const mode = await getCliMode(argvArg); const subcommand = argvArg._?.[1]; if (mode.help || subcommand === "help") { showHelp(mode); return; } if (subcommand === "recommend") { await handleRecommend(mode); return; } if (mode.json) { printJson({ ok: false, error: "JSON output is only supported for the read-only recommendation flow. Use `gitzone commit recommend --json`.", }); return; } const workflow = await resolveCommitWorkflow(argvArg); if (workflow.releaseFlagRequested) { logger.log( "warn", "`gitzone commit -r` is deprecated and no longer releases. Use `gitzone release` after committing.", ); } printCommitExecutionPlan(workflow); if (workflow.confirmation === "plan") { return; } const smartshellInstance = new plugins.smartshell.Smartshell({ executor: "bash", sourceFilePaths: [], }); let nextCommitObject: any; let answerBucket: plugins.smartinteract.AnswerBucket | undefined; for (const step of workflow.steps) { switch (step) { case "format": await runFormatStep(); break; case "test": await runCommandStep(smartshellInstance, "Running tests", workflow.testCommand); break; case "build": await runCommandStep(smartshellInstance, "Running build", workflow.buildCommand); break; case "analyze": nextCommitObject = await runAnalyzeStep(); answerBucket = await buildAnswerBucket(nextCommitObject, workflow, mode, argvArg); break; case "changelog": assertAnalysisComplete(answerBucket, nextCommitObject); await runChangelogStep(workflow, answerBucket!, nextCommitObject); break; case "commit": assertAnalysisComplete(answerBucket, nextCommitObject); await runCommitStep(smartshellInstance, answerBucket!); break; case "push": await runPushStep(smartshellInstance, workflow); break; } } const commitShaResult = await smartshellInstance.exec("git rev-parse --short HEAD"); const currentBranch = await detectCurrentBranch(smartshellInstance); ui.printSummary({ projectType: "source", branch: currentBranch, commitType: answerBucket!.getAnswerFor("commitType"), commitScope: answerBucket!.getAnswerFor("commitScope"), commitMessage: answerBucket!.getAnswerFor("commitDescription"), commitSha: commitShaResult.stdout.trim(), pushed: workflow.steps.includes("push"), }); }; async function runFormatStep(): Promise { ui.printHeader("Formatting project files"); const formatMod = await import("../mod_format/index.js"); await formatMod.run({ write: true, yes: true, interactive: false }); } async function runCommandStep( smartshellInstance: plugins.smartshell.Smartshell, label: string, command: string, ): Promise { ui.printHeader(label); const result = await smartshellInstance.exec(command); if (result.exitCode !== 0) { logger.log("error", `${label} failed. Aborting commit.`); process.exit(1); } logger.log("success", `${label} passed.`); } async function runAnalyzeStep(): Promise { ui.printHeader("Analyzing repository changes"); const aidoc = new plugins.tsdoc.AiDoc(); await aidoc.start(); try { const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd); ui.printRecommendation({ recommendedNextVersion: nextCommitObject.recommendedNextVersion, recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel, recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope, recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage, }); return nextCommitObject; } finally { await aidoc.stop(); } } async function buildAnswerBucket( nextCommitObject: any, workflow: IResolvedCommitWorkflow, mode: ICliMode, argvArg: any, ): Promise { const isBreakingChange = nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE"; const canAutoAccept = workflow.confirmation === "auto" && !isBreakingChange; if (canAutoAccept) { logger.log("info", "Auto-accepting AI recommendations"); return createAnswerBucket({ commitType: nextCommitObject.recommendedNextVersionLevel, commitScope: nextCommitObject.recommendedNextVersionScope, commitDescription: nextCommitObject.recommendedNextVersionMessage, }); } if (isBreakingChange && (workflow.confirmation === "auto" || argvArg.y || argvArg.yes)) { logger.log("warn", "BREAKING CHANGE detected - manual confirmation required"); } if (!mode.interactive) { throw new Error("Commit confirmation requires an interactive terminal. Use `-y` or set commit.confirmation to `auto`."); } const commitInteract = new plugins.smartinteract.SmartInteract(); commitInteract.addQuestions([ { type: "list", name: "commitType", message: "Choose TYPE of the commit:", choices: ["fix", "feat", "BREAKING CHANGE"], default: nextCommitObject.recommendedNextVersionLevel, }, { type: "input", name: "commitScope", message: "What is the SCOPE of the commit:", default: nextCommitObject.recommendedNextVersionScope, }, { type: "input", name: "commitDescription", message: "What is the DESCRIPTION of the commit?", default: nextCommitObject.recommendedNextVersionMessage, }, ]); return await commitInteract.runQueue(); } function createAnswerBucket(answers: { commitType: string; commitScope: string; commitDescription: string; }): plugins.smartinteract.AnswerBucket { const answerBucket = new plugins.smartinteract.AnswerBucket(); for (const [name, value] of Object.entries(answers)) { answerBucket.addAnswer({ name, value }); } return answerBucket; } async function runChangelogStep( workflow: IResolvedCommitWorkflow, answerBucket: plugins.smartinteract.AnswerBucket, nextCommitObject: any, ): Promise { await appendPendingChangelogEntry( plugins.path.join(paths.cwd, workflow.changelogFile), workflow.changelogSection, { type: answerBucket.getAnswerFor("commitType"), scope: answerBucket.getAnswerFor("commitScope"), message: answerBucket.getAnswerFor("commitDescription"), details: nextCommitObject.recommendedNextVersionDetails || [], }, ); logger.log("success", `Updated ${workflow.changelogFile} pending section.`); } async function runCommitStep( smartshellInstance: plugins.smartshell.Smartshell, answerBucket: plugins.smartinteract.AnswerBucket, ): Promise { ui.printHeader("Creating Semantic Commit"); const commitString = createCommitStringFromAnswerBucket(answerBucket); ui.printCommitMessage(commitString); await smartshellInstance.exec("git add -A"); const result = await smartshellInstance.exec(`git commit -m ${shellQuote(commitString)}`); if (result.exitCode !== 0) { logger.log("error", "git commit failed."); process.exit(1); } } async function runPushStep( smartshellInstance: plugins.smartshell.Smartshell, workflow: IResolvedCommitWorkflow, ): Promise { const currentBranch = await detectCurrentBranch(smartshellInstance); const followTags = workflow.pushFollowTags ? " --follow-tags" : ""; const result = await smartshellInstance.exec( `git push ${workflow.pushRemote} ${currentBranch}${followTags}`, ); if (result.exitCode !== 0) { logger.log("error", "git push failed."); process.exit(1); } } async function detectCurrentBranch( smartshellInstance: plugins.smartshell.Smartshell, ): Promise { const branchResult = await smartshellInstance.exec("git branch --show-current"); return branchResult.stdout.trim() || "master"; } function assertAnalysisComplete( answerBucket: plugins.smartinteract.AnswerBucket | undefined, nextCommitObject: any, ): void { if (!answerBucket || !nextCommitObject) { throw new Error("Commit workflow requires analyze before changelog and commit steps."); } } function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } function printCommitExecutionPlan(workflow: IResolvedCommitWorkflow): void { console.log(""); console.log("gitzone commit - resolved workflow"); console.log(`confirmation: ${workflow.confirmation}`); console.log(`steps: ${workflow.steps.join(" -> ")}`); console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogSection}`); console.log(""); } async function handleRecommend(mode: ICliMode): Promise { const recommendationBuilder = async () => { const aidoc = new plugins.tsdoc.AiDoc(); await aidoc.start(); try { return await aidoc.buildNextCommitObject(paths.cwd); } finally { await aidoc.stop(); } }; const recommendation = mode.json ? await runWithSuppressedOutput(recommendationBuilder) : await recommendationBuilder(); if (mode.json) { printJson(recommendation); return; } ui.printRecommendation({ recommendedNextVersion: recommendation.recommendedNextVersion, recommendedNextVersionLevel: recommendation.recommendedNextVersionLevel, recommendedNextVersionScope: recommendation.recommendedNextVersionScope, recommendedNextVersionMessage: recommendation.recommendedNextVersionMessage, }); console.log( `Suggested commit: ${recommendation.recommendedNextVersionLevel}(${recommendation.recommendedNextVersionScope}): ${recommendation.recommendedNextVersionMessage}`, ); } const createCommitStringFromAnswerBucket = ( answerBucket: plugins.smartinteract.AnswerBucket, ) => { const commitType = answerBucket.getAnswerFor("commitType"); const commitScope = answerBucket.getAnswerFor("commitScope"); const commitDescription = answerBucket.getAnswerFor("commitDescription"); return `${commitType}(${commitScope}): ${commitDescription}`; }; export function showHelp(mode?: ICliMode): void { if (mode?.json) { printJson({ command: "commit", usage: "gitzone commit [recommend] [options]", description: "Analyzes changes and creates one semantic source commit.", commands: [ { name: "recommend", description: "Generate a commit recommendation without mutating the repository", }, ], flags: [ { flag: "-y, --yes", description: "Auto-accept safe AI recommendations" }, { flag: "-p, --push", description: "Push to origin after committing" }, { flag: "-t, --test", description: "Run tests as part of the commit workflow" }, { flag: "-b, --build", description: "Run build as part of the commit workflow" }, { flag: "-f, --format", description: "Run gitzone format before committing" }, { flag: "--plan", description: "Show resolved workflow without mutating files" }, { flag: "--json", description: "Emit JSON for `commit recommend` only" }, ], examples: [ "gitzone commit recommend --json", "gitzone commit -y", "gitzone commit -ytbp", "gitzone release", ], }); return; } console.log(""); console.log("Usage: gitzone commit [recommend] [options]"); console.log(""); console.log("Creates one semantic source commit. It does not version, tag, or publish."); console.log(""); console.log("Commands:"); console.log(" recommend Generate a commit recommendation without mutating the repository"); console.log(""); console.log("Flags:"); console.log(" -y, --yes Auto-accept safe AI recommendations"); console.log(" -p, --push Push after commit"); console.log(" -t, --test Run tests in the configured order"); console.log(" -b, --build Run build in the configured order"); console.log(" -f, --format Run gitzone format before committing"); console.log(" --plan Show resolved workflow without mutating files"); console.log(" --json Emit JSON for `commit recommend` only"); console.log(""); console.log("Examples:"); console.log(" gitzone commit recommend --json"); console.log(" gitzone commit -y"); console.log(" gitzone commit -ytbp"); console.log(" gitzone release"); console.log(""); }