* 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.
1635 lines
45 KiB
TypeScript
1635 lines
45 KiB
TypeScript
/**
|
|
* Git service for VS Code extension
|
|
* Uses VS Code's built-in git extension API for repository operations
|
|
* and raw git commands via child_process for worktree operations
|
|
*/
|
|
|
|
import * as vscode from 'vscode';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import * as fs from 'fs';
|
|
import { spawn, execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import type { API as GitAPI, Repository, GitExtension, Status } from './git.d';
|
|
|
|
let gitApi: GitAPI | null = null;
|
|
let gitExtensionEnabled = false;
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
|
|
|
async function isSocketPath(candidate: string): Promise<boolean> {
|
|
if (!candidate) {
|
|
return false;
|
|
}
|
|
try {
|
|
const stat = await fs.promises.stat(candidate);
|
|
return typeof stat.isSocket === 'function' && stat.isSocket();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function resolveSshAuthSock(): Promise<string | undefined> {
|
|
const existing = (process.env.SSH_AUTH_SOCK || '').trim();
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
return undefined;
|
|
}
|
|
|
|
const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh');
|
|
if (await isSocketPath(gpgSock)) {
|
|
return gpgSock;
|
|
}
|
|
|
|
const runGpgconf = async (args: string[]): Promise<string> => {
|
|
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 undefined;
|
|
}
|
|
|
|
async function buildGitEnv(): Promise<NodeJS.ProcessEnv> {
|
|
const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) {
|
|
const resolved = await resolveSshAuthSock();
|
|
if (resolved) {
|
|
env.SSH_AUTH_SOCK = resolved;
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Initialize the git extension API
|
|
*/
|
|
export async function initGitExtension(): Promise<GitAPI | null> {
|
|
if (gitApi && gitExtensionEnabled) {
|
|
return gitApi;
|
|
}
|
|
|
|
try {
|
|
const gitExtension = vscode.extensions.getExtension<GitExtension>('vscode.git');
|
|
if (!gitExtension) {
|
|
console.warn('[GitService] Git extension not found');
|
|
return null;
|
|
}
|
|
|
|
if (!gitExtension.isActive) {
|
|
await gitExtension.activate();
|
|
}
|
|
|
|
const extension = gitExtension.exports;
|
|
if (!extension.enabled) {
|
|
console.warn('[GitService] Git extension is disabled');
|
|
return null;
|
|
}
|
|
|
|
gitApi = extension.getAPI(1);
|
|
gitExtensionEnabled = true;
|
|
|
|
// Listen for enablement changes
|
|
extension.onDidChangeEnablement((enabled) => {
|
|
gitExtensionEnabled = enabled;
|
|
if (!enabled) {
|
|
gitApi = null;
|
|
}
|
|
});
|
|
|
|
return gitApi;
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to initialize git extension:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the git API, initializing if necessary
|
|
*/
|
|
export async function getGitApi(): Promise<GitAPI | null> {
|
|
if (gitApi && gitExtensionEnabled) {
|
|
return gitApi;
|
|
}
|
|
return initGitExtension();
|
|
}
|
|
|
|
/**
|
|
* Get repository for a given directory
|
|
*/
|
|
export async function getRepository(directory: string): Promise<Repository | null> {
|
|
const api = await getGitApi();
|
|
if (!api) return null;
|
|
|
|
const normalizedDir = normalizePath(directory);
|
|
const uri = vscode.Uri.file(normalizedDir);
|
|
|
|
// Try to find an existing repository
|
|
let repo = api.getRepository(uri);
|
|
if (repo) return repo;
|
|
|
|
// Try to open the repository
|
|
repo = await api.openRepository(uri);
|
|
return repo;
|
|
}
|
|
|
|
/**
|
|
* Normalize a file path for cross-platform compatibility
|
|
*/
|
|
function normalizePath(p: string): string {
|
|
let normalized = p;
|
|
// Handle tilde expansion first (before converting slashes)
|
|
if (normalized.startsWith('~')) {
|
|
normalized = path.join(os.homedir(), normalized.slice(1));
|
|
}
|
|
// Convert backslashes to forward slashes for consistent path handling
|
|
normalized = normalized.replace(/\\/g, '/');
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Execute a raw git command and return the output
|
|
*/
|
|
async function execGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
return new Promise((resolve) => {
|
|
const normalizedCwd = normalizePath(cwd);
|
|
const gitPath = gitApi?.git.path || 'git';
|
|
|
|
buildGitEnv().then((env) => {
|
|
const proc = spawn(gitPath, args, {
|
|
cwd: normalizedCwd,
|
|
env,
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
proc.stderr?.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
proc.on('close', (exitCode) => {
|
|
resolve({ stdout, stderr, exitCode: exitCode ?? 0 });
|
|
});
|
|
|
|
proc.on('error', (error) => {
|
|
resolve({ stdout: '', stderr: error.message, exitCode: 1 });
|
|
});
|
|
}).catch((error) => {
|
|
resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 });
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============== Repository Operations ==============
|
|
|
|
/**
|
|
* Check if a directory is a git repository
|
|
*/
|
|
export async function checkIsGitRepository(directory: string): Promise<boolean> {
|
|
const result = await execGit(['rev-parse', '--is-inside-work-tree'], directory);
|
|
return result.exitCode === 0 && result.stdout.trim() === 'true';
|
|
}
|
|
|
|
/**
|
|
* Check if a directory is a linked worktree (not the main worktree)
|
|
*/
|
|
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
|
const gitDir = await execGit(['rev-parse', '--git-dir'], directory);
|
|
const commonDir = await execGit(['rev-parse', '--git-common-dir'], directory);
|
|
|
|
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) {
|
|
return false;
|
|
}
|
|
|
|
const gitDirPath = path.resolve(directory, gitDir.stdout.trim());
|
|
const commonDirPath = path.resolve(directory, commonDir.stdout.trim());
|
|
|
|
return gitDirPath !== commonDirPath;
|
|
}
|
|
|
|
// ============== Status Operations ==============
|
|
|
|
export interface GitStatusFile {
|
|
path: string;
|
|
index: string;
|
|
working_dir: string;
|
|
}
|
|
|
|
export interface GitMergeInProgress {
|
|
/** Short SHA of MERGE_HEAD */
|
|
head: string;
|
|
/** First line of MERGE_MSG */
|
|
message: string;
|
|
}
|
|
|
|
export interface GitRebaseInProgress {
|
|
/** Branch name being rebased */
|
|
headName: string;
|
|
/** Short SHA of the onto commit */
|
|
onto: string;
|
|
}
|
|
|
|
export interface GitStatusResult {
|
|
current: string;
|
|
tracking: string | null;
|
|
ahead: number;
|
|
behind: number;
|
|
files: GitStatusFile[];
|
|
isClean: boolean;
|
|
diffStats?: Record<string, { insertions: number; deletions: number }>;
|
|
/** Present when a merge is in progress with conflicts */
|
|
mergeInProgress?: GitMergeInProgress | null;
|
|
/** Present when a rebase is in progress */
|
|
rebaseInProgress?: GitRebaseInProgress | null;
|
|
}
|
|
|
|
/**
|
|
* Map VS Code git status to our status codes
|
|
*/
|
|
function mapStatus(status: Status): string {
|
|
// Status enum values
|
|
const statusMap: Record<number, string> = {
|
|
0: 'M', // INDEX_MODIFIED
|
|
1: 'A', // INDEX_ADDED
|
|
2: 'D', // INDEX_DELETED
|
|
3: 'R', // INDEX_RENAMED
|
|
4: 'C', // INDEX_COPIED
|
|
5: 'M', // MODIFIED
|
|
6: 'D', // DELETED
|
|
7: '?', // UNTRACKED
|
|
8: '!', // IGNORED
|
|
9: 'A', // INTENT_TO_ADD
|
|
10: 'R', // INTENT_TO_RENAME
|
|
11: 'T', // TYPE_CHANGED
|
|
12: 'U', // ADDED_BY_US
|
|
13: 'U', // ADDED_BY_THEM
|
|
14: 'U', // DELETED_BY_US
|
|
15: 'U', // DELETED_BY_THEM
|
|
16: 'U', // BOTH_ADDED
|
|
17: 'U', // BOTH_DELETED
|
|
18: 'U', // BOTH_MODIFIED
|
|
};
|
|
return statusMap[status] || ' ';
|
|
}
|
|
|
|
/**
|
|
* Get git status for a directory
|
|
*/
|
|
export async function getGitStatus(directory: string): Promise<GitStatusResult> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (!repo) {
|
|
// Fallback to raw git
|
|
return getGitStatusRaw(directory);
|
|
}
|
|
|
|
const state = repo.state;
|
|
const head = state.HEAD;
|
|
|
|
const files: GitStatusFile[] = [];
|
|
|
|
// Process index changes (staged)
|
|
for (const change of state.indexChanges) {
|
|
const relativePath = vscode.workspace.asRelativePath(change.uri, false);
|
|
files.push({
|
|
path: relativePath,
|
|
index: mapStatus(change.status),
|
|
working_dir: ' ',
|
|
});
|
|
}
|
|
|
|
// Process working tree changes (unstaged)
|
|
for (const change of state.workingTreeChanges) {
|
|
const relativePath = vscode.workspace.asRelativePath(change.uri, false);
|
|
const existing = files.find(f => f.path === relativePath);
|
|
if (existing) {
|
|
existing.working_dir = mapStatus(change.status);
|
|
} else {
|
|
files.push({
|
|
path: relativePath,
|
|
index: ' ',
|
|
working_dir: mapStatus(change.status),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for in-progress operations
|
|
const inProgressState = await checkInProgressOperations(directory);
|
|
|
|
return {
|
|
current: head?.name || '',
|
|
tracking: head?.upstream ? `${head.upstream.remote}/${head.upstream.name}` : null,
|
|
ahead: head?.ahead || 0,
|
|
behind: head?.behind || 0,
|
|
files,
|
|
isClean: files.length === 0,
|
|
...inProgressState,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check for in-progress merge/rebase operations
|
|
*/
|
|
async function checkInProgressOperations(directory: string): Promise<{
|
|
mergeInProgress?: GitMergeInProgress | null;
|
|
rebaseInProgress?: GitRebaseInProgress | null;
|
|
}> {
|
|
const result: {
|
|
mergeInProgress?: GitMergeInProgress | null;
|
|
rebaseInProgress?: GitRebaseInProgress | null;
|
|
} = {};
|
|
|
|
const gitDir = path.join(directory, '.git');
|
|
|
|
try {
|
|
// Check MERGE_HEAD for merge in progress
|
|
const mergeHeadPath = path.join(gitDir, 'MERGE_HEAD');
|
|
const mergeHeadExists = await fs.promises.stat(mergeHeadPath).then(() => true).catch(() => false);
|
|
|
|
if (mergeHeadExists) {
|
|
const mergeHead = await fs.promises.readFile(mergeHeadPath, 'utf8').catch(() => '');
|
|
const headSha = mergeHead.trim().slice(0, 7);
|
|
// Only set mergeInProgress if we actually have a valid head SHA
|
|
if (headSha) {
|
|
const mergeMsg = await fs.promises.readFile(path.join(gitDir, 'MERGE_MSG'), 'utf8').catch(() => '');
|
|
result.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 fs.promises.stat(path.join(gitDir, 'rebase-merge')).then(() => true).catch(() => false);
|
|
const rebaseApplyExists = await fs.promises.stat(path.join(gitDir, 'rebase-apply')).then(() => true).catch(() => false);
|
|
|
|
if (rebaseMergeExists || rebaseApplyExists) {
|
|
const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply';
|
|
const headName = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'head-name'), 'utf8').catch(() => '');
|
|
const onto = await fs.promises.readFile(path.join(gitDir, 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) {
|
|
result.rebaseInProgress = {
|
|
headName: headNameTrimmed,
|
|
onto: ontoTrimmed,
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Fallback: Get git status using raw git commands
|
|
*/
|
|
async function getGitStatusRaw(directory: string): Promise<GitStatusResult> {
|
|
const statusResult = await execGit(['status', '--porcelain=v1', '-b', '-uall'], directory);
|
|
|
|
if (statusResult.exitCode !== 0) {
|
|
return {
|
|
current: '',
|
|
tracking: null,
|
|
ahead: 0,
|
|
behind: 0,
|
|
files: [],
|
|
isClean: true,
|
|
};
|
|
}
|
|
|
|
const lines = statusResult.stdout.trim().split('\n').filter(Boolean);
|
|
const files: GitStatusFile[] = [];
|
|
let current = '';
|
|
let tracking: string | null = null;
|
|
let ahead = 0;
|
|
let behind = 0;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('##')) {
|
|
// Parse branch info
|
|
const branchMatch = line.match(/^## (.+?)(?:\.\.\.(.+?))?(?:\s+\[(.+)\])?$/);
|
|
if (branchMatch) {
|
|
current = branchMatch[1] || '';
|
|
tracking = branchMatch[2] || null;
|
|
const trackingInfo = branchMatch[3] || '';
|
|
const aheadMatch = trackingInfo.match(/ahead (\d+)/);
|
|
const behindMatch = trackingInfo.match(/behind (\d+)/);
|
|
ahead = aheadMatch ? parseInt(aheadMatch[1], 10) : 0;
|
|
behind = behindMatch ? parseInt(behindMatch[1], 10) : 0;
|
|
}
|
|
} else {
|
|
// Parse file status
|
|
const index = line[0] || ' ';
|
|
const workingDir = line[1] || ' ';
|
|
const filePath = line.slice(3).trim();
|
|
files.push({
|
|
path: filePath,
|
|
index,
|
|
working_dir: workingDir,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for in-progress operations
|
|
const inProgressState = await checkInProgressOperations(directory);
|
|
|
|
return {
|
|
current,
|
|
tracking,
|
|
ahead,
|
|
behind,
|
|
files,
|
|
isClean: files.length === 0,
|
|
...inProgressState,
|
|
};
|
|
}
|
|
|
|
// ============== Branch Operations ==============
|
|
|
|
export interface GitBranchDetails {
|
|
current: boolean;
|
|
name: string;
|
|
commit: string;
|
|
label: string;
|
|
tracking?: string;
|
|
ahead?: number;
|
|
behind?: number;
|
|
}
|
|
|
|
export interface GitBranchResult {
|
|
all: string[];
|
|
current: string;
|
|
branches: Record<string, GitBranchDetails>;
|
|
}
|
|
|
|
/**
|
|
* Get all branches for a directory
|
|
*/
|
|
export async function getGitBranches(directory: string): Promise<GitBranchResult> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (!repo) {
|
|
return getGitBranchesRaw(directory);
|
|
}
|
|
|
|
const state = repo.state;
|
|
const currentBranch = state.HEAD?.name || '';
|
|
const branches: Record<string, GitBranchDetails> = {};
|
|
const all: string[] = [];
|
|
|
|
// Get local branches
|
|
const localRefs = await repo.getBranches({ remote: false });
|
|
for (const ref of localRefs) {
|
|
if (ref.name) {
|
|
all.push(ref.name);
|
|
branches[ref.name] = {
|
|
current: ref.name === currentBranch,
|
|
name: ref.name,
|
|
commit: ref.commit || '',
|
|
label: ref.name,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Get remote branches
|
|
const remoteRefs = await repo.getBranches({ remote: true });
|
|
for (const ref of remoteRefs) {
|
|
if (ref.name) {
|
|
const remoteBranchName = `remotes/${ref.name}`;
|
|
all.push(remoteBranchName);
|
|
branches[remoteBranchName] = {
|
|
current: false,
|
|
name: remoteBranchName,
|
|
commit: ref.commit || '',
|
|
label: ref.name,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Add upstream info for HEAD
|
|
if (state.HEAD?.name && state.HEAD?.upstream) {
|
|
const branchInfo = branches[state.HEAD.name];
|
|
if (branchInfo) {
|
|
branchInfo.tracking = `${state.HEAD.upstream.remote}/${state.HEAD.upstream.name}`;
|
|
branchInfo.ahead = state.HEAD.ahead;
|
|
branchInfo.behind = state.HEAD.behind;
|
|
}
|
|
}
|
|
|
|
return { all, current: currentBranch, branches };
|
|
}
|
|
|
|
/**
|
|
* Fallback: Get branches using raw git commands
|
|
*/
|
|
async function getGitBranchesRaw(directory: string): Promise<GitBranchResult> {
|
|
const result = await execGit(['branch', '-a', '-v', '--format=%(refname:short)|%(objectname:short)|%(upstream:short)|%(HEAD)'], directory);
|
|
|
|
if (result.exitCode !== 0) {
|
|
return { all: [], current: '', branches: {} };
|
|
}
|
|
|
|
const lines = result.stdout.trim().split('\n').filter(Boolean);
|
|
const branches: Record<string, GitBranchDetails> = {};
|
|
const all: string[] = [];
|
|
let current = '';
|
|
|
|
for (const line of lines) {
|
|
const [name, commit, tracking, head] = line.split('|');
|
|
if (name) {
|
|
all.push(name);
|
|
const isCurrent = head === '*';
|
|
if (isCurrent) current = name;
|
|
|
|
branches[name] = {
|
|
current: isCurrent,
|
|
name,
|
|
commit: commit || '',
|
|
label: name.replace(/^remotes\//, ''),
|
|
tracking: tracking || undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { all, current, branches };
|
|
}
|
|
|
|
/**
|
|
* Checkout a branch
|
|
*/
|
|
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
await repo.checkout(branch);
|
|
return { success: true, branch };
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to checkout branch:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
const result = await execGit(['checkout', branch], directory);
|
|
return { success: result.exitCode === 0, branch };
|
|
}
|
|
|
|
/**
|
|
* Detach HEAD at current commit
|
|
* This allows the current branch to be used in a worktree
|
|
*/
|
|
export async function detachHead(directory: string): Promise<{ success: boolean; commit: string }> {
|
|
// Get current HEAD commit
|
|
const headResult = await execGit(['rev-parse', 'HEAD'], directory);
|
|
if (headResult.exitCode !== 0) {
|
|
return { success: false, commit: '' };
|
|
}
|
|
|
|
const commit = headResult.stdout.trim();
|
|
|
|
// Checkout the commit directly to detach HEAD
|
|
const result = await execGit(['checkout', '--detach', 'HEAD'], directory);
|
|
return { success: result.exitCode === 0, commit };
|
|
}
|
|
|
|
/**
|
|
* Get the current HEAD branch name (null if detached)
|
|
*/
|
|
export async function getCurrentBranch(directory: string): Promise<string | null> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
const head = repo.state.HEAD;
|
|
return head?.name || null;
|
|
}
|
|
|
|
// Fallback to raw git
|
|
const result = await execGit(['symbolic-ref', '--short', 'HEAD'], directory);
|
|
if (result.exitCode === 0) {
|
|
return result.stdout.trim();
|
|
}
|
|
return null; // Detached HEAD
|
|
}
|
|
|
|
/**
|
|
* Create a new branch
|
|
*/
|
|
export async function createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
await repo.createBranch(name, false, startPoint);
|
|
return { success: true, branch: name };
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to create branch:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
const args = ['branch', name];
|
|
if (startPoint) args.push(startPoint);
|
|
const result = await execGit(args, directory);
|
|
return { success: result.exitCode === 0, branch: name };
|
|
}
|
|
|
|
/**
|
|
* Delete a local branch
|
|
*/
|
|
export async function deleteGitBranch(directory: string, branch: string, force = false): Promise<{ success: boolean }> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
await repo.deleteBranch(branch, force);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to delete branch:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
const flag = force ? '-D' : '-d';
|
|
const result = await execGit(['branch', flag, branch], directory);
|
|
return { success: result.exitCode === 0 };
|
|
}
|
|
|
|
/**
|
|
* Delete a remote branch
|
|
*/
|
|
export async function deleteRemoteBranch(directory: string, branch: string, remote = 'origin'): Promise<{ success: boolean }> {
|
|
const result = await execGit(['push', remote, '--delete', branch], directory);
|
|
return { success: result.exitCode === 0 };
|
|
}
|
|
|
|
// ============== Worktree Operations ==============
|
|
|
|
export interface GitWorktreeInfo {
|
|
worktree: string;
|
|
head?: string;
|
|
branch?: string;
|
|
}
|
|
|
|
/**
|
|
* List all worktrees for a repository
|
|
*/
|
|
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
|
const result = await execGit(['worktree', 'list', '--porcelain'], directory);
|
|
|
|
if (result.exitCode !== 0) {
|
|
return [];
|
|
}
|
|
|
|
const worktrees: GitWorktreeInfo[] = [];
|
|
let current: Partial<GitWorktreeInfo> = {};
|
|
|
|
for (const line of result.stdout.split('\n')) {
|
|
if (line.startsWith('worktree ')) {
|
|
if (current.worktree) {
|
|
worktrees.push(current as GitWorktreeInfo);
|
|
}
|
|
current = { worktree: line.slice(9).trim() };
|
|
} else if (line.startsWith('HEAD ')) {
|
|
current.head = line.slice(5).trim();
|
|
} else if (line.startsWith('branch ')) {
|
|
current.branch = line.slice(7).trim();
|
|
} else if (line === '' && current.worktree) {
|
|
worktrees.push(current as GitWorktreeInfo);
|
|
current = {};
|
|
}
|
|
}
|
|
|
|
if (current.worktree) {
|
|
worktrees.push(current as GitWorktreeInfo);
|
|
}
|
|
|
|
return worktrees;
|
|
}
|
|
|
|
/**
|
|
* Get branches that are available for worktree checkout
|
|
* (branches not already checked out in any worktree)
|
|
*/
|
|
export async function getAvailableBranchesForWorktree(directory: string): Promise<GitBranchDetails[]> {
|
|
const [branches, worktrees] = await Promise.all([
|
|
getGitBranches(directory),
|
|
listGitWorktrees(directory),
|
|
]);
|
|
|
|
// Get set of branches already checked out in worktrees
|
|
const checkedOutBranches = new Set<string>();
|
|
for (const wt of worktrees) {
|
|
if (wt.branch) {
|
|
// Normalize branch name (remove refs/heads/ prefix)
|
|
const branchName = wt.branch.replace(/^refs\/heads\//, '');
|
|
checkedOutBranches.add(branchName);
|
|
}
|
|
}
|
|
|
|
// Filter out branches that are already checked out
|
|
const availableBranches: GitBranchDetails[] = [];
|
|
for (const name of branches.all) {
|
|
// Skip remote branches for worktree creation
|
|
if (name.startsWith('remotes/')) {
|
|
continue;
|
|
}
|
|
|
|
if (!checkedOutBranches.has(name)) {
|
|
const details = branches.branches[name];
|
|
if (details) {
|
|
availableBranches.push(details);
|
|
}
|
|
}
|
|
}
|
|
|
|
return availableBranches;
|
|
}
|
|
|
|
// ============== Diff Operations ==============
|
|
|
|
/**
|
|
* Get diff for a file
|
|
*/
|
|
export async function getGitDiff(
|
|
directory: string,
|
|
filePath: string,
|
|
staged = false,
|
|
contextLines?: number
|
|
): Promise<{ diff: string }> {
|
|
const args = ['diff'];
|
|
if (staged) args.push('--cached');
|
|
if (typeof contextLines === 'number') args.push(`-U${contextLines}`);
|
|
args.push('--', filePath);
|
|
|
|
const result = await execGit(args, directory);
|
|
return { diff: result.stdout };
|
|
}
|
|
|
|
/**
|
|
* Get diff between two refs for a file (base...head).
|
|
*/
|
|
export async function getGitRangeDiff(
|
|
directory: string,
|
|
base: string,
|
|
head: string,
|
|
filePath: string,
|
|
contextLines = 3
|
|
): Promise<{ diff: string }> {
|
|
const baseRef = (base || '').trim();
|
|
const headRef = (head || '').trim();
|
|
if (!baseRef || !headRef) {
|
|
return { diff: '' };
|
|
}
|
|
|
|
let resolvedBase = baseRef;
|
|
try {
|
|
const verify = await execGit(['rev-parse', '--verify', `refs/remotes/origin/${baseRef}`], directory);
|
|
if (verify.exitCode === 0) {
|
|
resolvedBase = `origin/${baseRef}`;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const args = ['diff', '--no-color', `-U${Math.max(0, contextLines)}`, `${resolvedBase}...${headRef}`, '--', filePath];
|
|
const result = await execGit(args, directory);
|
|
return { diff: result.stdout };
|
|
}
|
|
|
|
/**
|
|
* List files changed between two refs (base...head).
|
|
*/
|
|
export async function getGitRangeFiles(
|
|
directory: string,
|
|
base: string,
|
|
head: string
|
|
): Promise<string[]> {
|
|
const baseRef = (base || '').trim();
|
|
const headRef = (head || '').trim();
|
|
if (!baseRef || !headRef) {
|
|
return [];
|
|
}
|
|
|
|
let resolvedBase = baseRef;
|
|
try {
|
|
const verify = await execGit(['rev-parse', '--verify', `refs/remotes/origin/${baseRef}`], directory);
|
|
if (verify.exitCode === 0) {
|
|
resolvedBase = `origin/${baseRef}`;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const args = ['diff', '--name-only', `${resolvedBase}...${headRef}`];
|
|
const result = await execGit(args, directory);
|
|
if (result.exitCode !== 0) return [];
|
|
return String(result.stdout || '')
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Get file diff with original and modified content
|
|
*/
|
|
export async function getGitFileDiff(
|
|
directory: string,
|
|
filePath: string,
|
|
staged = false
|
|
): Promise<{ original: string; modified: string; path: string }> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
// For staged files, get content from HEAD
|
|
// For unstaged files, get content from index (staged) or HEAD
|
|
let original: string;
|
|
if (staged) {
|
|
original = await repo.show('HEAD', filePath);
|
|
} else {
|
|
try {
|
|
// Try to get from index first
|
|
original = await repo.show(':0:' + filePath, filePath);
|
|
} catch {
|
|
// Fall back to HEAD
|
|
original = await repo.show('HEAD', filePath);
|
|
}
|
|
}
|
|
|
|
// Read the current file content
|
|
const fileUri = vscode.Uri.file(path.join(directory, filePath));
|
|
const modifiedBytes = await vscode.workspace.fs.readFile(fileUri);
|
|
const modified = Buffer.from(modifiedBytes).toString('utf8');
|
|
|
|
return { original, modified, path: filePath };
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to get file diff:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback: return empty content
|
|
return { original: '', modified: '', path: filePath };
|
|
}
|
|
|
|
/**
|
|
* Revert a file to its last committed state
|
|
*/
|
|
export async function revertGitFile(directory: string, filePath: string): Promise<void> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
await repo.revert([filePath]);
|
|
return;
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to revert via API:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
await execGit(['checkout', '--', filePath], directory);
|
|
}
|
|
|
|
// ============== Commit Operations ==============
|
|
|
|
export interface GitCommitResult {
|
|
success: boolean;
|
|
commit: string;
|
|
branch: string;
|
|
summary: {
|
|
changes: number;
|
|
insertions: number;
|
|
deletions: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a git commit
|
|
*/
|
|
export async function createGitCommit(
|
|
directory: string,
|
|
message: string,
|
|
options?: { addAll?: boolean; files?: string[] }
|
|
): Promise<GitCommitResult> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
if (options?.addAll) {
|
|
await repo.add(['.']);
|
|
} else if (options?.files?.length) {
|
|
await repo.add(options.files);
|
|
}
|
|
|
|
await repo.commit(message);
|
|
|
|
const head = repo.state.HEAD;
|
|
return {
|
|
success: true,
|
|
commit: head?.commit || '',
|
|
branch: head?.name || '',
|
|
summary: { changes: 0, insertions: 0, deletions: 0 },
|
|
};
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to commit:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
if (options?.addAll) {
|
|
await execGit(['add', '-A'], directory);
|
|
} else if (options?.files?.length) {
|
|
await execGit(['add', ...options.files], directory);
|
|
}
|
|
|
|
const result = await execGit(['commit', '-m', message], directory);
|
|
|
|
if (result.exitCode !== 0) {
|
|
return {
|
|
success: false,
|
|
commit: '',
|
|
branch: '',
|
|
summary: { changes: 0, insertions: 0, deletions: 0 },
|
|
};
|
|
}
|
|
|
|
// Get commit info
|
|
const hashResult = await execGit(['rev-parse', 'HEAD'], directory);
|
|
const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory);
|
|
|
|
return {
|
|
success: true,
|
|
commit: hashResult.stdout.trim(),
|
|
branch: branchResult.stdout.trim(),
|
|
summary: { changes: 0, insertions: 0, deletions: 0 },
|
|
};
|
|
}
|
|
|
|
// ============== Remote Operations ==============
|
|
|
|
/**
|
|
* Convert options to an array of git arguments.
|
|
* Supports both array format ['--set-upstream', '--force'] and
|
|
* object format { '--set-upstream': null, '--force': true }
|
|
*/
|
|
function normalizeGitOptions(options?: string[] | Record<string, unknown>): string[] {
|
|
if (!options) return [];
|
|
|
|
if (Array.isArray(options)) {
|
|
return options;
|
|
}
|
|
|
|
// Object format: { '--set-upstream': null, '--force': true, '--remote': 'origin' }
|
|
const args: string[] = [];
|
|
for (const [key, value] of Object.entries(options)) {
|
|
if (value === null || value === true) {
|
|
args.push(key);
|
|
} else if (value !== false && value !== undefined) {
|
|
args.push(key, String(value));
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* Check if options contain a specific flag
|
|
*/
|
|
function hasOption(options: string[] | Record<string, unknown> | undefined, flag: string): boolean {
|
|
if (!options) return false;
|
|
|
|
if (Array.isArray(options)) {
|
|
return options.includes(flag);
|
|
}
|
|
|
|
return flag in options && options[flag] !== false;
|
|
}
|
|
|
|
/**
|
|
* Push to remote
|
|
*/
|
|
export async function gitPush(
|
|
directory: string,
|
|
options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }
|
|
): Promise<{ success: boolean; pushed: Array<{ local: string; remote: string }>; repo: string; ref: unknown }> {
|
|
const repo = await getRepository(directory);
|
|
const remote = options?.remote || 'origin';
|
|
const branch = options?.branch;
|
|
const gitOptions = options?.options;
|
|
|
|
// Determine if we should set upstream (default true if no options specified)
|
|
const setUpstream = gitOptions
|
|
? hasOption(gitOptions, '--set-upstream') || hasOption(gitOptions, '-u')
|
|
: true;
|
|
|
|
if (repo) {
|
|
try {
|
|
await repo.push(remote, branch, setUpstream);
|
|
|
|
return {
|
|
success: true,
|
|
pushed: [{ local: branch || '', remote }],
|
|
repo: directory,
|
|
ref: null,
|
|
};
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to push via VS Code API:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git - use full options here
|
|
const args = ['push'];
|
|
|
|
// Add normalized options
|
|
const normalizedOptions = normalizeGitOptions(gitOptions);
|
|
|
|
// If no options provided, default to -u for upstream
|
|
if (normalizedOptions.length === 0) {
|
|
args.push('-u');
|
|
} else {
|
|
args.push(...normalizedOptions);
|
|
}
|
|
|
|
// Add remote and branch
|
|
args.push(remote);
|
|
if (branch) args.push(branch);
|
|
|
|
const result = await execGit(args, directory);
|
|
|
|
return {
|
|
success: result.exitCode === 0,
|
|
pushed: result.exitCode === 0 ? [{ local: branch || '', remote }] : [],
|
|
repo: directory,
|
|
ref: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pull from remote
|
|
*/
|
|
export async function gitPull(
|
|
directory: string,
|
|
options?: { remote?: string; branch?: string }
|
|
): Promise<{ success: boolean; summary: { changes: number; insertions: number; deletions: number }; files: string[]; insertions: number; deletions: number }> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
await repo.pull();
|
|
return {
|
|
success: true,
|
|
summary: { changes: 0, insertions: 0, deletions: 0 },
|
|
files: [],
|
|
insertions: 0,
|
|
deletions: 0,
|
|
};
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to pull:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
const args = ['pull'];
|
|
if (options?.remote) args.push(options.remote);
|
|
if (options?.branch) args.push(options.branch);
|
|
|
|
const result = await execGit(args, directory);
|
|
|
|
return {
|
|
success: result.exitCode === 0,
|
|
summary: { changes: 0, insertions: 0, deletions: 0 },
|
|
files: [],
|
|
insertions: 0,
|
|
deletions: 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch from remote
|
|
*/
|
|
export async function gitFetch(
|
|
directory: string,
|
|
options?: { remote?: string; branch?: string }
|
|
): Promise<{ success: boolean }> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
await repo.fetch({ remote: options?.remote, ref: options?.branch });
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to fetch:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
const args = ['fetch'];
|
|
if (options?.remote) args.push(options.remote);
|
|
if (options?.branch) args.push(options.branch);
|
|
|
|
const result = await execGit(args, directory);
|
|
return { success: result.exitCode === 0 };
|
|
}
|
|
|
|
// ============== Log Operations ==============
|
|
|
|
export interface GitLogEntry {
|
|
hash: string;
|
|
date: string;
|
|
message: string;
|
|
refs: string;
|
|
body: string;
|
|
author_name: string;
|
|
author_email: string;
|
|
filesChanged: number;
|
|
insertions: number;
|
|
deletions: number;
|
|
}
|
|
|
|
/**
|
|
* Get git log
|
|
*/
|
|
export async function getGitLog(
|
|
directory: string,
|
|
options?: { maxCount?: number; from?: string; to?: string; file?: string }
|
|
): Promise<{ all: GitLogEntry[]; latest: GitLogEntry | null; total: number }> {
|
|
const maxCount = options?.maxCount || 50;
|
|
const args = [
|
|
'log',
|
|
`--max-count=${maxCount}`,
|
|
'--format=%H|%aI|%s|%D|%b|%an|%ae',
|
|
'--shortstat',
|
|
];
|
|
|
|
if (options?.from && options?.to) {
|
|
args.push(`${options.from}..${options.to}`);
|
|
}
|
|
|
|
if (options?.file) {
|
|
args.push('--', options.file);
|
|
}
|
|
|
|
const result = await execGit(args, directory);
|
|
|
|
if (result.exitCode !== 0) {
|
|
return { all: [], latest: null, total: 0 };
|
|
}
|
|
|
|
const entries: GitLogEntry[] = [];
|
|
const lines = result.stdout.split('\n');
|
|
let current: Partial<GitLogEntry> | null = null;
|
|
|
|
for (const line of lines) {
|
|
if (line.includes('|') && !line.startsWith(' ')) {
|
|
if (current?.hash) {
|
|
entries.push(current as GitLogEntry);
|
|
}
|
|
const parts = line.split('|');
|
|
current = {
|
|
hash: parts[0] || '',
|
|
date: parts[1] || '',
|
|
message: parts[2] || '',
|
|
refs: parts[3] || '',
|
|
body: parts[4] || '',
|
|
author_name: parts[5] || '',
|
|
author_email: parts[6] || '',
|
|
filesChanged: 0,
|
|
insertions: 0,
|
|
deletions: 0,
|
|
};
|
|
} else if (current && line.includes('file')) {
|
|
const statsMatch = line.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?)?(?:,\s+(\d+)\s+deletions?)?/);
|
|
if (statsMatch) {
|
|
current.filesChanged = parseInt(statsMatch[1] || '0', 10);
|
|
current.insertions = parseInt(statsMatch[2] || '0', 10);
|
|
current.deletions = parseInt(statsMatch[3] || '0', 10);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (current?.hash) {
|
|
entries.push(current as GitLogEntry);
|
|
}
|
|
|
|
return {
|
|
all: entries,
|
|
latest: entries[0] || null,
|
|
total: entries.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get files changed in a commit
|
|
*/
|
|
export async function getCommitFiles(
|
|
directory: string,
|
|
hash: string
|
|
): Promise<{ files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> }> {
|
|
const result = await execGit(['show', '--numstat', '--format=', hash], directory);
|
|
|
|
if (result.exitCode !== 0) {
|
|
return { files: [] };
|
|
}
|
|
|
|
const files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> = [];
|
|
|
|
for (const line of result.stdout.trim().split('\n').filter(Boolean)) {
|
|
const parts = line.split('\t');
|
|
if (parts.length >= 3) {
|
|
const isBinary = parts[0] === '-' && parts[1] === '-';
|
|
files.push({
|
|
path: parts[2] || '',
|
|
insertions: isBinary ? 0 : parseInt(parts[0] || '0', 10),
|
|
deletions: isBinary ? 0 : parseInt(parts[1] || '0', 10),
|
|
isBinary,
|
|
changeType: 'M', // Would need additional parsing for actual change type
|
|
});
|
|
}
|
|
}
|
|
|
|
return { files };
|
|
}
|
|
|
|
// ============== Git Identity Operations ==============
|
|
|
|
export interface GitIdentitySummary {
|
|
userName: string | null;
|
|
userEmail: string | null;
|
|
sshCommand: string | null;
|
|
}
|
|
|
|
/**
|
|
* Get current git identity for a directory
|
|
*/
|
|
export async function getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
|
|
const repo = await getRepository(directory);
|
|
|
|
if (repo) {
|
|
try {
|
|
const userName = await repo.getConfig('user.name').catch(() => '');
|
|
const userEmail = await repo.getConfig('user.email').catch(() => '');
|
|
const sshCommand = await repo.getConfig('core.sshCommand').catch(() => '');
|
|
|
|
return {
|
|
userName: userName || null,
|
|
userEmail: userEmail || null,
|
|
sshCommand: sshCommand || null,
|
|
};
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to get identity:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
const nameResult = await execGit(['config', 'user.name'], directory);
|
|
const emailResult = await execGit(['config', 'user.email'], directory);
|
|
const sshResult = await execGit(['config', 'core.sshCommand'], directory);
|
|
|
|
return {
|
|
userName: nameResult.exitCode === 0 ? nameResult.stdout.trim() : null,
|
|
userEmail: emailResult.exitCode === 0 ? emailResult.stdout.trim() : null,
|
|
sshCommand: sshResult.exitCode === 0 ? sshResult.stdout.trim() : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Escape an SSH key path for use in core.sshCommand.
|
|
* Handles Windows/Unix differences and prevents command injection.
|
|
*/
|
|
function escapeSshKeyPath(sshKeyPath: string): string {
|
|
// Validate: reject paths with characters that could enable injection
|
|
// Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives)
|
|
const dangerousChars = /[`$\\!"';&|<>(){}[\]*?#~]/;
|
|
if (dangerousChars.test(sshKeyPath)) {
|
|
throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`);
|
|
}
|
|
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
if (isWindows) {
|
|
// On Windows, Git (via MSYS/MinGW) expects Unix-style paths
|
|
// Convert backslashes to forward slashes and handle drive letters
|
|
let unixPath = sshKeyPath.replace(/\\/g, '/');
|
|
|
|
// Convert "C:/path" to "/c/path" for MSYS compatibility
|
|
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 = sshKeyPath.replace(/'/g, "'\\''");
|
|
return `'${escaped}'`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the SSH command string for git config
|
|
*/
|
|
function buildSshCommand(sshKeyPath: string): string {
|
|
const escapedPath = escapeSshKeyPath(sshKeyPath);
|
|
return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`;
|
|
}
|
|
|
|
/**
|
|
* Set git identity for a directory
|
|
*/
|
|
export async function setGitIdentity(
|
|
directory: string,
|
|
userName: string,
|
|
userEmail: string,
|
|
sshKey?: string | null
|
|
): Promise<{ success: boolean }> {
|
|
const repo = await getRepository(directory);
|
|
|
|
// Build SSH command once if needed
|
|
const sshCommand = sshKey ? buildSshCommand(sshKey) : null;
|
|
|
|
if (repo) {
|
|
try {
|
|
await repo.setConfig('user.name', userName);
|
|
await repo.setConfig('user.email', userEmail);
|
|
if (sshCommand) {
|
|
await repo.setConfig('core.sshCommand', sshCommand);
|
|
}
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('[GitService] Failed to set identity:', error);
|
|
}
|
|
}
|
|
|
|
// Fallback to raw git
|
|
await execGit(['config', 'user.name', userName], directory);
|
|
await execGit(['config', 'user.email', userEmail], directory);
|
|
if (sshCommand) {
|
|
await execGit(['config', 'core.sshCommand', sshCommand], directory);
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// ============== Remote Operations ==============
|
|
|
|
export interface GitRemote {
|
|
name: string;
|
|
fetchUrl: string;
|
|
pushUrl: string;
|
|
}
|
|
|
|
/**
|
|
* Get list of remotes
|
|
*/
|
|
export async function getRemotes(directory: string): Promise<GitRemote[]> {
|
|
const result = await execGit(['remote', '-v'], directory);
|
|
if (result.exitCode !== 0) {
|
|
return [];
|
|
}
|
|
|
|
const remoteMap = new Map<string, GitRemote>();
|
|
const lines = result.stdout.split('\n').filter(Boolean);
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
if (match) {
|
|
const [, name, url, type] = match;
|
|
if (!remoteMap.has(name)) {
|
|
remoteMap.set(name, { name, fetchUrl: '', pushUrl: '' });
|
|
}
|
|
const remote = remoteMap.get(name)!;
|
|
if (type === 'fetch') {
|
|
remote.fetchUrl = url;
|
|
} else {
|
|
remote.pushUrl = url;
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(remoteMap.values());
|
|
}
|
|
|
|
// ============== Merge & Rebase Operations ==============
|
|
|
|
export interface GitMergeResult {
|
|
success: boolean;
|
|
conflict?: boolean;
|
|
conflictFiles?: string[];
|
|
}
|
|
|
|
export interface GitRebaseResult {
|
|
success: boolean;
|
|
conflict?: boolean;
|
|
conflictFiles?: string[];
|
|
}
|
|
|
|
/**
|
|
* Rebase current branch onto target
|
|
*/
|
|
export async function rebase(
|
|
directory: string,
|
|
options: { onto: string }
|
|
): Promise<GitRebaseResult> {
|
|
const result = await execGit(['rebase', options.onto], directory);
|
|
|
|
if (result.exitCode === 0) {
|
|
return { success: true, conflict: false };
|
|
}
|
|
|
|
const output = (result.stdout + result.stderr).toLowerCase();
|
|
const isConflict =
|
|
output.includes('conflict') ||
|
|
output.includes('could not apply') ||
|
|
output.includes('merge conflict');
|
|
|
|
if (isConflict) {
|
|
const statusResult = await execGit(['status', '--porcelain'], directory);
|
|
const conflictFiles = statusResult.stdout
|
|
.split('\n')
|
|
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
|
.map((line) => line.slice(3).trim());
|
|
|
|
return { success: false, conflict: true, conflictFiles };
|
|
}
|
|
|
|
throw new Error(result.stderr || 'Rebase failed');
|
|
}
|
|
|
|
/**
|
|
* Abort an in-progress rebase
|
|
*/
|
|
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
|
const result = await execGit(['rebase', '--abort'], directory);
|
|
return { success: result.exitCode === 0 };
|
|
}
|
|
|
|
/**
|
|
* Merge branch into current
|
|
*/
|
|
export async function merge(
|
|
directory: string,
|
|
options: { branch: string }
|
|
): Promise<GitMergeResult> {
|
|
const result = await execGit(['merge', options.branch], directory);
|
|
|
|
if (result.exitCode === 0) {
|
|
return { success: true, conflict: false };
|
|
}
|
|
|
|
const output = (result.stdout + result.stderr).toLowerCase();
|
|
const isConflict =
|
|
output.includes('conflict') ||
|
|
output.includes('merge conflict') ||
|
|
output.includes('automatic merge failed');
|
|
|
|
if (isConflict) {
|
|
const statusResult = await execGit(['status', '--porcelain'], directory);
|
|
const conflictFiles = statusResult.stdout
|
|
.split('\n')
|
|
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
|
.map((line) => line.slice(3).trim());
|
|
|
|
return { success: false, conflict: true, conflictFiles };
|
|
}
|
|
|
|
throw new Error(result.stderr || 'Merge failed');
|
|
}
|
|
|
|
/**
|
|
* Abort an in-progress merge
|
|
*/
|
|
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
|
const result = await execGit(['merge', '--abort'], directory);
|
|
return { success: result.exitCode === 0 };
|
|
}
|
|
|
|
/**
|
|
* Continue an in-progress rebase after conflicts are resolved
|
|
*/
|
|
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
|
const result = await execGit(['rebase', '--continue'], directory);
|
|
|
|
if (result.exitCode === 0) {
|
|
return { success: true, conflict: false };
|
|
}
|
|
|
|
const output = (result.stdout + result.stderr).toLowerCase();
|
|
const isConflict =
|
|
output.includes('conflict') ||
|
|
output.includes('needs merge') ||
|
|
output.includes('unmerged');
|
|
|
|
if (isConflict) {
|
|
const statusResult = await execGit(['status', '--porcelain'], directory);
|
|
const conflictFiles = statusResult.stdout
|
|
.split('\n')
|
|
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
|
.map((line) => line.slice(3).trim());
|
|
|
|
return { success: false, conflict: true, conflictFiles };
|
|
}
|
|
|
|
throw new Error(result.stderr || 'Continue rebase failed');
|
|
}
|
|
|
|
/**
|
|
* Continue an in-progress merge after conflicts are resolved
|
|
*/
|
|
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
|
// For merge, we commit after resolving conflicts
|
|
const result = await execGit(['commit', '--no-edit'], directory);
|
|
|
|
if (result.exitCode === 0) {
|
|
return { success: true, conflict: false };
|
|
}
|
|
|
|
const output = (result.stdout + result.stderr).toLowerCase();
|
|
const isConflict =
|
|
output.includes('conflict') ||
|
|
output.includes('needs merge') ||
|
|
output.includes('unmerged');
|
|
|
|
if (isConflict) {
|
|
const statusResult = await execGit(['status', '--porcelain'], directory);
|
|
const conflictFiles = statusResult.stdout
|
|
.split('\n')
|
|
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
|
.map((line) => line.slice(3).trim());
|
|
|
|
return { success: false, conflict: true, conflictFiles };
|
|
}
|
|
|
|
throw new Error(result.stderr || 'Continue merge failed');
|
|
}
|
|
|
|
// ============== Stash Operations ==============
|
|
|
|
/**
|
|
* Stash changes
|
|
*/
|
|
export async function stash(
|
|
directory: string,
|
|
options?: { message?: string; includeUntracked?: boolean }
|
|
): Promise<{ success: boolean }> {
|
|
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);
|
|
}
|
|
|
|
const result = await execGit(args, directory);
|
|
return { success: result.exitCode === 0 };
|
|
}
|
|
|
|
/**
|
|
* Pop the most recent stash
|
|
*/
|
|
export async function stashPop(directory: string): Promise<{ success: boolean }> {
|
|
const result = await execGit(['stash', 'pop'], directory);
|
|
return { success: result.exitCode === 0 };
|
|
}
|