372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
// 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<void> {
|
|
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<void> {
|
|
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<any> {
|
|
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<plugins.smartinteract.AnswerBucket> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<void> {
|
|
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("");
|
|
}
|