Update
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user