Files
pikasTech-unidesk/scripts/src/git-tools.ts
T
2026-06-15 22:48:09 +00:00

267 lines
9.1 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { repoRoot } from "./config";
interface GitHubPushFallbackOptions {
repo: string | null;
branch: string | null;
cwd: string;
hostName: string;
port: number;
confirm: boolean;
}
export function runGitToolsCommand(args: string[]): Record<string, unknown> {
const [action] = args;
if (action === undefined || action === "help" || action === "--help" || action === "-h") return gitToolsHelp();
if (action !== "github-push-fallback") throw new Error(`unsupported git command: ${action}`);
const options = parseGitHubPushFallbackOptions(args.slice(1));
const repo = options.repo ?? deriveGitHubRepo(options.cwd);
const branch = options.branch ?? currentGitBranch(options.cwd);
if (repo === null) throw new Error("git github-push-fallback requires --repo owner/name, or an origin remote that points at GitHub");
if (branch === null) throw new Error("git github-push-fallback requires --branch <branch>, or a checked-out branch");
validateGitHubRepo(repo);
validatePushRef(branch);
const remoteUrl = `ssh://git@ssh.github.com:${options.port}/${repo}.git`;
const sshCommand = [
"ssh",
"-o", `HostName=${options.hostName}`,
"-o", `Port=${options.port}`,
"-o", "HostKeyAlias=ssh.github.com",
"-o", "StrictHostKeyChecking=accept-new",
].join(" ");
const argv = ["git", "push", remoteUrl, branch];
const base = {
ok: true,
command: "git github-push-fallback",
cwd: options.cwd,
mutation: options.confirm,
repository: repo,
branch,
remoteUrl,
ssh: {
hostName: options.hostName,
port: options.port,
hostKeyAlias: "ssh.github.com",
strictHostKeyChecking: "accept-new",
},
env: {
GIT_SSH_COMMAND: sshCommand,
valuesPrinted: false,
},
argv,
boundary: "does not edit git remotes; intended for GitHub DNS/port-22 reachability fallback only",
};
if (!options.confirm) {
return {
...base,
executed: false,
dryRun: true,
next: [
"rerun with --confirm to execute this one push without changing origin",
"use --host-name <ip> only when both github.com and ssh.github.com DNS are broken on the target host",
],
};
}
const result = spawnSync("git", ["push", remoteUrl, branch], {
cwd: options.cwd,
env: { ...process.env, GIT_SSH_COMMAND: sshCommand },
encoding: "utf8",
timeout: 120_000,
});
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
const ok = result.status === 0;
return {
...base,
ok,
executed: true,
dryRun: false,
exitCode: result.status,
signal: result.signal,
stdoutTail: tail(stdout, 2000),
stderrTail: tail(stderr, 4000),
diagnostic: classifyGitPushFailure(`${stdout}\n${stderr}`),
};
}
function gitToolsHelp(): Record<string, unknown> {
return {
ok: true,
command: "git",
usage: [
"bun scripts/cli.ts git github-push-fallback --repo pikasTech/HWLAB --branch fix/name",
"bun scripts/cli.ts git github-push-fallback --repo pikasTech/HWLAB --branch fix/name --host-name 140.82.116.36 --confirm",
],
behavior: [
"Plans or executes a one-shot GitHub push through ssh.github.com:443 without editing git remotes.",
"Default output is a dry-run plan. Add --confirm to execute the push.",
"Use --host-name <ip> only when target-host DNS cannot resolve GitHub SSH names; HostKeyAlias remains ssh.github.com.",
],
options: {
"--repo <owner/name>": "GitHub repository. Defaults to parsing origin.",
"--branch <branch>": "Local branch/ref to push. Defaults to the checked-out branch.",
"--cwd <path>": "Git worktree. Defaults to the UniDesk repo root.",
"--host-name <host-or-ip>": "SSH HostName override. Defaults to ssh.github.com.",
"--port <n>": "SSH port. Defaults to 443.",
"--confirm": "Execute the push. Without this flag, only prints a plan.",
},
};
}
function parseGitHubPushFallbackOptions(args: string[]): GitHubPushFallbackOptions {
const options: GitHubPushFallbackOptions = {
repo: null,
branch: null,
cwd: repoRoot,
hostName: "ssh.github.com",
port: 443,
confirm: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
const next = args[index + 1];
if (arg === "--repo") {
options.repo = requireValue(arg, next);
index += 1;
continue;
}
if (arg.startsWith("--repo=")) {
options.repo = requireValue("--repo", arg.slice("--repo=".length));
continue;
}
if (arg === "--branch") {
options.branch = requireValue(arg, next);
index += 1;
continue;
}
if (arg.startsWith("--branch=")) {
options.branch = requireValue("--branch", arg.slice("--branch=".length));
continue;
}
if (arg === "--cwd") {
options.cwd = resolve(requireValue(arg, next));
index += 1;
continue;
}
if (arg.startsWith("--cwd=")) {
options.cwd = resolve(requireValue("--cwd", arg.slice("--cwd=".length)));
continue;
}
if (arg === "--host-name" || arg === "--host") {
options.hostName = requireValue(arg, next);
index += 1;
continue;
}
if (arg.startsWith("--host-name=")) {
options.hostName = requireValue("--host-name", arg.slice("--host-name=".length));
continue;
}
if (arg === "--host-ip") {
options.hostName = requireValue(arg, next);
index += 1;
continue;
}
if (arg === "--port") {
options.port = parsePort(requireValue(arg, next));
index += 1;
continue;
}
if (arg.startsWith("--port=")) {
options.port = parsePort(requireValue("--port", arg.slice("--port=".length)));
continue;
}
if (arg === "--confirm") {
options.confirm = true;
continue;
}
if (arg === "--dry-run") continue;
throw new Error(`unsupported git github-push-fallback option: ${arg}`);
}
validateHostName(options.hostName);
return options;
}
function deriveGitHubRepo(cwd: string): string | null {
const result = gitCapture(cwd, ["remote", "get-url", "origin"]);
if (!result.ok) return null;
const remote = result.stdout.trim();
const patterns = [
/^git@github\.com:([^/\s]+\/[^/\s]+?)(?:\.git)?$/u,
/^https:\/\/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?$/u,
/^ssh:\/\/git@(?:github\.com|ssh\.github\.com)(?::\d+)?\/([^/\s]+\/[^/\s]+?)(?:\.git)?$/u,
];
for (const pattern of patterns) {
const match = remote.match(pattern);
if (match?.[1] !== undefined) return match[1];
}
return null;
}
function currentGitBranch(cwd: string): string | null {
const result = gitCapture(cwd, ["branch", "--show-current"]);
const branch = result.ok ? result.stdout.trim() : "";
return branch.length > 0 ? branch : null;
}
function gitCapture(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 10_000 });
return {
ok: result.status === 0,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}
function classifyGitPushFailure(output: string): Record<string, unknown> | null {
if (/Could not resolve hostname github\.com|Temporary failure in name resolution|Name or service not known/iu.test(output)) {
return {
kind: "github-dns-or-name-resolution",
next: "retry with git github-push-fallback; if ssh.github.com DNS also fails, pass --host-name <known GitHub SSH IP>",
};
}
if (/connect to host github\.com port 22|Connection timed out|Network is unreachable/iu.test(output)) {
return {
kind: "github-port-22-or-network-blocked",
next: "retry through ssh.github.com:443 with git github-push-fallback",
};
}
if (/Permission denied \(publickey\)|Authentication failed|Repository not found/iu.test(output)) {
return {
kind: "auth-or-repository-access",
next: "do not keep retrying network fallbacks; inspect SSH key, deploy key, repository name, and GitHub access",
};
}
return null;
}
function requireValue(name: string, value: string | undefined): string {
if (value === undefined || value.length === 0) throw new Error(`${name} requires a non-empty value`);
return value;
}
function parsePort(value: string): number {
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error("--port must be an integer from 1 to 65535");
return port;
}
function validateGitHubRepo(value: string): void {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error("--repo must be owner/name");
}
function validatePushRef(value: string): void {
if (!/^[A-Za-z0-9_./:-]+$/u.test(value) || value.includes("..") || value.startsWith("-")) throw new Error("--branch must be a simple git ref without whitespace or shell characters");
}
function validateHostName(value: string): void {
if (!/^[A-Za-z0-9_.:-]+$/u.test(value) || value.startsWith("-")) throw new Error("--host-name must be a hostname or IP literal");
}
function tail(value: string, maxChars: number): string {
return value.length > maxChars ? value.slice(-maxChars) : value;
}