#!/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 { 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 { 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 { 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 { 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 { const gitCurrentBranch = await execCommandRequiresSuccess("git", [ "branch", "--show-current", ]); return gitCurrentBranch.stdout.trim(); } async function getGitLocalRev(): Promise { 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 ..." 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 ..." to update what will be committed) // (use "git restore ..." to discard changes in working directory) // modified: README.md // deleted: script-config.json // // Untracked files: // (use "git add ..." 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 { 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 inChangesToBeCommited = 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(cb: () => Promise, clearLine?: boolean): Promise { 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; let time = `${costMs}ms`; if (costMs > 1000) { time = parseInt(costMs / 1000) + "s"; } process.stderr.write(`\r${this.message} ${time} ${dots}\x1b[K`); }, 200); } stop(clearLine?: boolean): void { if (this.interval) { clearInterval(this.interval); process.stderr.write(clearLine ? "\r" : "\n"); } } } async function checkRev(): Promise { const localRev = await getGitLocalRev(); const remoteRev = await new ProcessBar("Checking rev").call( async (): Promise => { 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 => { await execCommandShell("git", ["push"]); }); } main().catch((err) => { console.error(`[ERROR] ${err}`); process.exit(0); }).then(() => process.exit(0)); // @SCRIPT-SIGNATURE-V1: yk-r1.ES256.20260125T135031+08:00.MEUCIBEze5RIPyQ19vGFgOur // 3UX23ghubli25a0lskk1ezgKAiEAzNzsLCWJ9DNRUbIrA9nbmO15V5qlQY46AeCal6ywS/w=