116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export interface GitExecResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
exitCode: number;
|
|
}
|
|
|
|
export interface GitCommitResult {
|
|
changed: boolean; // false if "nothing to commit"
|
|
sha: string | null;
|
|
}
|
|
|
|
export class GitClient {
|
|
constructor(private cwd: string, private timeoutMs: number = 60_000) {}
|
|
|
|
async run(args: string[]): Promise<GitExecResult> {
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(
|
|
'git',
|
|
['-C', this.cwd, ...args],
|
|
{ timeout: this.timeoutMs, maxBuffer: 10 * 1024 * 1024 }
|
|
);
|
|
return { stdout, stderr, exitCode: 0 };
|
|
} catch (e) {
|
|
const err = e as { stdout?: string; stderr?: string; code?: number };
|
|
return {
|
|
stdout: err.stdout ?? '',
|
|
stderr: err.stderr ?? String(e),
|
|
exitCode: typeof err.code === 'number' ? err.code : -1
|
|
};
|
|
}
|
|
}
|
|
|
|
async isRepo(): Promise<boolean> {
|
|
const r = await this.run(['rev-parse', '--git-dir']);
|
|
return r.exitCode === 0;
|
|
}
|
|
|
|
async hasRemote(name: string = 'origin'): Promise<boolean> {
|
|
const r = await this.run(['remote', 'get-url', name]);
|
|
return r.exitCode === 0;
|
|
}
|
|
|
|
async addAll(): Promise<void> {
|
|
const r = await this.run(['add', '-A']);
|
|
if (r.exitCode !== 0) throw new Error(`git add failed: ${r.stderr}`);
|
|
}
|
|
|
|
async commit(message: string): Promise<GitCommitResult> {
|
|
// Detect "nothing to commit" via stderr/stdout rather than treating as a thrown error.
|
|
const r = await this.run(['commit', '-m', message]);
|
|
if (r.exitCode === 0) {
|
|
const sha = await this.run(['rev-parse', 'HEAD']);
|
|
return { changed: true, sha: sha.stdout.trim() };
|
|
}
|
|
const blob = (r.stdout + '\n' + r.stderr).toLowerCase();
|
|
const nothingToCommit = /nothing to commit|nothing added to commit|no changes added to commit/i.test(blob);
|
|
if (nothingToCommit) {
|
|
return { changed: false, sha: null };
|
|
}
|
|
throw new Error(`git commit failed: ${r.stderr || r.stdout}`);
|
|
}
|
|
|
|
async hasUpstream(): Promise<boolean> {
|
|
const r = await this.run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
return r.exitCode === 0;
|
|
}
|
|
|
|
async push(remote: string = 'origin', branch?: string): Promise<void> {
|
|
// If no upstream is configured, set one with -u so subsequent `git push` works.
|
|
const upstream = await this.hasUpstream();
|
|
let args: string[];
|
|
if (!upstream) {
|
|
const targetBranch = branch ?? (await this.currentBranch());
|
|
args = ['push', '-u', remote, targetBranch];
|
|
} else {
|
|
args = branch ? ['push', remote, branch] : ['push', remote];
|
|
}
|
|
const r = await this.run(args);
|
|
if (r.exitCode !== 0) throw new Error(`git push failed: ${r.stderr}`);
|
|
}
|
|
|
|
async currentBranch(): Promise<string> {
|
|
const r = await this.run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
if (r.exitCode !== 0) throw new Error(`git rev-parse failed: ${r.stderr}`);
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
async fetch(remote: string = 'origin'): Promise<GitExecResult> {
|
|
return this.run(['fetch', remote]);
|
|
}
|
|
|
|
async rebaseOnto(ref: string): Promise<GitExecResult> {
|
|
return this.run(['rebase', ref]);
|
|
}
|
|
|
|
async rebaseAbort(): Promise<GitExecResult> {
|
|
return this.run(['rebase', '--abort']);
|
|
}
|
|
|
|
async hasUncommittedChanges(): Promise<boolean> {
|
|
const r = await this.run(['status', '--porcelain']);
|
|
return r.stdout.trim().length > 0;
|
|
}
|
|
|
|
/** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */
|
|
async listConflicts(): Promise<string[]> {
|
|
const r = await this.run(['diff', '--name-only', '--diff-filter=U']);
|
|
return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
}
|
|
}
|