574 lines
18 KiB
TypeScript
574 lines
18 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 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 <registry-url>` 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 <registry-url>` 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<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:
|
|
"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("");
|
|
}
|