Files
openchamber/packages/web/server/lib/git-service.js
T

1211 lines
33 KiB
JavaScript

import simpleGit from 'simple-git';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { execFile } from 'child_process';
import { promisify } from 'util';
const fsp = fs.promises;
const execFileAsync = promisify(execFile);
const normalizeDirectoryPath = (value) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed === '~') {
return os.homedir();
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
};
const cleanBranchName = (branch) => {
if (!branch) {
return branch;
}
if (branch.startsWith('refs/heads/')) {
return branch.substring('refs/heads/'.length);
}
if (branch.startsWith('heads/')) {
return branch.substring('heads/'.length);
}
if (branch.startsWith('refs/')) {
return branch.substring('refs/'.length);
}
return branch;
};
export async function isGitRepository(directory) {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath)) {
return false;
}
const gitDir = path.join(directoryPath, '.git');
return fs.existsSync(gitDir);
}
export async function ensureOpenChamberIgnored(directory) {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath)) {
return false;
}
const gitDir = path.join(directoryPath, '.git');
if (!fs.existsSync(gitDir)) {
return false;
}
const infoDir = path.join(gitDir, 'info');
const excludePath = path.join(infoDir, 'exclude');
const entry = '/.openchamber/';
try {
await fsp.mkdir(infoDir, { recursive: true });
let contents = '';
try {
contents = await fsp.readFile(excludePath, 'utf8');
} catch (readError) {
if (readError && readError.code !== 'ENOENT') {
throw readError;
}
}
const lines = contents.split(/\r?\n/).map((line) => line.trim());
if (!lines.includes(entry)) {
const prefix = contents.length > 0 && !contents.endsWith('\n') ? '\n' : '';
await fsp.appendFile(excludePath, `${prefix}${entry}\n`, 'utf8');
}
return true;
} catch (error) {
console.error('Failed to ensure .openchamber ignore:', error);
throw error;
}
}
export async function getGlobalIdentity() {
const git = simpleGit();
try {
const userName = await git.getConfig('user.name', 'global').catch(() => null);
const userEmail = await git.getConfig('user.email', 'global').catch(() => null);
const sshCommand = await git.getConfig('core.sshCommand', 'global').catch(() => null);
return {
userName: userName?.value || null,
userEmail: userEmail?.value || null,
sshCommand: sshCommand?.value || null
};
} catch (error) {
console.error('Failed to get global Git identity:', error);
return {
userName: null,
userEmail: null,
sshCommand: null
};
}
}
export async function getRemoteUrl(directory, remoteName = 'origin') {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const url = await git.remote(['get-url', remoteName]);
return url?.trim() || null;
} catch {
return null;
}
}
export async function getCurrentIdentity(directory) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const userName = await git.getConfig('user.name', 'local').catch(() =>
git.getConfig('user.name', 'global')
);
const userEmail = await git.getConfig('user.email', 'local').catch(() =>
git.getConfig('user.email', 'global')
);
const sshCommand = await git.getConfig('core.sshCommand', 'local').catch(() =>
git.getConfig('core.sshCommand', 'global')
);
return {
userName: userName?.value || null,
userEmail: userEmail?.value || null,
sshCommand: sshCommand?.value || null
};
} catch (error) {
console.error('Failed to get current Git identity:', error);
return {
userName: null,
userEmail: null,
sshCommand: null
};
}
}
export async function hasLocalIdentity(directory) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const localName = await git.getConfig('user.name', 'local').catch(() => null);
const localEmail = await git.getConfig('user.email', 'local').catch(() => null);
return Boolean(localName?.value || localEmail?.value);
} catch {
return false;
}
}
export async function setLocalIdentity(directory, profile) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
await git.addConfig('user.name', profile.userName, false, 'local');
await git.addConfig('user.email', profile.userEmail, false, 'local');
const authType = profile.authType || 'ssh';
if (authType === 'ssh' && profile.sshKey) {
await git.addConfig(
'core.sshCommand',
`ssh -i ${profile.sshKey}`,
false,
'local'
);
await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
} else if (authType === 'token' && profile.host) {
await git.addConfig(
'credential.helper',
'store',
false,
'local'
);
await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
}
return true;
} catch (error) {
console.error('Failed to set Git identity:', error);
throw error;
}
}
export async function getStatus(directory) {
const directoryPath = normalizeDirectoryPath(directory);
const git = simpleGit(directoryPath);
try {
// Use -uall to show all untracked files individually, not just directories
const status = await git.status(['-uall']);
const [stagedStatsRaw, workingStatsRaw] = await Promise.all([
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
git.raw(['diff', '--numstat']).catch(() => ''),
]);
const diffStatsMap = new Map();
const accumulateStats = (raw) => {
if (!raw) return;
raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const parts = line.split('\t');
if (parts.length < 3) {
return;
}
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
const path = pathParts.join('\t');
if (!path) {
return;
}
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
const existing = diffStatsMap.get(path) || { insertions: 0, deletions: 0 };
diffStatsMap.set(path, {
insertions: existing.insertions + insertions,
deletions: existing.deletions + deletions,
});
});
};
accumulateStats(stagedStatsRaw);
accumulateStats(workingStatsRaw);
const diffStats = Object.fromEntries(diffStatsMap.entries());
const newFileStats = await Promise.all(
status.files.map(async (file) => {
const working = (file.working_dir || '').trim();
const indexStatus = (file.index || '').trim();
const statusCode = working || indexStatus;
if (statusCode !== '?' && statusCode !== 'A') {
return null;
}
const existing = diffStats[file.path];
if (existing && existing.insertions > 0) {
return null;
}
const absolutePath = path.join(directoryPath, file.path);
try {
const stat = await fsp.stat(absolutePath);
if (!stat.isFile()) {
return null;
}
const buffer = await fsp.readFile(absolutePath);
if (buffer.indexOf(0) !== -1) {
return {
path: file.path,
insertions: existing?.insertions ?? 0,
deletions: existing?.deletions ?? 0,
};
}
const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n');
if (!normalized.length) {
return {
path: file.path,
insertions: 0,
deletions: 0,
};
}
const segments = normalized.split('\n');
if (normalized.endsWith('\n')) {
segments.pop();
}
const lineCount = segments.length;
return {
path: file.path,
insertions: lineCount,
deletions: 0,
};
} catch (error) {
console.warn('Failed to estimate diff stats for new file', file.path, error);
return null;
}
})
);
for (const entry of newFileStats) {
if (!entry) continue;
diffStats[entry.path] = {
insertions: entry.insertions,
deletions: entry.deletions,
};
}
const selectBaseRefForUnpublished = async () => {
const candidates = [];
const originHead = await git
.raw(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'])
.then((value) => String(value || '').trim())
.catch(() => '');
if (originHead) {
// "refs/remotes/origin/main" -> "origin/main"
candidates.push(originHead.replace(/^refs\/remotes\//, ''));
}
candidates.push('origin/main', 'origin/master', 'main', 'master');
for (const ref of candidates) {
const exists = await git
.raw(['rev-parse', '--verify', ref])
.then((value) => String(value || '').trim())
.catch(() => '');
if (exists) return ref;
}
return null;
};
let tracking = status.tracking || null;
let ahead = status.ahead;
let behind = status.behind;
// When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
// We still want to show the number of unpublished commits to the user.
if (!tracking && status.current) {
const baseRef = await selectBaseRefForUnpublished();
if (baseRef) {
const countRaw = await git
.raw(['rev-list', '--count', `${baseRef}..HEAD`])
.then((value) => String(value || '').trim())
.catch(() => '');
const count = parseInt(countRaw, 10);
if (Number.isFinite(count)) {
ahead = count;
behind = 0;
}
}
}
return {
current: status.current,
tracking,
ahead,
behind,
files: status.files.map((f) => ({
path: f.path,
index: f.index,
working_dir: f.working_dir,
})),
isClean: status.isClean(),
diffStats,
};
} catch (error) {
console.error('Failed to get Git status:', error);
throw error;
}
}
export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
}
if (staged) {
args.push('--cached');
}
if (path) {
args.push('--', path);
}
const diff = await git.raw(args);
if (diff && diff.trim().length > 0) {
return diff;
}
if (staged) {
return diff;
}
try {
await git.raw(['ls-files', '--error-unmatch', path]);
return diff;
} catch {
const noIndexArgs = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
}
noIndexArgs.push('--no-index', '--', '/dev/null', path);
const noIndexDiff = await git.raw(noIndexArgs);
return noIndexDiff;
}
} catch (error) {
console.error('Failed to get Git diff:', error);
throw error;
}
}
export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) {
throw new Error('base and head are required');
}
// Prefer remote-tracking base ref so merged commits don't reappear
// when local base branch is stale (common when user stays on feature branch).
let resolvedBase = baseRef;
const originCandidate = `refs/remotes/origin/${baseRef}`;
try {
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
if (verified && verified.trim()) {
resolvedBase = `origin/${baseRef}`;
}
} catch {
// ignore
}
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
}
args.push(`${resolvedBase}...${headRef}`);
if (path) {
args.push('--', path);
}
const diff = await git.raw(args);
return diff;
}
export async function getRangeFiles(directory, { base, head } = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) {
throw new Error('base and head are required');
}
let resolvedBase = baseRef;
const originCandidate = `refs/remotes/origin/${baseRef}`;
try {
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
if (verified && verified.trim()) {
resolvedBase = `origin/${baseRef}`;
}
} catch {
// ignore
}
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
return String(raw || '')
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
function isImageFile(filePath) {
const ext = filePath.split('.').pop()?.toLowerCase();
return IMAGE_EXTENSIONS.includes(ext || '');
}
function getImageMimeType(filePath) {
const ext = filePath.split('.').pop()?.toLowerCase();
const mimeMap = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'webp': 'image/webp',
'ico': 'image/x-icon',
'bmp': 'image/bmp',
'avif': 'image/avif',
};
return mimeMap[ext] || 'application/octet-stream';
}
export async function getFileDiff(directory, { path: filePath, staged = false } = {}) {
if (!directory || !filePath) {
throw new Error('directory and path are required for getFileDiff');
}
const directoryPath = normalizeDirectoryPath(directory);
const git = simpleGit(directoryPath);
const isImage = isImageFile(filePath);
const mimeType = isImage ? getImageMimeType(filePath) : null;
let original = '';
try {
if (isImage) {
// For images, use git show with raw output and convert to base64
try {
const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], {
cwd: directoryPath,
encoding: 'buffer',
maxBuffer: 50 * 1024 * 1024, // 50MB max
});
if (stdout && stdout.length > 0) {
original = `data:${mimeType};base64,${stdout.toString('base64')}`;
}
} catch {
original = '';
}
} else {
original = await git.show([`HEAD:${filePath}`]);
}
} catch {
original = '';
}
const fullPath = path.join(directoryPath, filePath);
let modified = '';
try {
const stat = await fsp.stat(fullPath);
if (stat.isFile()) {
if (isImage) {
// For images, read as binary and convert to data URL
const buffer = await fsp.readFile(fullPath);
modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
} else {
modified = await fsp.readFile(fullPath, 'utf8');
}
}
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
modified = '';
} else {
console.error('Failed to read modified file contents for diff:', error);
throw error;
}
}
return {
original,
modified,
path: filePath,
};
}
export async function revertFile(directory, filePath) {
const directoryPath = normalizeDirectoryPath(directory);
const git = simpleGit(directoryPath);
const repoRoot = path.resolve(directoryPath);
const absoluteTarget = path.resolve(repoRoot, filePath);
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
throw new Error('Invalid file path');
}
const isTracked = await git
.raw(['ls-files', '--error-unmatch', filePath])
.then(() => true)
.catch(() => false);
if (!isTracked) {
try {
await git.raw(['clean', '-f', '-d', '--', filePath]);
return;
} catch (cleanError) {
try {
await fsp.rm(absoluteTarget, { recursive: true, force: true });
return;
} catch (fsError) {
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
return;
}
console.error('Failed to remove untracked file during revert:', fsError);
throw fsError;
}
}
}
try {
await git.raw(['restore', '--staged', filePath]);
} catch (error) {
await git.raw(['reset', 'HEAD', '--', filePath]).catch(() => {});
}
try {
await git.raw(['restore', filePath]);
} catch (error) {
try {
await git.raw(['checkout', '--', filePath]);
} catch (fallbackError) {
console.error('Failed to revert git file:', fallbackError);
throw fallbackError;
}
}
}
export async function collectDiffs(directory, files = []) {
const results = [];
for (const filePath of files) {
try {
const diff = await getDiff(directory, { path: filePath });
if (diff && diff.trim().length > 0) {
results.push({ path: filePath, diff });
}
} catch (error) {
console.error(`Failed to diff ${filePath}:`, error);
}
}
return results;
}
export async function pull(directory, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const result = await git.pull(
options.remote || 'origin',
options.branch,
options.options || {}
);
return {
success: true,
summary: result.summary,
files: result.files,
insertions: result.insertions,
deletions: result.deletions
};
} catch (error) {
console.error('Failed to pull:', error);
throw error;
}
}
export async function push(directory, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
const buildUpstreamOptions = (raw) => {
if (Array.isArray(raw)) {
return raw.includes('--set-upstream') ? raw : [...raw, '--set-upstream'];
}
if (raw && typeof raw === 'object') {
return { ...raw, '--set-upstream': null };
}
return ['--set-upstream'];
};
const looksLikeMissingUpstream = (error) => {
const message = String(error?.message || error?.stderr || '').toLowerCase();
return (
message.includes('has no upstream') ||
message.includes('no upstream') ||
message.includes('set-upstream') ||
message.includes('set upstream') ||
(message.includes('upstream') && message.includes('push') && message.includes('-u'))
);
};
const normalizePushResult = (result) => {
return {
success: true,
pushed: result.pushed,
repo: result.repo,
ref: result.ref,
};
};
const remote = options.remote || 'origin';
// If caller didn't specify a branch, this is the common "Push"/"Commit & Push" path.
// When there's no upstream yet (typical for freshly-created worktree branches), publish it on first push.
if (!options.branch) {
try {
const status = await git.status();
if (status.current && !status.tracking) {
const result = await git.push(remote, status.current, buildUpstreamOptions(options.options));
return normalizePushResult(result);
}
} catch (error) {
// If we can't read status, fall back to the regular push path below.
console.warn('Failed to read git status before push:', error);
}
}
try {
const result = await git.push(remote, options.branch, options.options || {});
return normalizePushResult(result);
} catch (error) {
// Last-resort fallback: retry with upstream if the error suggests it's missing.
if (!looksLikeMissingUpstream(error)) {
console.error('Failed to push:', error);
throw error;
}
try {
const status = await git.status();
const branch = options.branch || status.current;
if (!branch) {
console.error('Failed to push: missing branch name for upstream setup:', error);
throw error;
}
const result = await git.push(remote, branch, buildUpstreamOptions(options.options));
return normalizePushResult(result);
} catch (fallbackError) {
console.error('Failed to push (including upstream fallback):', fallbackError);
throw fallbackError;
}
}
}
export async function deleteRemoteBranch(directory, options = {}) {
const { branch, remote } = options;
if (!branch) {
throw new Error('branch is required to delete remote branch');
}
const git = simpleGit(normalizeDirectoryPath(directory));
const targetBranch = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length)
: branch;
const remoteName = remote || 'origin';
try {
await git.push(remoteName, `:${targetBranch}`);
return { success: true };
} catch (error) {
console.error('Failed to delete remote branch:', error);
throw error;
}
}
export async function fetch(directory, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
await git.fetch(
options.remote || 'origin',
options.branch,
options.options || {}
);
return { success: true };
} catch (error) {
console.error('Failed to fetch:', error);
throw error;
}
}
export async function commit(directory, message, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
if (options.addAll) {
await git.add('.');
} else if (Array.isArray(options.files) && options.files.length > 0) {
await git.add(options.files);
}
const commitArgs =
!options.addAll && Array.isArray(options.files) && options.files.length > 0
? options.files
: undefined;
const result = await git.commit(message, commitArgs);
return {
success: true,
commit: result.commit,
branch: result.branch,
summary: result.summary
};
} catch (error) {
console.error('Failed to commit:', error);
throw error;
}
}
export async function getBranches(directory) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const result = await git.branch();
const allBranches = result.all;
const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/'));
const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches);
const filteredAll = [
...allBranches.filter(branch => !branch.startsWith('remotes/')),
...activeRemoteBranches
];
return {
all: filteredAll,
current: result.current,
branches: result.branches
};
} catch (error) {
console.error('Failed to get branches:', error);
throw error;
}
}
async function filterActiveRemoteBranches(git, remoteBranches) {
try {
const lsRemoteResult = await git.raw(['ls-remote', '--heads', 'origin']);
const actualRemoteBranches = new Set();
const lines = lsRemoteResult.trim().split('\n');
for (const line of lines) {
if (line.includes('\trefs/heads/')) {
const branchName = line.split('\t')[1].replace('refs/heads/', '');
actualRemoteBranches.add(branchName);
}
}
return remoteBranches.filter(remoteBranch => {
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
if (!match) return false;
const branchName = match[1];
return actualRemoteBranches.has(branchName);
});
} catch (error) {
console.warn('Failed to filter active remote branches, returning all:', error.message);
return remoteBranches;
}
}
export async function createBranch(directory, branchName, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
return { success: true, branch: branchName };
} catch (error) {
console.error('Failed to create branch:', error);
throw error;
}
}
export async function checkoutBranch(directory, branchName) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
await git.checkout(branchName);
return { success: true, branch: branchName };
} catch (error) {
console.error('Failed to checkout branch:', error);
throw error;
}
}
export async function getWorktrees(directory) {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) {
return [];
}
const git = simpleGit(directoryPath);
try {
const result = await git.raw(['worktree', 'list', '--porcelain']);
const worktrees = [];
const lines = result.split('\n');
let current = {};
for (const line of lines) {
if (line.startsWith('worktree ')) {
if (current.worktree) {
worktrees.push(current);
}
current = { worktree: line.substring(9) };
} else if (line.startsWith('HEAD ')) {
current.head = line.substring(5);
} else if (line.startsWith('branch ')) {
current.branch = cleanBranchName(line.substring(7));
} else if (line === '') {
if (current.worktree) {
worktrees.push(current);
current = {};
}
}
}
if (current.worktree) {
worktrees.push(current);
}
return worktrees;
} catch (error) {
console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
return [];
}
}
export async function addWorktree(directory, worktreePath, branch, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const args = ['worktree', 'add'];
const startPoint = typeof options.startPoint === 'string' ? options.startPoint.trim() : '';
if (options.createBranch) {
args.push('-b', branch);
}
args.push(worktreePath);
if (!options.createBranch) {
args.push(branch);
} else if (startPoint) {
args.push(startPoint);
}
await git.raw(args);
return {
success: true,
path: worktreePath,
branch
};
} catch (error) {
console.error('Failed to add worktree:', error);
throw error;
}
}
export async function removeWorktree(directory, worktreePath, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const args = ['worktree', 'remove', worktreePath];
if (options.force) {
args.push('--force');
}
await git.raw(args);
return { success: true };
} catch (error) {
console.error('Failed to remove worktree:', error);
throw error;
}
}
export async function deleteBranch(directory, branch, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const branchName = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length)
: branch;
const args = ['branch', options.force ? '-D' : '-d', branchName];
await git.raw(args);
return { success: true };
} catch (error) {
console.error('Failed to delete branch:', error);
throw error;
}
}
export async function getLog(directory, options = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const maxCount = options.maxCount || 50;
const baseLog = await git.log({
maxCount,
from: options.from,
to: options.to,
file: options.file
});
const logArgs = [
'log',
`--max-count=${maxCount}`,
'--date=iso',
'--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e',
'--shortstat'
];
if (options.from && options.to) {
logArgs.push(`${options.from}..${options.to}`);
} else if (options.from) {
logArgs.push(`${options.from}..HEAD`);
} else if (options.to) {
logArgs.push(options.to);
}
if (options.file) {
logArgs.push('--', options.file);
}
const rawLog = await git.raw(logArgs);
const records = rawLog
.split('\x1e')
.map((entry) => entry.trim())
.filter(Boolean);
const statsMap = new Map();
records.forEach((record) => {
const lines = record.split('\n').filter((line) => line.trim().length > 0);
const header = lines.shift() || '';
const [hash] = header.split('\x1f');
if (!hash) {
return;
}
let filesChanged = 0;
let insertions = 0;
let deletions = 0;
lines.forEach((line) => {
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
if (filesMatch) {
filesChanged = parseInt(filesMatch[1], 10);
}
if (insertMatch) {
insertions = parseInt(insertMatch[1], 10);
}
if (deleteMatch) {
deletions = parseInt(deleteMatch[1], 10);
}
});
statsMap.set(hash, { filesChanged, insertions, deletions });
});
const merged = baseLog.all.map((entry) => {
const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0 };
return {
hash: entry.hash,
date: entry.date,
message: entry.message,
refs: entry.refs || '',
body: entry.body || '',
author_name: entry.author_name,
author_email: entry.author_email,
filesChanged: stats.filesChanged,
insertions: stats.insertions,
deletions: stats.deletions
};
});
return {
all: merged,
latest: merged[0] || null,
total: baseLog.total
};
} catch (error) {
console.error('Failed to get log:', error);
throw error;
}
}
export async function isLinkedWorktree(directory) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const [gitDir, gitCommonDir] = await Promise.all([
git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()),
git.raw(['rev-parse', '--git-common-dir']).then((output) => output.trim())
]);
return gitDir !== gitCommonDir;
} catch (error) {
console.error('Failed to determine worktree type:', error);
return false;
}
}
export async function getCommitFiles(directory, commitHash) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const numstatRaw = await git.raw([
'show',
'--numstat',
'--format=',
commitHash
]);
const files = [];
const lines = numstatRaw.trim().split('\n').filter(Boolean);
for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 3) continue;
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
const filePath = pathParts.join('\t');
if (!filePath) continue;
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
let changeType = 'M';
let displayPath = filePath;
if (filePath.includes(' => ')) {
changeType = 'R';
const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
if (match) {
displayPath = filePath;
}
}
files.push({
path: displayPath,
insertions,
deletions,
isBinary,
changeType
});
}
const nameStatusRaw = await git.raw([
'show',
'--name-status',
'--format=',
commitHash
]).catch(() => '');
const statusMap = new Map();
const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
for (const line of statusLines) {
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
if (match) {
const [, status, path] = match;
statusMap.set(path, status);
}
}
for (const file of files) {
const basePath = file.path.includes(' => ')
? file.path.split(' => ').pop()?.replace(/[{}]/g, '') || file.path
: file.path;
const status = statusMap.get(basePath) || statusMap.get(file.path);
if (status) {
file.changeType = status;
}
}
return { files };
} catch (error) {
console.error('Failed to get commit files:', error);
throw error;
}
}
export async function renameBranch(directory, oldName, newName) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
// Use git branch -m command to rename the branch
await git.raw(['branch', '-m', oldName, newName]);
return { success: true, branch: newName };
} catch (error) {
console.error('Failed to rename branch:', error);
throw error;
}
}