From 0d0bd631869957aa784340af4e81936fc6fc2e32 Mon Sep 17 00:00:00 2001 From: Hatter Jiang Date: Sun, 25 Jan 2026 13:00:09 +0800 Subject: [PATCH] add commit.ts --- single-scripts/commit.ts | 316 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100755 single-scripts/commit.ts diff --git a/single-scripts/commit.ts b/single-scripts/commit.ts new file mode 100755 index 0000000..b54f2f9 --- /dev/null +++ b/single-scripts/commit.ts @@ -0,0 +1,316 @@ +#!/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, + 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"; + } + 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.stdout.write(`\r${this.message} ${time} ${dots}\x1b[K`); + }, 200); + } + stop(clearLine?: boolean): void { + if (this.interval) { + clearInterval(this.interval); + process.stdout.write(clearLine ? "\r" : "\n"); + } + } +} + +async function checkRev(): Promise { + const processBar = new ProcessBar("Checking rev"); + processBar.start(); + const localRev = await getGitLocalRev(); + const remoteRev = await getGitRemoteRev(); + processBar.stop(); + if (localRev === remoteRev) { + console.log(`Check rev successfully, rev: ${localRev}`); + } else { + throw `Check rev failed, local rev: ${localRev} vs remote rev: ${remoteRev}`; + } +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +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); + + console.log(gitCommitArgs); + await execCommandShell("git", gitCommitArgs); +} + +main().catch((err) => { + console.error(`[ERROR] ${err}`); + process.exit(0); +}).then(() => process.exit(0)); + +// @SCRIPT-SIGNATURE-V1: yk-r1.ES256.20260125T125926+08:00.MEYCIQDyxV26TTjKJCjDH1Pb +// s36IR6JZ4biD/G27wdrxtnQQYQIhAMVSIGkLWOd8dASIayCKeZ0UlfYJN3rercqJKqzRf0Cm