mirror of
https://github.com/amatya-aditya/advanced-multi-column.git
synced 2026-07-22 14:30:28 +00:00
226 lines
6.4 KiB
JavaScript
226 lines
6.4 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
import { createInterface } from "node:readline/promises";
|
|
import { stdin as input, stdout as output } from "node:process";
|
|
|
|
const TYPES = [
|
|
"feat",
|
|
"fix",
|
|
"docs",
|
|
"chore",
|
|
"refactor",
|
|
"test",
|
|
"perf",
|
|
"style",
|
|
"ci",
|
|
"build",
|
|
"revert",
|
|
];
|
|
|
|
const proc = globalThis.process;
|
|
|
|
function run(command, args, options = {}) {
|
|
return spawnSync(command, args, {
|
|
stdio: "pipe",
|
|
encoding: "utf8",
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function runInherit(command, args) {
|
|
return spawnSync(command, args, {
|
|
stdio: "inherit",
|
|
encoding: "utf8",
|
|
});
|
|
}
|
|
|
|
function isInsideGitRepo() {
|
|
const result = run("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
return result.status === 0 && result.stdout.trim() === "true";
|
|
}
|
|
|
|
function getStagedFiles() {
|
|
const result = run("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMRTUXB"]);
|
|
if (result.status !== 0) return [];
|
|
return result.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => line.replace(/\\/g, "/"));
|
|
}
|
|
|
|
function getTopLevelDirs(files) {
|
|
const dirs = new Set();
|
|
for (const file of files) {
|
|
const parts = file.split("/");
|
|
const top = parts[0];
|
|
if (top && top !== ".") dirs.add(top);
|
|
}
|
|
return [...dirs];
|
|
}
|
|
|
|
function inferType(files) {
|
|
if (files.length === 0) return "chore";
|
|
const docsOnly = files.every(
|
|
(file) =>
|
|
file.startsWith("docs/") ||
|
|
file.startsWith("wiki/") ||
|
|
file.toLowerCase() === "readme.md",
|
|
);
|
|
if (docsOnly) return "docs";
|
|
|
|
const testsOnly = files.every(
|
|
(file) =>
|
|
file.startsWith("test/") ||
|
|
file.includes(".test.") ||
|
|
file.includes(".spec."),
|
|
);
|
|
if (testsOnly) return "test";
|
|
|
|
return "feat";
|
|
}
|
|
|
|
function inferScope(files) {
|
|
if (files.length === 0) return "";
|
|
const dirs = getTopLevelDirs(files);
|
|
if (dirs.length === 1) return sanitizeScope(dirs[0]);
|
|
return "";
|
|
}
|
|
|
|
function sanitizeScope(rawScope) {
|
|
return rawScope
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, "-")
|
|
.replace(/[^a-z0-9._/-]/g, "");
|
|
}
|
|
|
|
function inferSubject(files) {
|
|
if (files.length === 0) return "update project files";
|
|
if (files.length === 1) return `update ${files[0]}`;
|
|
|
|
const dirs = getTopLevelDirs(files);
|
|
if (dirs.length === 1) return `update ${dirs[0]} files`;
|
|
return `update ${files.length} files`;
|
|
}
|
|
|
|
async function ask(rl, prompt, defaultValue = "") {
|
|
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
const answer = (await rl.question(`${prompt}${suffix}: `)).trim();
|
|
return answer || defaultValue;
|
|
}
|
|
|
|
async function askYesNo(rl, prompt, defaultYes = true) {
|
|
const defaultText = defaultYes ? "Y/n" : "y/N";
|
|
const answer = (await rl.question(`${prompt} [${defaultText}]: `)).trim().toLowerCase();
|
|
if (!answer) return defaultYes;
|
|
return answer === "y" || answer === "yes";
|
|
}
|
|
|
|
async function askType(rl, suggestedType) {
|
|
output.write("\nSelect commit type:\n");
|
|
for (let i = 0; i < TYPES.length; i++) {
|
|
const type = TYPES[i];
|
|
const marker = type === suggestedType ? " (suggested)" : "";
|
|
output.write(` ${i + 1}. ${type}${marker}\n`);
|
|
}
|
|
|
|
while (true) {
|
|
const defaultIndex = String(Math.max(1, TYPES.indexOf(suggestedType) + 1));
|
|
const answer = (await ask(rl, "Type number or name", defaultIndex)).toLowerCase();
|
|
const byNumber = Number.parseInt(answer, 10);
|
|
if (Number.isFinite(byNumber) && byNumber >= 1 && byNumber <= TYPES.length) {
|
|
return TYPES[byNumber - 1];
|
|
}
|
|
if (TYPES.includes(answer)) return answer;
|
|
output.write("Invalid type. Choose a valid number or type name.\n");
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
if (!isInsideGitRepo()) {
|
|
output.write("[commit] Not inside a git repository.\n");
|
|
proc.exit(1);
|
|
}
|
|
|
|
let stagedFiles = getStagedFiles();
|
|
const rl = createInterface({ input, output });
|
|
|
|
try {
|
|
if (stagedFiles.length === 0) {
|
|
output.write("[commit] No staged files found.\n");
|
|
const stageAll = await askYesNo(rl, "Stage all changes with `git add -A`?", true);
|
|
if (!stageAll) {
|
|
output.write("[commit] Aborted. Stage files first with `git add ...`.\n");
|
|
proc.exit(1);
|
|
}
|
|
const addResult = runInherit("git", ["add", "-A"]);
|
|
if (addResult.status !== 0) {
|
|
output.write("[commit] Failed to stage changes.\n");
|
|
proc.exit(addResult.status ?? 1);
|
|
}
|
|
stagedFiles = getStagedFiles();
|
|
if (stagedFiles.length === 0) {
|
|
output.write("[commit] Nothing to commit.\n");
|
|
proc.exit(1);
|
|
}
|
|
}
|
|
|
|
output.write(`\n[commit] Staged files: ${stagedFiles.length}\n`);
|
|
for (const file of stagedFiles.slice(0, 8)) {
|
|
output.write(` - ${file}\n`);
|
|
}
|
|
if (stagedFiles.length > 8) {
|
|
output.write(` ...and ${stagedFiles.length - 8} more\n`);
|
|
}
|
|
|
|
const suggestedType = inferType(stagedFiles);
|
|
const suggestedScope = inferScope(stagedFiles);
|
|
const suggestedSubject = inferSubject(stagedFiles);
|
|
|
|
const type = await askType(rl, suggestedType);
|
|
const scopeInput = await ask(rl, "Scope (optional)", suggestedScope);
|
|
const scope = scopeInput ? sanitizeScope(scopeInput) : "";
|
|
|
|
let subject = await ask(rl, "Subject (leave empty for auto)", "");
|
|
if (!subject) subject = suggestedSubject;
|
|
subject = subject.replace(/\.$/, "").trim();
|
|
if (!subject) subject = "update files";
|
|
|
|
const breaking = await askYesNo(rl, "Breaking change?", false);
|
|
const body = await ask(rl, "Body (optional, one short line)", "");
|
|
|
|
let breakingNote = "";
|
|
if (breaking) {
|
|
const defaultNote = "BREAKING CHANGE: behavior changed";
|
|
breakingNote = await ask(rl, "Breaking note", defaultNote);
|
|
if (!breakingNote.toUpperCase().startsWith("BREAKING CHANGE:")) {
|
|
breakingNote = `BREAKING CHANGE: ${breakingNote}`;
|
|
}
|
|
}
|
|
|
|
const header = `${type}${scope ? `(${scope})` : ""}${breaking ? "!" : ""}: ${subject}`;
|
|
|
|
output.write("\nCommit preview:\n");
|
|
output.write(` ${header}\n`);
|
|
if (body) output.write(`\n ${body}\n`);
|
|
if (breakingNote) output.write(`\n ${breakingNote}\n`);
|
|
|
|
const confirm = await askYesNo(rl, "\nCreate commit now?", true);
|
|
if (!confirm) {
|
|
output.write("[commit] Aborted.\n");
|
|
proc.exit(1);
|
|
}
|
|
|
|
const args = ["commit", "-m", header];
|
|
if (body) args.push("-m", body);
|
|
if (breakingNote) args.push("-m", breakingNote);
|
|
|
|
const commitResult = runInherit("git", args);
|
|
proc.exit(commitResult.status ?? 0);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
void main();
|
|
|