Files
inkling/src/main/services/GitClient.ts
2026-05-10 03:23:00 +09:00

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);
}
}