Files
ts-scripts/single-scripts/commit.ts
2026-01-25 18:09:16 +08:00

330 lines
9.9 KiB
TypeScript
Executable File

#!/usr/bin/env runts -- --runtime-bun
import {spawn} from "node:child_process";
import {existsSync} from "node:fs";
import {createInterface} from "node:readline/promises";
import {stdin, stdout} from "node:process";
class ProcessOutput {
code: number;
stdout: string;
stderr: string;
constructor(code: number, stdout: string, stderr: string) {
this.code = code;
this.stdout = stdout;
this.stderr = stderr;
}
}
async function execCommandRequiresSuccess(
command: string,
args: string[],
timeout?: number,
): Promise<ProcessOutput> {
const result = await execCommand(command, args, timeout);
if (result.code !== 0) {
throw new Error(
`Exec command ${command} ${
args.join(" ")
} failed: ${result.code}\n- stdout: ${result.stdout}\n - stderr: ${result.stderr}`,
);
}
return result;
}
async function execCommand(
command: string,
args: string[],
timeout?: number,
): Promise<ProcessOutput> {
const ps = spawn(command, args, { env: { "LANG": "en_US" } });
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(`Exec command ${command} ${args.join(" ")} timeout`);
}, timeout || 10000);
let stdout = "";
let stderr = "";
ps.stdout.on("data", (data) => {
stdout += data.toString();
});
ps.stderr.on("data", (data) => {
stderr += data.toString();
});
ps.on("close", (code) => {
try {
const output = new ProcessOutput(code, stdout, stderr);
ps.stdin.end();
resolve(output);
} catch (e) {
reject(e);
}
});
ps.on("error", (err) => {
reject(err);
});
});
}
async function execCommandShell(
command: string,
args: string[],
): Promise<void> {
const ps = spawn(command, args, {
// shell: true, // when turn `shell` true will cause shell command line injection
stdio: ["inherit", "inherit", "inherit"],
});
return new Promise((resolve, reject) => {
ps.on("close", (code) => {
try {
ps.stdin.end();
resolve(void 0);
} catch (e) {
return reject(e);
}
});
ps.on("error", (err) => {
reject(err);
});
});
}
async function getGitRemoteRev(): Promise<string> {
const currentBranch = await getGitCurrentBranch();
const lsGitRemoteOrigin = await execCommandRequiresSuccess(
"git",
["ls-remote", "origin", currentBranch],
);
return lsGitRemoteOrigin.stdout.trim().split(/\s+/)[0].trim();
}
async function getGitCurrentBranch(): Promise<string> {
const gitCurrentBranch = await execCommandRequiresSuccess("git", [
"branch",
"--show-current",
]);
return gitCurrentBranch.stdout.trim();
}
async function getGitLocalRev(): Promise<string> {
const gitLocalRev = await execCommandRequiresSuccess("git", [
"rev-parse",
"HEAD",
]);
return gitLocalRev.stdout.trim();
}
class GitStatusResult {
newfile: string[];
modified: string[];
deleted: string[];
untracked: string[];
constructor(
newfile: string[],
modified: string[],
deleted: string[],
untracked: string[],
) {
this.newfile = newfile;
this.modified = modified;
this.deleted = deleted;
this.untracked = untracked;
}
}
// On branch main
// Your branch is up to date with 'origin/main'.
//
// nothing to commit, working tree clean
// ----------------------------------------------------------------------
// On branch main
// Your branch is up to date with 'origin/main'.
//
// Changes to be committed:
// (use "git restore --staged <file>..." to unstage)
// new file: single-scripts/commit.ts
// ----------------------------------------------------------------------
// On branch main
// Your branch is up to date with 'origin/main'.
//
// Changes not staged for commit:
// (use "git add/rm <file>..." to update what will be committed)
// (use "git restore <file>..." to discard changes in working directory)
// modified: README.md
// deleted: script-config.json
//
// Untracked files:
// (use "git add <file>..." to include in what will be committed)
// single-scripts/commit.ts
//
// no changes added to commit (use "git add" and/or "git commit -a")
async function getGitStatus(): Promise<GitStatusResult | null> {
const newfile: string[] = [];
const modified: string[] = [];
const deleted: string[] = [];
const untracked: string[] = [];
const gitStatus = await execCommandRequiresSuccess("git", ["status"]);
const gitStatusStdout = gitStatus.stdout.trim();
if (gitStatusStdout.includes("nothing to commit, working tree clean")) {
return null;
}
let inChangesNotStaged = false;
let inUntrackedFiles = false;
gitStatusStdout.split("\n").forEach((line) => {
if (
line.startsWith("Changes to be committed:") ||
line.startsWith("Changes not staged for commit:")
) {
inChangesNotStaged = true;
inUntrackedFiles = false;
return;
}
if (line.startsWith("Untracked files:")) {
inChangesNotStaged = false;
inUntrackedFiles = true;
return;
}
if (inChangesNotStaged) {
const modifiedOrDeletedLine = line.trim();
if (modifiedOrDeletedLine.startsWith("new file:")) {
const newFile = modifiedOrDeletedLine.substring(
"new file:".length,
).trim();
newfile.push(newFile);
} else if (modifiedOrDeletedLine.startsWith("modified:")) {
const modifiedFile = modifiedOrDeletedLine.substring(
"modified:".length,
).trim();
modified.push(modifiedFile);
} else if (modifiedOrDeletedLine.startsWith("deleted:")) {
const deletedFile = modifiedOrDeletedLine.substring(
"deleted:".length,
).trim();
deleted.push(deletedFile);
}
}
if (inUntrackedFiles) {
const newFile = line.trim();
if (existsSync(newFile)) {
untracked.push(newFile);
}
}
});
if (
newfile.length === 0 && modified.length === 0 && deleted.length === 0 &&
untracked.length === 0
) {
// WILL THIS HAPPEN?
return null;
}
return new GitStatusResult(newfile, modified, deleted, untracked);
}
class ProcessBar {
interval: number;
message: string;
constructor(message?: string) {
this.message = message || "Processing";
}
async call<T>(cb: () => Promise<T>, clearLine?: boolean): Promise<T> {
this.start();
try {
return await cb();
} finally {
this.stop(clearLine);
}
}
start(): void {
const startMs = new Date().getTime();
let count = 0;
this.interval = setInterval(() => {
const dots = ".".repeat(((count++) % 10) + 1);
const costMs = new Date().getTime() - startMs;
const time = `${Math.floor(costMs / 1000)}s`;
process.stderr.write(`\r${this.message} ${time} ${dots}\x1b[K`);
}, 500);
}
stop(clearLine?: boolean): void {
if (this.interval) {
clearInterval(this.interval);
process.stderr.write(clearLine ? "\r\x1b[K" : "\n");
}
}
}
async function checkRev(): Promise<boolean> {
const localRev = await getGitLocalRev();
const remoteRev = await new ProcessBar("Checking rev").call(
async (): Promise<string> => {
return await getGitRemoteRev();
},
);
if (localRev === remoteRev) {
console.log(`Check rev successfully, rev: ${localRev}`);
} else {
throw `Check rev failed, local rev: ${localRev} vs remote rev: ${remoteRev}`;
}
}
async function main() {
await checkRev(); // check local rev <--> remote rev equals
const gitStatus = await getGitStatus();
if (gitStatus === null) {
console.log("No git status found");
return;
}
console.log("Git status:", gitStatus);
let emptyCount = 0;
let message = "empty message";
const readline = createInterface({ input: stdin, output: stdout });
do {
if (emptyCount++ === 3) {
readline.close();
console.log("Too many empty messages, then exit");
return;
}
message = await readline.question("Input your commit message > ");
} while (message.length == 0);
readline.close();
const gitCommitArgs: string[] = [];
gitCommitArgs.push("commit");
for (const file of gitStatus.modified) {
gitCommitArgs.push(file);
}
for (const file of gitStatus.deleted) {
gitCommitArgs.push(file);
}
for (const file of gitStatus.untracked) {
gitCommitArgs.push(file);
}
gitCommitArgs.push("-m");
gitCommitArgs.push(message);
if (gitStatus.untracked.length > 0) {
const gitAddArgs = ["add"];
for (const file of gitStatus.untracked) {
gitAddArgs.push(file);
}
console.log(">>>>>", ["git", gitAddArgs]);
await execCommandShell("git", gitAddArgs);
}
console.log(">>>>>", ["git", gitCommitArgs]);
await execCommandShell("git", gitCommitArgs);
console.log(">>>>>", ["git", "push"]);
await new ProcessBar("Git pushing").call(async (): Promise<void> => {
await execCommandShell("git", ["push"]);
});
}
main().catch((err) => {
console.error(`[ERROR] ${err}`);
process.exit(0);
}).then(() => process.exit(0));
// @SCRIPT-SIGNATURE-V1: yk-r1.ES256.20260125T180902+08:00.MEUCIQC7L20KJsSMIVy9E8ce
// TebyRlLx2O4rSf3Z4aGSeoXhyAIgB4KQRMs1IJz8wlNJeB3J1uQOso8+o8NzCRsjzDLlr4c=