This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
'use strict';
const path = require('node:path');
const fs = require('node:fs/promises');
const { run } = require('./process-runner.cjs');
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
const {
assertSafeRepositoryPath,
assertRepositoryRelativePath,
assertRepositoryRelativePaths,
assertCommitMessage,
assertFullCommitSha,
assertCloneRemote
} = require('../shared/validation.cjs');
class GitService {
async isAvailable() {
try {
const result = await run('git', ['--version'], { timeout: 10_000 });
return { available: true, version: result.stdout.trim() };
} catch (error) {
return { available: false, version: null, error: error.message };
}
}
async ensureRepository(repoPath) {
const resolved = assertSafeRepositoryPath(repoPath);
const stat = await fs.stat(resolved).catch(() => null);
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
return path.resolve(result.stdout.trim());
}
async status(repoPath) {
const root = await this.ensureRepository(repoPath);
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
cwd: root,
timeout: 30_000
});
const parsed = parsePorcelainV2(result.stdout);
const remoteUrl = await this.getRemoteUrl(root).catch(() => '');
const head = parsed.branch.oid && parsed.branch.oid !== '(initial)' ? parsed.branch.oid : null;
return { ...parsed, root, remoteUrl, head, shortHead: head ? head.slice(0, 7) : null };
}
statusFingerprint(status) {
return JSON.stringify({
head: status?.head || null,
branch: status?.branch || null,
files: (status?.files || []).map((file) => [file.path, file.originalPath, file.indexCode, file.worktreeCode])
});
}
async getRemoteUrl(repoPath, remote = 'origin') {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
return result.stdout.trim();
}
async diff(repoPath, filePath, staged = false) {
const root = await this.ensureRepository(repoPath);
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
const args = ['diff', '--no-ext-diff', '--no-color', '--unified=4'];
if (staged) args.push('--cached');
if (safeFile) args.push('--', safeFile);
const result = await run('git', args, { cwd: root, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
if (!result.stdout && safeFile && !staged) {
const candidate = path.resolve(root, safeFile);
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
}
return result.stdout;
}
async stage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
const selected = assertRepositoryRelativePaths(files);
await run('git', selected.length ? ['add', '--', ...selected] : ['add', '--all'], { cwd: root, timeout: 60_000 });
return this.status(root);
}
async unstage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
const selected = assertRepositoryRelativePaths(files);
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
if (hasHead.exitCode === 0) {
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root });
} else {
await run('git', selected.length ? ['rm', '--cached', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
}
return this.status(root);
}
async prepareSelectedStage(root, files) {
const selected = assertRepositoryRelativePaths(files);
if (selected.length) {
const stagedBefore = await run('git', ['diff', '--cached', '--name-only', '-z'], { cwd: root });
const alreadyStaged = stagedBefore.stdout.split('\0').filter(Boolean);
const excludedStaged = alreadyStaged.filter((file) => !selected.includes(file));
if (excludedStaged.length) {
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
}
}
await this.stage(root, selected);
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
return selected;
}
async commit(repoPath, message, files = []) {
const root = await this.ensureRepository(repoPath);
const commitMessage = assertCommitMessage(message);
await this.prepareSelectedStage(root, files);
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
const status = await this.status(root);
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
}
async commitAndPush(repoPath, message, files = []) {
const committed = await this.commit(repoPath, message, files);
try {
const pushed = await this.push(repoPath);
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
} catch (error) {
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED';
wrapped.commitSha = committed.sha;
wrapped.recoverable = true;
throw wrapped;
}
}
async push(repoPath) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
const branch = status.branch.head;
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async fetch(repoPath) {
const root = await this.ensureRepository(repoPath);
const result = await run('git', ['fetch', '--prune'], { cwd: root, timeout: 180_000 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async pullFastForward(repoPath) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before synchronizing.');
if (!status.branch.upstream) throw new Error('This branch has no upstream branch. Publish it first.');
const result = await run('git', ['pull', '--ff-only'], { cwd: root, timeout: 180_000 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async history(repoPath, limit = 20) {
const root = await this.ensureRepository(repoPath);
const format = '%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e';
const result = await run('git', ['log', `-${Math.min(Math.max(Number(limit) || 20, 1), 100)}`, `--format=${format}`], { cwd: root, allowExitCodes: [128] });
if (result.exitCode === 128) return [];
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [sha, shortSha, author, email, date, subject] = record.split('\x1f');
return { sha, shortSha, author, email, date, subject };
});
}
async branches(repoPath) {
const root = await this.ensureRepository(repoPath);
const format = '%(refname:short)%x1f%(objectname)%x1f%(HEAD)%x1f%(upstream:short)%x1f%(upstream:track)%x1e';
const result = await run('git', ['for-each-ref', `--format=${format}`, 'refs/heads'], { cwd: root });
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [name, sha, current, upstream, track] = record.split('\x1f');
const ahead = Number(track?.match(/ahead (\d+)/)?.[1] || 0);
const behind = Number(track?.match(/behind (\d+)/)?.[1] || 0);
return { name, sha, shortSha: sha?.slice(0, 7), current: current === '*', upstream: upstream || null, ahead, behind };
});
}
assertBranchName(branch) {
const value = String(branch || '').trim();
if (!value) throw new Error('Branch name is required.');
return value;
}
async checkoutBranch(repoPath, branch) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before switching branches.');
const value = this.assertBranchName(branch);
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
await run('git', ['switch', value], { cwd: root, timeout: 60_000 });
return this.status(root);
}
async createBranch(repoPath, branch) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before creating a branch.');
const value = this.assertBranchName(branch);
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
await run('git', ['switch', '-c', value], { cwd: root, timeout: 60_000 });
return this.status(root);
}
async stash(repoPath, message = '') {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (status.clean) throw new Error('There are no changes to stash.');
const args = ['stash', 'push', '--include-untracked'];
const label = String(message || '').trim();
if (label) args.push('-m', label.slice(0, 200));
const result = await run('git', args, { cwd: root, timeout: 120_000 });
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
}
async stashList(repoPath) {
const root = await this.ensureRepository(repoPath);
const format = '%gd%x1f%H%x1f%aI%x1f%gs%x1e';
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [ref, sha, date, subject] = record.split('\x1f');
return { ref, sha, shortSha: sha.slice(0, 7), date, subject };
});
}
async popStash(repoPath, ref = 'stash@{0}') {
const root = await this.ensureRepository(repoPath);
const value = String(ref || 'stash@{0}');
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
}
async verifyCommitOnRemoteBranch(repoPath, sha, branch) {
const root = await this.ensureRepository(repoPath);
const fullSha = assertFullCommitSha(sha);
const branchName = this.assertBranchName(branch);
await run('git', ['fetch', '--prune', 'origin', branchName], { cwd: root, timeout: 180_000 });
await run('git', ['cat-file', '-e', `${fullSha}^{commit}`], { cwd: root, timeout: 30_000 });
const ancestor = await run('git', ['merge-base', '--is-ancestor', fullSha, `origin/${branchName}`], { cwd: root, allowExitCodes: [1] });
if (ancestor.exitCode !== 0) throw new Error(`Commit ${fullSha.slice(0, 7)} is not contained in origin/${branchName}.`);
return { valid: true, sha: fullSha, branch: branchName };
}
async inspectCloneTarget(remoteUrl, destination) {
const remote = assertCloneRemote(remoteUrl);
const target = assertSafeRepositoryPath(destination);
const existing = await fs.stat(target).catch(() => null);
if (!existing) return { state: 'missing', remote, target };
if (!existing.isDirectory()) {
const error = new Error('The automatic clone target exists and is not a folder.');
error.code = 'CLONE_TARGET_NOT_DIRECTORY';
throw error;
}
const entries = await fs.readdir(target);
if (!entries.length) return { state: 'empty', remote, target };
const existingRemote = await this.getRemoteUrl(target).catch(() => '');
const expected = normalizeRemoteUrl(remote);
const actual = normalizeRemoteUrl(existingRemote);
const sameRepository = Boolean(
expected && actual
&& expected.host === actual.host
&& expected.path === actual.path
);
if (sameRepository) return { state: 'matching-repository', remote, target };
const error = new Error(existingRemote
? 'The automatic clone target already contains a different Git repository.'
: 'The automatic clone target already contains files. Choose another location or link the existing folder.');
error.code = existingRemote ? 'CLONE_TARGET_DIFFERENT_REPOSITORY' : 'CLONE_TARGET_NOT_EMPTY';
throw error;
}
async clone(remoteUrl, destination) {
const assessment = await this.inspectCloneTarget(remoteUrl, destination);
if (assessment.state === 'matching-repository') {
const status = await this.status(assessment.target);
return { ...status, reused: true };
}
if (assessment.state === 'missing') {
await fs.mkdir(path.dirname(assessment.target), { recursive: true });
}
await run('git', ['clone', '--progress', assessment.remote, assessment.target], { timeout: 15 * 60_000, maxBuffer: 32 * 1024 * 1024 });
const status = await this.status(assessment.target);
return { ...status, reused: false };
}
}
module.exports = { GitService };