// 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 helpers from "./mod.helpers.js"; import * as ui from "./mod.ui.js"; import { ReleaseConfig } from "../mod_config/classes.releaseconfig.js"; import type { ICliMode } from "../helpers.climode.js"; import { getCliMode, printJson, runWithSuppressedOutput, } from "../helpers.climode.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; } // Read commit config from .smartconfig.json const smartconfigInstance = new plugins.smartconfig.Smartconfig(); const gitzoneConfig = smartconfigInstance.dataFor<{ commit?: { alwaysTest?: boolean; alwaysBuild?: boolean; }; }>("@git.zone/cli", {}); const commitConfig = gitzoneConfig.commit || {}; // Check flags and merge with config options const wantsRelease = !!(argvArg.r || argvArg.release); const wantsTest = !!(argvArg.t || argvArg.test || commitConfig.alwaysTest); const wantsBuild = !!(argvArg.b || argvArg.build || commitConfig.alwaysBuild); let releaseConfig: ReleaseConfig | null = null; if (wantsRelease) { releaseConfig = await ReleaseConfig.fromCwd(); if (!releaseConfig.hasRegistries()) { logger.log("error", "No release registries configured."); console.log(""); console.log( " Run `gitzone config add ` to add registries.", ); console.log(""); process.exit(1); } } // Print execution plan at the start ui.printExecutionPlan({ autoAccept: !!(argvArg.y || argvArg.yes), push: !!(argvArg.p || argvArg.push), test: wantsTest, build: wantsBuild, release: wantsRelease, format: !!argvArg.format, registries: releaseConfig?.getRegistries(), }); if (argvArg.format) { const formatMod = await import("../mod_format/index.js"); await formatMod.run(); } // Run tests early to fail fast before analysis if (wantsTest) { ui.printHeader("๐Ÿงช Running tests..."); const smartshellForTest = new plugins.smartshell.Smartshell({ executor: "bash", sourceFilePaths: [], }); const testResult = await smartshellForTest.exec("pnpm test"); if (testResult.exitCode !== 0) { logger.log("error", "Tests failed. Aborting commit."); process.exit(1); } logger.log("success", "All tests passed."); } ui.printHeader("๐Ÿ” Analyzing repository changes..."); const aidoc = new plugins.tsdoc.AiDoc(); await aidoc.start(); const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd); await aidoc.stop(); ui.printRecommendation({ recommendedNextVersion: nextCommitObject.recommendedNextVersion, recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel, recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope, recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage, }); let answerBucket: plugins.smartinteract.AnswerBucket; // Check if -y/--yes flag is set AND version is not a breaking change // Breaking changes (major version bumps) always require manual confirmation const isBreakingChange = nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE"; const canAutoAccept = (argvArg.y || argvArg.yes) && !isBreakingChange; if (canAutoAccept) { // Auto-mode: create AnswerBucket programmatically logger.log("info", "โœ“ Auto-accepting AI recommendations (--yes flag)"); answerBucket = new plugins.smartinteract.AnswerBucket(); answerBucket.addAnswer({ name: "commitType", value: nextCommitObject.recommendedNextVersionLevel, }); answerBucket.addAnswer({ name: "commitScope", value: nextCommitObject.recommendedNextVersionScope, }); answerBucket.addAnswer({ name: "commitDescription", value: nextCommitObject.recommendedNextVersionMessage, }); answerBucket.addAnswer({ name: "pushToOrigin", value: !!(argvArg.p || argvArg.push), // Only push if -p flag also provided }); answerBucket.addAnswer({ name: "createRelease", value: wantsRelease, }); } else { // Warn if --yes was provided but we're requiring confirmation due to breaking change if (isBreakingChange && (argvArg.y || argvArg.yes)) { logger.log( "warn", "โš ๏ธ BREAKING CHANGE detected - manual confirmation required", ); } // Interactive mode: prompt user for input 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, }, { type: "confirm", name: `pushToOrigin`, message: `Do you want to push this version now?`, default: true, }, { type: "confirm", name: `createRelease`, message: `Do you want to publish to npm registries?`, default: wantsRelease, }, ]); answerBucket = await commitInteract.runQueue(); } const commitString = createCommitStringFromAnswerBucket(answerBucket); const commitType = answerBucket.getAnswerFor("commitType"); let commitVersionType: helpers.VersionType; switch (commitType) { case "fix": commitVersionType = "patch"; break; case "feat": commitVersionType = "minor"; break; case "BREAKING CHANGE": commitVersionType = "major"; break; default: throw new Error(`Unsupported commit type: ${commitType}`); } ui.printHeader("โœจ Creating Semantic Commit"); ui.printCommitMessage(commitString); const smartshellInstance = new plugins.smartshell.Smartshell({ executor: "bash", sourceFilePaths: [], }); // Load release config if user wants to release (interactively selected) if (answerBucket.getAnswerFor("createRelease") && !releaseConfig) { releaseConfig = await ReleaseConfig.fromCwd(); if (!releaseConfig.hasRegistries()) { logger.log("error", "No release registries configured."); console.log(""); console.log( " Run `gitzone config add ` to add registries.", ); console.log(""); process.exit(1); } } // Determine total steps based on options // Note: test runs early (like format) so not counted in numbered steps const willPush = answerBucket.getAnswerFor("pushToOrigin") && !(process.env.CI === "true"); const willRelease = answerBucket.getAnswerFor("createRelease") && releaseConfig?.hasRegistries(); let totalSteps = 5; // Base steps: commitinfo, changelog, staging, commit, version if (wantsBuild) totalSteps += 2; // build step + verification step if (willPush) totalSteps++; if (willRelease) totalSteps++; let currentStep = 0; // Step 1: Baking commitinfo currentStep++; ui.printStep( currentStep, totalSteps, "๐Ÿ”ง Baking commit info into code", "in-progress", ); const commitInfo = new plugins.commitinfo.CommitInfo( paths.cwd, commitVersionType, ); await commitInfo.writeIntoPotentialDirs(); ui.printStep( currentStep, totalSteps, "๐Ÿ”ง Baking commit info into code", "done", ); // Step 2: Writing changelog currentStep++; ui.printStep( currentStep, totalSteps, "๐Ÿ“„ Generating changelog.md", "in-progress", ); let changelog = nextCommitObject.changelog || "# Changelog\n"; changelog = changelog.replaceAll( "{{nextVersion}}", (await commitInfo.getNextPlannedVersion()).versionString, ); changelog = changelog.replaceAll( "{{nextVersionScope}}", `${await answerBucket.getAnswerFor("commitType")}(${await answerBucket.getAnswerFor("commitScope")})`, ); changelog = changelog.replaceAll( "{{nextVersionMessage}}", nextCommitObject.recommendedNextVersionMessage, ); if (nextCommitObject.recommendedNextVersionDetails?.length > 0) { changelog = changelog.replaceAll( "{{nextVersionDetails}}", "- " + nextCommitObject.recommendedNextVersionDetails.join("\n- "), ); } else { changelog = changelog.replaceAll("\n{{nextVersionDetails}}", ""); } await plugins.smartfs .file(plugins.path.join(paths.cwd, `changelog.md`)) .encoding("utf8") .write(changelog); ui.printStep(currentStep, totalSteps, "๐Ÿ“„ Generating changelog.md", "done"); // Step 3: Staging files currentStep++; ui.printStep(currentStep, totalSteps, "๐Ÿ“ฆ Staging files", "in-progress"); await smartshellInstance.exec(`git add -A`); ui.printStep(currentStep, totalSteps, "๐Ÿ“ฆ Staging files", "done"); // Step 4: Creating commit currentStep++; ui.printStep( currentStep, totalSteps, "๐Ÿ’พ Creating git commit", "in-progress", ); await smartshellInstance.exec(`git commit -m "${commitString}"`); ui.printStep(currentStep, totalSteps, "๐Ÿ’พ Creating git commit", "done"); // Step 5: Bumping version currentStep++; const projectType = await helpers.detectProjectType(); const newVersion = await helpers.bumpProjectVersion( projectType, commitVersionType, currentStep, totalSteps, ); // Step 6: Run build (optional) if (wantsBuild) { currentStep++; ui.printStep(currentStep, totalSteps, "๐Ÿ”จ Running build", "in-progress"); const buildResult = await smartshellInstance.exec("pnpm build"); if (buildResult.exitCode !== 0) { ui.printStep(currentStep, totalSteps, "๐Ÿ”จ Running build", "error"); logger.log("error", "Build failed. Aborting release."); process.exit(1); } ui.printStep(currentStep, totalSteps, "๐Ÿ”จ Running build", "done"); // Step 7: Verify no uncommitted changes currentStep++; ui.printStep( currentStep, totalSteps, "๐Ÿ” Verifying clean working tree", "in-progress", ); const statusResult = await smartshellInstance.exec( "git status --porcelain", ); if (statusResult.stdout.trim() !== "") { ui.printStep( currentStep, totalSteps, "๐Ÿ” Verifying clean working tree", "error", ); logger.log( "error", "Build produced uncommitted changes. This usually means build output is not gitignored.", ); logger.log("error", "Uncommitted files:"); console.log(statusResult.stdout); logger.log( "error", "Aborting release. Please ensure build artifacts are in .gitignore", ); process.exit(1); } ui.printStep( currentStep, totalSteps, "๐Ÿ” Verifying clean working tree", "done", ); } // Step: Push to remote (optional) const currentBranch = await helpers.detectCurrentBranch(); if (willPush) { currentStep++; ui.printStep( currentStep, totalSteps, `๐Ÿš€ Pushing to origin/${currentBranch}`, "in-progress", ); await smartshellInstance.exec( `git push origin ${currentBranch} --follow-tags`, ); ui.printStep( currentStep, totalSteps, `๐Ÿš€ Pushing to origin/${currentBranch}`, "done", ); } // Step 7: Publish to npm registries (optional) let releasedRegistries: string[] = []; if (willRelease && releaseConfig) { currentStep++; const registries = releaseConfig.getRegistries(); ui.printStep( currentStep, totalSteps, `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`, "in-progress", ); const accessLevel = releaseConfig.getAccessLevel(); for (const registry of registries) { try { await smartshellInstance.exec( `npm publish --registry=${registry} --access=${accessLevel}`, ); releasedRegistries.push(registry); } catch (error) { logger.log("error", `Failed to publish to ${registry}: ${error}`); } } if (releasedRegistries.length === registries.length) { ui.printStep( currentStep, totalSteps, `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`, "done", ); } else { ui.printStep( currentStep, totalSteps, `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`, "error", ); } } console.log(""); // Add spacing before summary // Get commit SHA for summary const commitShaResult = await smartshellInstance.exec( "git rev-parse --short HEAD", ); const commitSha = commitShaResult.stdout.trim(); // Print final summary ui.printSummary({ projectType, branch: currentBranch, commitType: answerBucket.getAnswerFor("commitType"), commitScope: answerBucket.getAnswerFor("commitScope"), commitMessage: answerBucket.getAnswerFor("commitDescription"), newVersion: newVersion, commitSha: commitSha, pushed: willPush, released: releasedRegistries.length > 0, releasedRegistries: releasedRegistries.length > 0 ? releasedRegistries : undefined, }); }; 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: "Creates semantic commits or emits a read-only recommendation.", commands: [ { name: "recommend", description: "Generate a commit recommendation without mutating the repository", }, ], flags: [ { flag: "-y, --yes", description: "Auto-accept AI recommendations" }, { flag: "-p, --push", description: "Push to origin after commit" }, { flag: "-t, --test", description: "Run tests before the commit flow" }, { flag: "-b, --build", description: "Run the build after the commit flow", }, { flag: "-r, --release", description: "Publish to configured registries after push", }, { flag: "--format", description: "Run gitzone format before committing", }, { flag: "--json", description: "Emit JSON for `commit recommend` only", }, ], examples: [ "gitzone commit recommend --json", "gitzone commit -y", "gitzone commit -ypbr", ], }); return; } console.log(""); console.log("Usage: gitzone commit [recommend] [options]"); 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 AI recommendations"); console.log(" -p, --push Push to origin after commit"); console.log(" -t, --test Run tests before the commit flow"); console.log(" -b, --build Run the build after the commit flow"); console.log( " -r, --release Publish to configured registries after push", ); console.log(" --format Run gitzone format before committing"); 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 -ypbr"); console.log(""); }