* Add getRemotes API endpoint
- Add getRemotes() function to git-service.js using simple-git's getRemotes(true)
- Returns array of {name, fetchUrl, pushUrl} for each remote
- Add GET /api/git/remotes endpoint to server/index.js
- Follows existing patterns for git endpoints (directory query param, error handling)
* Add merge and rebase API endpoints
- Add rebase(), abortRebase(), merge(), abortMerge() to git-service.js
- Add POST /api/git/rebase, /api/git/rebase/abort endpoints
- Add POST /api/git/merge, /api/git/merge/abort endpoints
- All functions return { success, conflict?, conflictFiles? }
- Conflict detection via error message parsing and git status
* Add client API functions for git remotes, merge, and rebase
- Added GitRemote, GitMergeResult, GitRebaseResult interfaces to types.ts
- Added getRemotes(), rebase(), abortRebase(), merge(), abortMerge() to gitApiHttp.ts
- Added corresponding exports and runtime wrappers to gitApi.ts
- All functions follow existing patterns with proper error handling
- Lint and type-check pass
* feat(git): add remote selection dropdown to SyncActions
- Add remotes prop to SyncActions component
- Change callbacks to accept GitRemote parameter
- Show dropdown menu when multiple remotes exist
- Execute immediately for single remote repos
- Display remote name and fetch URL in dropdown items
* feat: add BranchIntegrationSection component
- Branch selector dropdown (local + remote branches)
- Merge and Rebase buttons with loading states
- Props: currentBranch, localBranches, remoteBranches, onMerge, onRebase, disabled, isOperating
- Follows existing UI patterns (Command + DropdownMenu)
- Tooltips for all interactive elements
* Add ConflictDialog component for merge/rebase conflicts
- Shows when merge/rebase returns conflict
- Three action options: Resolve in New Session, Abort, Continue Later
- Resolve in New Session opens OpenChamber session in conflict directory
- Displays list of conflicted files
- Uses theme tokens for colors
- Follows existing dialog patterns from AboutDialog.tsx
* Integrate git remote selection and branch operations into GitView
- Fetch remotes on mount and store in state
- Pass remotes to SyncActions and update handleSyncAction to accept GitRemote parameter
- Add BranchIntegrationSection component below sync actions for merge/rebase operations
- Add ConflictDialog to handle merge/rebase conflicts with option to resolve in new session
- Export BranchIntegrationSection and ConflictDialog from git/index.ts
- Update GitHeader to accept remotes prop and pass to SyncActions
- Handle single vs multiple remote scenarios (immediate action vs dropdown)
- Fix React hooks exhaustive-deps warnings by capturing status in local variable
* fix: add missing git API methods to web and vscode packages
* feat: extend VSCode bridge with git remote/rebase/merge endpoints
* feat: add stash support for git operations across UI and API
* hive(01-add-types-for-conflict-details): Added MergeConflictDetails interface to packages/u
* hive(02-add-server-side-conflict-details-function): Added `getConflictDetails(directory)` function to
* hive(03-add-server-endpoint-for-conflict-details): Added GET /api/git/conflict-details endpoint to pa
* hive(04-add-client-side-api-for-conflict-details): Added client-side API for conflict details:
1. **
* hive(05-enhance-conflictdialog-with-rich-context): Enhanced ConflictDialog to fetch and use rich conf
* hive(06-add-state-persistence-for-conflicts): Added state persistence for merge/rebase conflicts
* feat: add conflict details API and AI resolve flow
* fix: improve focus handling in git UI and adjust web dev server port
* feat: add continue merge/rebase support and logs
* fix: address bugs in git merge/rebase feature
- Add explicit parentheses to hasUnresolvedConflicts logic for clarity
- Add error handling for stash operation in handleStashAndRetry
- Fix SSH key path escaping on Windows by normalizing before validation
* fix: add default value for remotes prop to prevent crash
When remotes is undefined, accessing .length throws TypeError.
Add default empty array to handle undefined case gracefully.
* fix: replace DialogFooter with plain div for proper button layout
DialogFooter's default flex-col-reverse and sm:flex-row styles
were conflicting with the intended vertical button stack layout,
causing buttons to not display properly.
* Fix lint erorr
* fix: remove duplicate BranchIntegrationSection and fix broken vscode bridge
- Remove duplicate BranchIntegrationSection from GitView.tsx (already in GitHeader)
- Fix vscode bridge calling non-existent ensureOpenChamberIgnored function
(legacy worktree function was removed, make api:git/ignore-openchamber a no-op)
* fix: handleResolveWithAIFromBanner now properly detects conflicts from status
The function was checking conflictFiles state which may be empty when
the banner is shown. Now it extracts conflict files directly from the
git status (files with 'U' status) and properly sets up the conflict
dialog state before opening it.
1605 lines
45 KiB
JavaScript
1605 lines
45 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 gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
|
|
|
/**
|
|
* Escape an SSH key path for use in core.sshCommand.
|
|
* Handles Windows/Unix differences and prevents command injection.
|
|
*/
|
|
function escapeSshKeyPath(sshKeyPath) {
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
// Normalize path first on Windows (convert backslashes to forward slashes)
|
|
let normalizedPath = sshKeyPath;
|
|
if (isWindows) {
|
|
normalizedPath = sshKeyPath.replace(/\\/g, '/');
|
|
}
|
|
|
|
// Validate: reject paths with characters that could enable injection
|
|
// Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives)
|
|
// Note: backslash is not in this list since we've already normalized Windows paths
|
|
const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/;
|
|
if (dangerousChars.test(normalizedPath)) {
|
|
throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`);
|
|
}
|
|
|
|
if (isWindows) {
|
|
// On Windows, Git (via MSYS/MinGW) expects Unix-style paths
|
|
// Convert "C:/path" to "/c/path" for MSYS compatibility
|
|
let unixPath = normalizedPath;
|
|
const driveMatch = unixPath.match(/^([A-Za-z]):\//);
|
|
if (driveMatch) {
|
|
unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`;
|
|
}
|
|
|
|
// Use single quotes for the path (prevents shell interpretation)
|
|
return `'${unixPath}'`;
|
|
} else {
|
|
// On Unix, use single quotes and escape any single quotes in the path
|
|
// Single quotes prevent all shell interpretation except for single quotes themselves
|
|
const escaped = normalizedPath.replace(/'/g, "'\\''");
|
|
return `'${escaped}'`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the SSH command string for git config
|
|
*/
|
|
function buildSshCommand(sshKeyPath) {
|
|
const escapedPath = escapeSshKeyPath(sshKeyPath);
|
|
return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`;
|
|
}
|
|
|
|
const isSocketPath = async (candidate) => {
|
|
if (!candidate || typeof candidate !== 'string') {
|
|
return false;
|
|
}
|
|
try {
|
|
const stat = await fsp.stat(candidate);
|
|
return typeof stat.isSocket === 'function' && stat.isSocket();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const resolveSshAuthSock = async () => {
|
|
const existing = (process.env.SSH_AUTH_SOCK || '').trim();
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
return null;
|
|
}
|
|
|
|
const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh');
|
|
if (await isSocketPath(gpgSock)) {
|
|
return gpgSock;
|
|
}
|
|
|
|
const runGpgconf = async (args) => {
|
|
for (const candidate of gpgconfCandidates) {
|
|
try {
|
|
const { stdout } = await execFileAsync(candidate, args);
|
|
return String(stdout || '');
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
|
|
if (candidate && await isSocketPath(candidate)) {
|
|
return candidate;
|
|
}
|
|
|
|
if (candidate) {
|
|
await runGpgconf(['--launch', 'gpg-agent']);
|
|
const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
|
|
if (retried && await isSocketPath(retried)) {
|
|
return retried;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const buildGitEnv = async () => {
|
|
const env = { ...process.env };
|
|
if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) {
|
|
const resolved = await resolveSshAuthSock();
|
|
if (resolved) {
|
|
env.SSH_AUTH_SOCK = resolved;
|
|
}
|
|
}
|
|
return env;
|
|
};
|
|
|
|
const createGit = async (directory) => {
|
|
const env = await buildGitEnv();
|
|
if (!directory) {
|
|
return simpleGit({ env });
|
|
}
|
|
return simpleGit({ baseDir: normalizeDirectoryPath(directory), env });
|
|
};
|
|
|
|
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 getGlobalIdentity() {
|
|
const git = await createGit();
|
|
|
|
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 = await createGit(directory);
|
|
|
|
try {
|
|
const url = await git.remote(['get-url', remoteName]);
|
|
return url?.trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getCurrentIdentity(directory) {
|
|
const git = await createGit(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 = await createGit(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 = await createGit(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',
|
|
buildSshCommand(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 = await createGit(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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for in-progress operations
|
|
let mergeInProgress = null;
|
|
let rebaseInProgress = null;
|
|
|
|
try {
|
|
// Check MERGE_HEAD for merge in progress
|
|
const mergeHeadExists = await git
|
|
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
if (mergeHeadExists) {
|
|
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
|
|
const headSha = mergeHead.trim().slice(0, 7);
|
|
// Only set mergeInProgress if we actually have a valid head SHA
|
|
if (headSha) {
|
|
const mergeMsg = await fsp.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8').catch(() => '');
|
|
mergeInProgress = {
|
|
head: headSha,
|
|
message: mergeMsg.split('\n')[0] || '',
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
// Check for rebase in progress (.git/rebase-merge or .git/rebase-apply)
|
|
const rebaseMergeExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false);
|
|
const rebaseApplyExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false);
|
|
|
|
if (rebaseMergeExists || rebaseApplyExists) {
|
|
const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply';
|
|
const headName = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'head-name'), 'utf8').catch(() => '');
|
|
const onto = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'onto'), 'utf8').catch(() => '');
|
|
|
|
const headNameTrimmed = headName.trim().replace('refs/heads/', '');
|
|
const ontoTrimmed = onto.trim().slice(0, 7);
|
|
|
|
// Only set rebaseInProgress if we have valid data
|
|
if (headNameTrimmed || ontoTrimmed) {
|
|
rebaseInProgress = {
|
|
headName: headNameTrimmed,
|
|
onto: ontoTrimmed,
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
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,
|
|
mergeInProgress,
|
|
rebaseInProgress,
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to get Git status:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) {
|
|
const git = await createGit(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);
|
|
try {
|
|
const noIndexDiff = await git.raw(noIndexArgs);
|
|
return noIndexDiff;
|
|
} catch (noIndexError) {
|
|
// git diff --no-index returns exit code 1 when differences exist (not a real error)
|
|
if (noIndexError.exitCode === 1 && noIndexError.message) {
|
|
return noIndexError.message;
|
|
}
|
|
throw noIndexError;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to get Git diff:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) {
|
|
const git = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 deleteBranch(directory, branch, options = {}) {
|
|
const git = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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 = await createGit(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;
|
|
}
|
|
}
|
|
|
|
export async function getRemotes(directory) {
|
|
const git = await createGit(directory);
|
|
|
|
try {
|
|
const remotes = await git.getRemotes(true);
|
|
|
|
return remotes.map((remote) => ({
|
|
name: remote.name,
|
|
fetchUrl: remote.refs.fetch,
|
|
pushUrl: remote.refs.push
|
|
}));
|
|
} catch (error) {
|
|
console.error('Failed to get remotes:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function rebase(directory, options = {}) {
|
|
const git = await createGit(directory);
|
|
|
|
try {
|
|
const { onto } = options;
|
|
if (!onto) {
|
|
throw new Error('onto parameter is required for rebase');
|
|
}
|
|
|
|
await git.rebase([onto]);
|
|
|
|
return {
|
|
success: true,
|
|
conflict: false
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
const isConflict = errorMessage.includes('conflict') ||
|
|
errorMessage.includes('could not apply') ||
|
|
errorMessage.includes('merge conflict');
|
|
|
|
if (isConflict) {
|
|
// Get list of conflicted files
|
|
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
return {
|
|
success: false,
|
|
conflict: true,
|
|
conflictFiles: status.conflicted || []
|
|
};
|
|
}
|
|
|
|
console.error('Failed to rebase:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function abortRebase(directory) {
|
|
const git = await createGit(directory);
|
|
|
|
try {
|
|
await git.rebase(['--abort']);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Failed to abort rebase:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function merge(directory, options = {}) {
|
|
const git = await createGit(directory);
|
|
|
|
try {
|
|
const { branch } = options;
|
|
if (!branch) {
|
|
throw new Error('branch parameter is required for merge');
|
|
}
|
|
|
|
await git.merge([branch]);
|
|
|
|
return {
|
|
success: true,
|
|
conflict: false
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
const isConflict = errorMessage.includes('conflict') ||
|
|
errorMessage.includes('merge conflict') ||
|
|
errorMessage.includes('automatic merge failed');
|
|
|
|
if (isConflict) {
|
|
// Get list of conflicted files
|
|
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
return {
|
|
success: false,
|
|
conflict: true,
|
|
conflictFiles: status.conflicted || []
|
|
};
|
|
}
|
|
|
|
console.error('Failed to merge:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function abortMerge(directory) {
|
|
const git = await createGit(directory);
|
|
|
|
try {
|
|
await git.merge(['--abort']);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Failed to abort merge:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function continueRebase(directory) {
|
|
const directoryPath = normalizeDirectoryPath(directory);
|
|
const git = await createGit(directoryPath);
|
|
|
|
try {
|
|
// Set GIT_EDITOR to prevent editor prompts
|
|
await git.env('GIT_EDITOR', 'true').rebase(['--continue']);
|
|
return { success: true, conflict: false };
|
|
} catch (error) {
|
|
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
const isConflict = errorMessage.includes('conflict') ||
|
|
errorMessage.includes('needs merge') ||
|
|
errorMessage.includes('unmerged') ||
|
|
errorMessage.includes('fix conflicts');
|
|
|
|
if (isConflict) {
|
|
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
return {
|
|
success: false,
|
|
conflict: true,
|
|
conflictFiles: status.conflicted || []
|
|
};
|
|
}
|
|
|
|
// Check for "nothing to commit" which means rebase step is complete
|
|
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes')) {
|
|
// Skip this commit and continue
|
|
try {
|
|
await git.env('GIT_EDITOR', 'true').rebase(['--skip']);
|
|
return { success: true, conflict: false };
|
|
} catch {
|
|
// If skip also fails, the rebase may be complete
|
|
return { success: true, conflict: false };
|
|
}
|
|
}
|
|
|
|
console.error('Failed to continue rebase:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function continueMerge(directory) {
|
|
const directoryPath = normalizeDirectoryPath(directory);
|
|
const git = await createGit(directoryPath);
|
|
|
|
try {
|
|
// Check if there are still unmerged files
|
|
const status = await git.status();
|
|
if (status.conflicted && status.conflicted.length > 0) {
|
|
return {
|
|
success: false,
|
|
conflict: true,
|
|
conflictFiles: status.conflicted
|
|
};
|
|
}
|
|
|
|
// For merge, we commit after resolving conflicts
|
|
// Use --no-edit to use the default merge commit message
|
|
await git.env('GIT_EDITOR', 'true').commit([], { '--no-edit': null });
|
|
return { success: true, conflict: false };
|
|
} catch (error) {
|
|
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
const isConflict = errorMessage.includes('conflict') ||
|
|
errorMessage.includes('needs merge') ||
|
|
errorMessage.includes('unmerged') ||
|
|
errorMessage.includes('fix conflicts');
|
|
|
|
if (isConflict) {
|
|
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
return {
|
|
success: false,
|
|
conflict: true,
|
|
conflictFiles: status.conflicted || []
|
|
};
|
|
}
|
|
|
|
// "nothing to commit" can happen if all conflicts resolved to one side
|
|
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added')) {
|
|
// The merge is effectively complete (all changes already committed or no changes needed)
|
|
return { success: true, conflict: false };
|
|
}
|
|
|
|
console.error('Failed to continue merge:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getConflictDetails(directory) {
|
|
const directoryPath = normalizeDirectoryPath(directory);
|
|
const git = await createGit(directoryPath);
|
|
|
|
try {
|
|
// Get git status --porcelain
|
|
const statusPorcelain = await git.raw(['status', '--porcelain']).catch(() => '');
|
|
|
|
// Get unmerged files
|
|
const unmergedFilesRaw = await git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
|
|
const unmergedFiles = unmergedFilesRaw
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
// Get current diff
|
|
const diff = await git.raw(['diff']).catch(() => '');
|
|
|
|
// Detect operation type and get head info
|
|
let operation = 'merge';
|
|
let headInfo = '';
|
|
|
|
// Check for MERGE_HEAD (merge in progress)
|
|
const mergeHeadExists = await git
|
|
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
if (mergeHeadExists) {
|
|
operation = 'merge';
|
|
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
|
|
const mergeMsg = await fsp
|
|
.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8')
|
|
.catch(() => '');
|
|
headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`;
|
|
} else {
|
|
// Check for REBASE_HEAD (rebase in progress)
|
|
const rebaseHeadExists = await git
|
|
.raw(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'])
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
if (rebaseHeadExists) {
|
|
operation = 'rebase';
|
|
const rebaseHead = await git.raw(['rev-parse', 'REBASE_HEAD']).catch(() => '');
|
|
headInfo = `REBASE_HEAD: ${rebaseHead.trim()}`;
|
|
}
|
|
}
|
|
|
|
return {
|
|
statusPorcelain: statusPorcelain.trim(),
|
|
unmergedFiles,
|
|
diff: diff.trim(),
|
|
headInfo: headInfo.trim(),
|
|
operation,
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to get conflict details:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============== Stash Operations ==============
|
|
|
|
export async function stash(directory, options = {}) {
|
|
const git = await createGit(directory);
|
|
|
|
try {
|
|
const args = ['stash', 'push'];
|
|
|
|
// Include untracked files by default
|
|
if (options.includeUntracked !== false) {
|
|
args.push('--include-untracked');
|
|
}
|
|
|
|
if (options.message) {
|
|
args.push('-m', options.message);
|
|
}
|
|
|
|
await git.raw(args);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Failed to stash:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function stashPop(directory) {
|
|
const git = await createGit(directory);
|
|
|
|
try {
|
|
await git.raw(['stash', 'pop']);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Failed to pop stash:', error);
|
|
throw error;
|
|
}
|
|
}
|