Files
openchamber/packages/vscode/src/gitService.ts
T
Bohdan Triapitsyn 4e857b7877 fix(vscode): remove unreachable branch checkout behavior
The VS Code app mounts VSCodeLayout, which exposes sessions, chat, and settings but not the shared GitView or its branch selector. The remote-tracking checkout path added for that selector therefore had no user-reachable caller in the extension.

Restore the existing VS Code checkout implementation and remove the VS Code changelog claim. The web runtime keeps the fix because its Git view exposes the branch selector.
2026-08-28 20:08:58 +03:00

3861 lines
122 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;
type WorktreeBootstrapStatus = {
status: 'pending' | 'ready' | 'failed';
phase: 'directory-created' | 'git-ready' | 'setup-ready';
error: string | null;
updatedAt: number;
};
const worktreeBootstrapState = new Map<string, WorktreeBootstrapStatus>();
const activeWorktreeBootstrapTasks = new Map<string, Promise<unknown>>();
const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const;
const WORKTREE_BOOTSTRAP_READY = 'ready' as const;
const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const;
const WORKTREE_PHASE_DIRECTORY_CREATED = 'directory-created' as const;
const WORKTREE_PHASE_GIT_READY = 'git-ready' as const;
const WORKTREE_PHASE_SETUP_READY = 'setup-ready' as const;
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
const GIT_NULL_REF = '0'.repeat(40);
const toBootstrapStateKey = (directory: string): string => {
const normalized = normalizeDirectoryPath(directory);
if (!normalized) {
return '';
}
return path.resolve(normalized);
};
const setWorktreeBootstrapState = (
directory: string,
status: WorktreeBootstrapStatus['status'],
phase: WorktreeBootstrapStatus['phase'],
error: string | null = null,
): WorktreeBootstrapStatus | null => {
const key = toBootstrapStateKey(directory);
if (!key) {
return null;
}
const state: WorktreeBootstrapStatus = {
status,
phase,
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
updatedAt: Date.now(),
};
worktreeBootstrapState.set(key, state);
return state;
};
const setWorktreeBootstrapFailure = (directory: string, error: unknown): void => {
const current = worktreeBootstrapState.get(toBootstrapStateKey(directory));
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
current?.phase ?? WORKTREE_PHASE_DIRECTORY_CREATED,
error instanceof Error ? error.message : String(error),
);
};
const clearWorktreeBootstrapState = (directory: string): void => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
worktreeBootstrapState.delete(key);
};
const trackWorktreeBootstrapTask = (directory: string, task: Promise<unknown>): void => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
activeWorktreeBootstrapTasks.set(key, task);
const clearTask = () => {
if (activeWorktreeBootstrapTasks.get(key) === task) {
activeWorktreeBootstrapTasks.delete(key);
}
};
void task.then(clearTask, clearTask);
};
const waitForActiveWorktreeBootstrap = async (directory: string): Promise<void> => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
while (true) {
const task = activeWorktreeBootstrapTasks.get(key);
if (!task) {
return;
}
await task.catch(() => undefined);
}
};
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
*/
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
*/
async function getGitApi(): Promise<GitAPI | null> {
if (gitApi && gitExtensionEnabled) {
return gitApi;
}
return initGitExtension();
}
/**
* Get repository for a given directory
*/
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;
}
function normalizeDirectoryPath(value: string): string {
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;
}
function cleanBranchName(branch: string): string {
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;
}
/**
* 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,
windowsHide: true,
});
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 });
});
});
}
function isValidCommitHash(hash: string): boolean {
return /^[0-9a-fA-F]{7,40}$/.test(hash);
}
function extractGitStatusPath(status: string, pathPart: string): string {
if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
return pathPart.split('\t').pop() || pathPart;
}
return pathPart;
}
function extractGitNumstatDestinationPath(filePath: string): string {
if (!filePath.includes(' => ')) {
return filePath;
}
const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/);
if (braceMatch) {
const [, prefix, , destination, suffix] = braceMatch;
return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/');
}
return filePath.split(' => ').pop()?.trim() || filePath;
}
// ============== 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 ==============
interface GitStatusFile {
path: string;
index: string;
working_dir: string;
}
interface GitMergeInProgress {
/** Short SHA of MERGE_HEAD */
head: string;
/** First line of MERGE_MSG */
message: string;
}
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;
}
type GitStatusOptions = {
mode?: 'light';
};
/**
* 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] || ' ';
}
function getRepositoryRelativePath(repo: Repository, uri: vscode.Uri): string {
return path.relative(repo.rootUri.fsPath, uri.fsPath).replace(/\\/g, '/');
}
/**
* Get git status for a directory
*/
export async function getGitStatus(directory: string, options?: GitStatusOptions): Promise<GitStatusResult> {
// The VS Code Git API path does not compute heavyweight diff stats today,
// but accepts the shared options contract so callers can rely on parity.
void options;
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 = getRepositoryRelativePath(repo, change.uri);
files.push({
path: relativePath,
index: mapStatus(change.status),
working_dir: ' ',
});
}
// Process working tree changes (unstaged)
for (const change of state.workingTreeChanges) {
const relativePath = getRepositoryRelativePath(repo, change.uri);
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 ==============
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 };
}
/**
* 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 {
head: string;
name: string;
branch: string;
path: string;
directoryCreated?: true;
bootstrapStatus?: WorktreeBootstrapStatus;
}
type WorktreeListEntry = {
worktree: string;
head?: string;
branchRef?: string;
branch?: string;
};
interface GitWorktreeValidationError {
code: string;
message: string;
}
export interface GitWorktreeValidationResult {
ok: boolean;
errors: GitWorktreeValidationError[];
resolved?: {
mode?: 'new' | 'existing';
localBranch?: string | null;
};
}
export interface CreateGitWorktreePayload {
mode?: 'new' | 'existing';
worktreeName?: string;
name?: string;
branchName?: string;
existingBranch?: string;
startRef?: string;
startCommand?: string;
setUpstream?: boolean;
upstreamRemote?: string;
upstreamBranch?: string;
ensureRemoteName?: string;
ensureRemoteUrl?: string;
returnAfterDirectoryCreated?: boolean;
}
export interface RemoveGitWorktreePayload {
directory: string;
deleteLocalBranch?: boolean;
}
const OPENCODE_ADJECTIVES = [
'brave', 'calm', 'clever', 'cosmic', 'crisp', 'curious', 'eager', 'gentle', 'glowing', 'happy',
'hidden', 'jolly', 'kind', 'lucky', 'mighty', 'misty', 'neon', 'nimble', 'playful', 'proud',
'quick', 'quiet', 'shiny', 'silent', 'stellar', 'sunny', 'swift', 'tidy', 'witty',
];
const OPENCODE_NOUNS = [
'cabin', 'cactus', 'canyon', 'circuit', 'comet', 'eagle', 'engine', 'falcon', 'forest', 'garden',
'harbor', 'island', 'knight', 'lagoon', 'meadow', 'moon', 'mountain', 'nebula', 'orchid', 'otter',
'panda', 'pixel', 'planet', 'river', 'rocket', 'sailor', 'squid', 'star', 'tiger', 'wizard', 'wolf',
];
const OPENCODE_WORKTREE_ATTEMPTS = 26;
const getOpenCodeDataPath = () => {
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(xdgDataHome, 'opencode');
};
const pickRandom = (values: string[]) => values[Math.floor(Math.random() * values.length)];
const generateOpenCodeRandomName = () => `${pickRandom(OPENCODE_ADJECTIVES)}-${pickRandom(OPENCODE_NOUNS)}`;
const slugWorktreeName = (value: string) => {
return String(value || '')
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^heads\//, '')
.replace(/\s+/g, '-')
.replace(/^\/+|\/+$/g, '')
.split('/').join('-')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '')
.slice(0, 80);
};
const parseWorktreePorcelain = (raw: string): WorktreeListEntry[] => {
const lines = String(raw || '').split('\n').map((line) => line.trim());
const entries: WorktreeListEntry[] = [];
let current: WorktreeListEntry | null = null;
for (const line of lines) {
if (!line) {
if (current?.worktree) {
entries.push(current);
}
current = null;
continue;
}
if (line.startsWith('worktree ')) {
if (current?.worktree) {
entries.push(current);
}
current = { worktree: line.substring('worktree '.length).trim() };
continue;
}
if (!current) {
continue;
}
if (line.startsWith('HEAD ')) {
current.head = line.substring('HEAD '.length).trim();
continue;
}
if (line.startsWith('branch ')) {
const branchRef = line.substring('branch '.length).trim();
current.branchRef = branchRef;
current.branch = cleanBranchName(branchRef);
}
}
if (current?.worktree) {
entries.push(current);
}
return entries;
};
const canonicalPath = async (input: string): Promise<string> => {
const absolutePath = path.resolve(input);
const realPath = await fs.promises.realpath(absolutePath).catch(() => absolutePath);
const normalized = path.normalize(realPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
};
const checkPathExists = async (targetPath: string): Promise<boolean> => {
try {
await fs.promises.stat(targetPath);
return true;
} catch {
return false;
}
};
const normalizeStartRef = (value: string | undefined): string => {
const trimmed = String(value || '').trim();
return trimmed || 'HEAD';
};
const parseRemoteBranchRef = (value: string) => {
const trimmed = String(value || '').trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('refs/remotes/')) {
const rest = trimmed.substring('refs/remotes/'.length);
const slashIndex = rest.indexOf('/');
if (slashIndex <= 0 || slashIndex === rest.length - 1) {
return null;
}
return {
remote: rest.slice(0, slashIndex),
branch: rest.slice(slashIndex + 1),
remoteRef: rest,
fullRef: `refs/remotes/${rest}`,
};
}
if (trimmed.startsWith('remotes/')) {
return parseRemoteBranchRef(`refs/${trimmed}`);
}
const slashIndex = trimmed.indexOf('/');
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
return null;
}
return {
remote: trimmed.slice(0, slashIndex),
branch: trimmed.slice(slashIndex + 1),
remoteRef: trimmed,
fullRef: `refs/remotes/${trimmed}`,
};
};
const resolveRemoteBranchRef = async (primaryWorktree: string, value: string) => {
const raw = String(value || '').trim();
const parsed = parseRemoteBranchRef(raw);
if (!parsed) {
return null;
}
if (raw.startsWith('refs/remotes/') || raw.startsWith('remotes/')) {
return parsed;
}
const localRef = `refs/heads/${raw}`;
const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
if (localExists.success) {
return null;
}
return parsed;
};
const normalizeUpstreamTarget = (remote: string | undefined, branch: string | undefined) => {
const remoteName = String(remote || '').trim();
const branchName = String(branch || '').trim();
if (!remoteName || !branchName) {
return null;
}
return {
remote: remoteName,
branch: branchName,
full: `${remoteName}/${branchName}`,
};
};
type GitCommandResult = {
success: boolean;
exitCode: number;
stdout: string;
stderr: string;
message?: string;
};
const runGitCommand = async (cwd: string, args: string[]): Promise<GitCommandResult> => {
const result = await execGit(args, cwd);
const message = [result.stderr, result.stdout].map((value) => String(value || '').trim()).filter(Boolean).join('\n').trim();
return {
success: result.exitCode === 0,
exitCode: result.exitCode,
stdout: String(result.stdout || ''),
stderr: String(result.stderr || ''),
message,
};
};
const runGitCommandOrThrow = async (cwd: string, args: string[], fallbackMessage: string) => {
const result = await runGitCommand(cwd, args);
if (!result.success) {
throw new Error(result.message || fallbackMessage || 'Git command failed');
}
return result;
};
const wait = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
const isIndexLockError = (result: GitCommandResult): boolean => {
const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n');
return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message);
};
const getWorktreeIndexLockPath = async (directory: string): Promise<string | null> => {
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']);
if (!result.success) {
return null;
}
const value = String(result.stdout || '').trim();
return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null;
};
const getFileIdentity = async (filePath: string): Promise<string | null> => {
try {
const stat = await fs.promises.stat(filePath);
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return null;
}
throw error;
}
};
// OpenChamber places managed worktrees under a deep data-dir path
// (`<XDG_DATA_HOME>/opencode/worktree/<40-char project id>/<name>/`). On
// Windows that prefix plus a deeply nested repo file routinely exceeds
// MAX_PATH (260). Git can check those paths out when core.longpaths is
// enabled; without it, `git reset --hard` during bootstrap fails with
// "Filename too long" and leaves a half-populated worktree (issue #2746).
const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard'] as const;
const isFilenameTooLongError = (message: string | null | undefined): boolean =>
/file ?name too long/i.test(String(message || ''));
const formatWorktreePopulateError = (message: string | null | undefined): string => {
const text = String(message || '').trim() || 'Failed to populate worktree';
if (!isFilenameTooLongError(text)) {
return text;
}
return [
text,
'The worktree checkout path exceeds this system\'s path-length limit.',
'OpenChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.',
].join('\n');
};
const ensureWorktreeLongpaths = async (directory: string): Promise<void> => {
const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']);
if (String(current.stdout || '').trim().toLowerCase() === 'true') {
return;
}
// Local config is shared across linked worktrees via the common git dir, so
// subsequent OpenChamber and CLI git operations in this repo also get long
// path support. Failures here are non-fatal: populate still passes
// `-c core.longpaths=true` on reset.
await runGitCommand(directory, ['config', 'core.longpaths', 'true']);
};
const populateWorktreeWithLockRecovery = async (directory: string): Promise<void> => {
await ensureWorktreeLongpaths(directory);
let result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(formatWorktreePopulateError(result.message));
}
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(formatWorktreePopulateError(result.message));
}
const lockPath = await getWorktreeIndexLockPath(directory);
const identity = lockPath ? await getFileIdentity(lockPath) : null;
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
if (result.success) {
return;
}
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
throw new Error(formatWorktreePopulateError(result.message));
}
await fs.promises.unlink(lockPath).catch((error) => {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error;
}
});
const finalResult = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
if (!finalResult.success) {
throw new Error(formatWorktreePopulateError(finalResult.message || 'Failed to populate worktree'));
}
};
// Worktrees are created with `git worktree add --no-checkout` and populated
// with `git reset --hard`, neither of which runs git's post-checkout hook —
// git only runs it for checkouts, clone, and worktree add *without*
// --no-checkout. Invoke the hook explicitly after population to restore git's
// checkout semantics: git passes the previous HEAD (null ref for a brand-new
// worktree), the new HEAD, and flag 1 for a branch checkout, and runs the hook
// from the worktree top-level.
const runPostCheckoutHook = async (directory: string): Promise<void> => {
let hookDirectory: string | null = null;
try {
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'hooks']);
if (!result.success) return;
hookDirectory = normalizeDirectoryPath(String(result.stdout || '').trim());
} catch {
return;
}
if (!hookDirectory) return;
const hookPath = path.join(hookDirectory, 'post-checkout');
try {
const stat = await fs.promises.stat(hookPath);
if (!stat.isFile()) return;
if (process.platform !== 'win32') {
await fs.promises.access(hookPath, fs.constants.X_OK);
}
} catch {
// Missing or non-executable hooks are skipped, matching git.
return;
}
const [headResult, gitDirResult] = await Promise.all([
runGitCommand(directory, ['rev-parse', 'HEAD']),
runGitCommand(directory, ['rev-parse', '--absolute-git-dir']),
]);
if (!headResult.success || !gitDirResult.success) return;
const head = String(headResult.stdout || '').trim();
const gitDir = String(gitDirResult.stdout || '').trim();
if (!head || !gitDir) return;
try {
await execFileAsync(hookPath, [GIT_NULL_REF, head, '1'], {
cwd: directory,
env: {
...(await buildGitEnv()),
GIT_DIR: gitDir,
GIT_WORK_TREE: path.resolve(directory),
},
windowsHide: true,
});
} catch (error) {
// A failing hook must not fail worktree creation or session bootstrap:
// warn and continue.
console.warn('[GitService] post-checkout hook failed in worktree:', error instanceof Error ? error.message : String(error));
}
};
const ensureOpenCodeProjectId = async (primaryWorktree: string): Promise<string> => {
const gitDir = path.join(primaryWorktree, '.git');
const idFile = path.join(gitDir, 'opencode');
const existing = await fs.promises.readFile(idFile, 'utf8').then((value) => value.trim()).catch(() => '');
if (existing) {
return existing;
}
const rootsResult = await runGitCommandOrThrow(
primaryWorktree,
['rev-list', '--max-parents=0', '--all'],
'Failed to resolve repository roots'
);
const roots = rootsResult.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
const projectId = roots[0] || '';
if (!projectId) {
throw new Error('Failed to derive OpenCode project ID');
}
await fs.promises.mkdir(gitDir, { recursive: true }).catch(() => undefined);
await fs.promises.writeFile(idFile, projectId, 'utf8').catch(() => undefined);
return projectId;
};
const resolveWorktreeProjectContext = async (directory: string) => {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath) {
throw new Error('Directory is required');
}
const topResult = await runGitCommandOrThrow(
directoryPath,
['rev-parse', '--show-toplevel'],
'Failed to resolve git top-level directory'
);
const sandbox = path.resolve(directoryPath, topResult.stdout.trim());
const commonResult = await runGitCommandOrThrow(
sandbox,
['rev-parse', '--git-common-dir'],
'Failed to resolve git common directory'
);
const commonDir = path.resolve(sandbox, commonResult.stdout.trim());
const primaryWorktree = path.dirname(commonDir);
const projectID = await ensureOpenCodeProjectId(primaryWorktree);
const worktreeRoot = path.join(getOpenCodeDataPath(), 'worktree', projectID);
return { projectID, sandbox, primaryWorktree, worktreeRoot };
};
const listWorktreeEntries = async (directory: string): Promise<WorktreeListEntry[]> => {
const rawResult = await runGitCommandOrThrow(directory, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees');
return parseWorktreePorcelain(rawResult.stdout);
};
const resolveWorktreeNameCandidates = (baseName: string): string[] => {
const normalizedBase = slugWorktreeName(baseName || '');
if (!normalizedBase) {
return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, () => generateOpenCodeRandomName());
}
return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, (_, index) => {
if (index === 0) {
return normalizedBase;
}
return `${normalizedBase}-${generateOpenCodeRandomName()}`;
});
};
const resolveCandidateDirectory = async (
worktreeRoot: string,
preferredName: string,
explicitBranchName: string,
primaryWorktree: string
) => {
const candidates = resolveWorktreeNameCandidates(preferredName);
for (const name of candidates) {
const directory = path.join(worktreeRoot, name);
if (await checkPathExists(directory)) {
continue;
}
if (explicitBranchName) {
return { name, directory, branch: explicitBranchName };
}
const branch = `openchamber/${name}`;
const branchRef = `refs/heads/${branch}`;
const branchExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', branchRef]);
if (branchExists.success) {
continue;
}
return { name, directory, branch };
}
throw new Error('Failed to generate a unique worktree name');
};
const fetchRemoteBranchRef = async (primaryWorktree: string, remoteName: string, branchName: string) => {
const remote = String(remoteName || '').trim();
const branch = String(branchName || '').trim();
if (!remote || !branch) {
return;
}
const refspec = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`;
await runGitCommandOrThrow(primaryWorktree, ['fetch', remote, refspec], `Failed to fetch ${remote}/${branch}`);
};
const resolveBranchForExistingMode = async (primaryWorktree: string, existingBranch: string, preferredBranchName: string) => {
const requested = String(existingBranch || '').trim();
if (!requested) {
throw new Error('existingBranch is required in existing mode');
}
const normalizedLocal = cleanBranchName(requested);
const localRef = `refs/heads/${normalizedLocal}`;
const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
if (localExists.success) {
return {
localBranch: normalizedLocal,
checkoutRef: normalizedLocal,
createLocalBranch: false,
remoteRef: null as ReturnType<typeof parseRemoteBranchRef>,
};
}
const remoteRef = parseRemoteBranchRef(requested);
if (!remoteRef) {
throw new Error(`Branch not found: ${requested}`);
}
const remoteExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]);
if (!remoteExists.success) {
await fetchRemoteBranchRef(primaryWorktree, remoteRef.remote, remoteRef.branch).catch(() => undefined);
const recheck = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]);
if (!recheck.success) {
throw new Error(`Remote branch not found: ${requested}`);
}
}
const localBranch = cleanBranchName(preferredBranchName || remoteRef.branch || requested);
if (!localBranch) {
throw new Error('Failed to resolve local branch name for existing branch worktree');
}
return {
localBranch,
checkoutRef: remoteRef.remoteRef,
createLocalBranch: true,
remoteRef,
};
};
const findBranchInUse = async (primaryWorktree: string, localBranchName: string) => {
if (!localBranchName) {
return null;
}
const entries = await listWorktreeEntries(primaryWorktree);
const targetRef = `refs/heads/${localBranchName}`;
const targetClean = cleanBranchName(targetRef);
return entries.find((entry) => {
const entryRef = String(entry.branchRef || '').trim();
const entryClean = cleanBranchName(entryRef || entry.branch || '');
return entryRef === targetRef || entryClean === targetClean;
}) || null;
};
const runWorktreeStartCommand = async (directory: string, command: string): Promise<{ success: boolean; message?: string; stdout?: string; stderr?: string }> => {
const text = String(command || '').trim();
if (!text) {
return { success: true };
}
const env = await buildGitEnv();
if (process.platform === 'win32') {
try {
const { stdout, stderr } = await execFileAsync('cmd', ['/c', text], {
cwd: directory,
env,
maxBuffer: 20 * 1024 * 1024,
});
return { success: true, stdout: String(stdout || ''), stderr: String(stderr || '') };
} catch (error) {
const err = error as { stdout?: string; stderr?: string; message?: string };
return {
success: false,
stdout: err.stdout,
stderr: err.stderr,
message: String(err.message || err.stderr || err.stdout || 'Failed to run start command').trim(),
};
}
}
try {
const { stdout, stderr } = await execFileAsync('bash', ['-lc', text], {
cwd: directory,
env,
maxBuffer: 20 * 1024 * 1024,
});
return { success: true, stdout: String(stdout || ''), stderr: String(stderr || '') };
} catch (error) {
const err = error as { stdout?: string; stderr?: string; message?: string };
return {
success: false,
stdout: err.stdout,
stderr: err.stderr,
message: String(err.message || err.stderr || err.stdout || 'Failed to run start command').trim(),
};
}
};
const loadProjectStartCommand = async (projectID: string): Promise<string> => {
const storagePath = path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
try {
const raw = await fs.promises.readFile(storagePath, 'utf8');
const parsed = JSON.parse(raw) as { commands?: { start?: string } };
const start = typeof parsed?.commands?.start === 'string' ? parsed.commands.start.trim() : '';
return start || '';
} catch {
return '';
}
};
// OpenCode owns its own project/sandbox registry and records a worktree as a
// sandbox itself when an instance boots for that directory. OpenChamber used to
// write that state into OpenCode's storage JSON directly, behind the back of the
// running process — and since OpenCode v2 reads sandboxes from its database, the
// JSON write did not even reach it. Registration is not ours to perform.
const isInsideOrSameDirectory = (root: string, target: string): boolean => {
const relative = path.relative(root, target);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
};
const isAttachedGitWorktreeDirectory = async (directory: string): Promise<boolean> => {
try {
const result = await runGitCommand(directory, ['rev-parse', '--is-inside-work-tree']);
return result.success && String(result.stdout || '').trim() === 'true';
} catch {
return false;
}
};
const cleanupFailedFastWorktreeCreate = async (
context: Awaited<ReturnType<typeof resolveWorktreeProjectContext>>,
candidate: { directory: string }
): Promise<void> => {
const candidateDirectory = path.resolve(candidate.directory);
const worktreeRoot = path.resolve(context.worktreeRoot);
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
if (!isInsideWorktreeRoot || isAttached) {
return;
}
try {
const entries = await fs.promises.readdir(candidateDirectory);
if (entries.length === 0) {
await fs.promises.rmdir(candidateDirectory);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(String(code || ''))) {
console.warn('[GitService] Failed to clean up empty worktree directory after creation failure:', error instanceof Error ? error.message : String(error));
}
}
};
const runWorktreeStartScripts = async (directory: string, projectID: string, startCommand: string | undefined) => {
const projectStart = await loadProjectStartCommand(projectID);
if (projectStart) {
const projectResult = await runWorktreeStartCommand(directory, projectStart);
if (!projectResult.success) {
console.warn('[GitService] Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout);
return;
}
}
const extraCommand = String(startCommand || '').trim();
if (!extraCommand) {
return;
}
const extraResult = await runWorktreeStartCommand(directory, extraCommand);
if (!extraResult.success) {
console.warn('[GitService] Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout);
}
};
const queueWorktreeBootstrap = (args: {
directory: string;
projectID: string;
primaryWorktree: string;
localBranch: string;
setUpstream: boolean;
upstreamRemote: string;
upstreamBranch: string;
ensureRemoteName: string;
ensureRemoteUrl: string;
startCommand: string | undefined;
}) => {
const {
directory,
projectID,
primaryWorktree,
localBranch,
setUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
startCommand,
} = args;
const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
.then(async () => {
await populateWorktreeWithLockRecovery(directory);
await runPostCheckoutHook(directory);
if (setUpstream) {
await applyUpstreamConfiguration({
primaryWorktree,
worktreeDirectory: directory,
localBranch,
setUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
}).catch((error) => {
console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
});
}
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_GIT_READY);
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error));
});
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_PHASE_SETUP_READY);
})
.catch((error) => {
setWorktreeBootstrapFailure(directory, error);
console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
});
trackWorktreeBootstrapTask(directory, task);
};
const ensureRemoteWithUrl = async (primaryWorktree: string, remoteName: string, remoteUrl: string) => {
const name = String(remoteName || '').trim();
const url = String(remoteUrl || '').trim();
if (!name || !url) {
return;
}
const getUrl = await runGitCommand(primaryWorktree, ['remote', 'get-url', name]);
if (getUrl.success) {
const currentUrl = String(getUrl.stdout || '').trim();
if (currentUrl !== url) {
await runGitCommandOrThrow(primaryWorktree, ['remote', 'set-url', name, url], 'Failed to update git remote URL');
}
return;
}
await runGitCommandOrThrow(primaryWorktree, ['remote', 'add', name, url], 'Failed to add git remote');
};
const checkRemoteBranchExists = async (primaryWorktree: string, remoteName: string, branchName: string, remoteUrl = '') => {
const remote = String(remoteName || '').trim();
const branch = String(branchName || '').trim();
const url = String(remoteUrl || '').trim();
if (!remote || !branch) {
return { success: false, found: false };
}
const target = url || remote;
const lsRemote = await runGitCommand(primaryWorktree, ['ls-remote', '--heads', target, `refs/heads/${branch}`]);
if (!lsRemote.success) {
return { success: false, found: false };
}
return {
success: true,
found: Boolean(String(lsRemote.stdout || '').trim()),
};
};
const setBranchTrackingFallback = async (worktreeDirectory: string, localBranch: string, upstream: { remote: string; branch: string }) => {
await runGitCommandOrThrow(
worktreeDirectory,
['config', `branch.${localBranch}.remote`, upstream.remote],
`Failed to set branch.${localBranch}.remote`
);
await runGitCommandOrThrow(
worktreeDirectory,
['config', `branch.${localBranch}.merge`, `refs/heads/${upstream.branch}`],
`Failed to set branch.${localBranch}.merge`
);
};
const applyUpstreamConfiguration = async (args: {
primaryWorktree: string;
worktreeDirectory: string;
localBranch: string;
setUpstream: boolean;
upstreamRemote?: string;
upstreamBranch?: string;
ensureRemoteName?: string;
ensureRemoteUrl?: string;
}) => {
const {
primaryWorktree,
worktreeDirectory,
localBranch,
setUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
} = args;
if (!setUpstream) {
return;
}
if (ensureRemoteName && ensureRemoteUrl) {
await ensureRemoteWithUrl(primaryWorktree, ensureRemoteName, ensureRemoteUrl);
}
const upstream = normalizeUpstreamTarget(upstreamRemote, upstreamBranch);
if (!upstream || !localBranch) {
return;
}
let fetched = true;
try {
await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch);
} catch {
fetched = false;
}
if (fetched) {
await runGitCommandOrThrow(
worktreeDirectory,
['branch', `--set-upstream-to=${upstream.full}`, localBranch],
`Failed to set upstream to ${upstream.full}`
);
return;
}
await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream);
};
/**
* List all worktrees for a repository
*/
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) {
return [];
}
try {
const result = await runGitCommandOrThrow(directoryPath, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees');
return parseWorktreePorcelain(result.stdout).map((entry) => ({
head: entry.head || '',
name: path.basename(entry.worktree || ''),
branch: entry.branch || '',
path: entry.worktree,
}));
} catch (error) {
console.warn('[GitService] Failed to list worktrees, returning empty list:', error instanceof Error ? error.message : String(error));
return [];
}
}
export async function validateWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeValidationResult> {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const errors: GitWorktreeValidationError[] = [];
try {
const context = await resolveWorktreeProjectContext(directory);
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const startRef = normalizeStartRef(input?.startRef);
const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
let localBranch = '';
let inferredUpstream: { remote: string; branch: string } | null = null;
if (mode === 'existing') {
try {
const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
const lsRemote = await runGitCommand(
context.primaryWorktree,
['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`]
);
if (!lsRemote.success) {
throw new Error(`Unable to query remote ${ensureRemoteName}`);
}
if (!String(lsRemote.stdout || '').trim()) {
throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`);
}
localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch);
inferredUpstream = {
remote: parsedExistingRemote.remote,
branch: parsedExistingRemote.branch,
};
} else {
const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
localBranch = resolved.localBranch || '';
if (resolved.remoteRef) {
inferredUpstream = {
remote: resolved.remoteRef.remote,
branch: resolved.remoteRef.branch,
};
}
}
} catch (error) {
errors.push({
code: 'branch_not_found',
message: error instanceof Error ? error.message : 'Existing branch not found',
});
}
} else {
if (preferredBranchName) {
const exists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${preferredBranchName}`]);
if (exists.success) {
errors.push({ code: 'branch_exists', message: `Branch already exists: ${preferredBranchName}` });
}
localBranch = preferredBranchName;
}
const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (startRef && startRef !== 'HEAD') {
if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) {
const remoteCheck = await checkRemoteBranchExists(
context.primaryWorktree,
parsedRemoteRef.remote,
parsedRemoteRef.branch,
ensureRemoteUrl
);
if (!remoteCheck.success) {
errors.push({ code: 'remote_unreachable', message: `Unable to query remote ${ensureRemoteName}` });
} else if (!remoteCheck.found) {
errors.push({ code: 'start_ref_not_found', message: `Remote branch not found: ${parsedRemoteRef.remoteRef}` });
}
} else if (parsedRemoteRef) {
const remoteCheck = await checkRemoteBranchExists(context.primaryWorktree, parsedRemoteRef.remote, parsedRemoteRef.branch);
if (!remoteCheck.success) {
errors.push({ code: 'remote_unreachable', message: `Unable to query remote ${parsedRemoteRef.remote}` });
} else if (!remoteCheck.found) {
errors.push({ code: 'start_ref_not_found', message: `Remote branch not found: ${parsedRemoteRef.remoteRef}` });
}
} else {
const startRefExists = await runGitCommand(context.primaryWorktree, ['rev-parse', '--verify', '--quiet', startRef]);
if (!startRefExists.success) {
errors.push({ code: 'start_ref_not_found', message: `Start ref not found: ${startRef}` });
}
}
}
if (parsedRemoteRef) {
inferredUpstream = { remote: parsedRemoteRef.remote, branch: parsedRemoteRef.branch };
}
}
if (localBranch) {
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
if (inUse) {
errors.push({ code: 'branch_in_use', message: `Branch is already checked out in ${inUse.worktree}` });
}
}
if ((ensureRemoteName && !ensureRemoteUrl) || (!ensureRemoteName && ensureRemoteUrl)) {
errors.push({ code: 'invalid_remote_config', message: 'Both ensureRemoteName and ensureRemoteUrl are required together' });
}
const shouldSetUpstream = Boolean(input?.setUpstream);
if (shouldSetUpstream) {
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
if (!upstreamRemote || !upstreamBranch) {
errors.push({ code: 'upstream_incomplete', message: 'upstreamRemote and upstreamBranch are required when setUpstream is true' });
} else {
const remoteExists = await runGitCommand(context.primaryWorktree, ['remote', 'get-url', upstreamRemote]);
if (!remoteExists.success && (!ensureRemoteName || ensureRemoteName !== upstreamRemote)) {
errors.push({ code: 'remote_not_found', message: `Remote not found: ${upstreamRemote}` });
}
}
}
return {
ok: errors.length === 0,
errors,
resolved: {
mode,
localBranch: localBranch || null,
},
};
} catch (error) {
return {
ok: false,
errors: [{
code: 'validation_failed',
message: error instanceof Error ? error.message : 'Failed to validate worktree creation',
}],
};
}
}
const assertWorktreeCreatePreflight = async (directory: string, input: CreateGitWorktreePayload = {}): Promise<void> => {
const validation = await validateWorktreeCreate(directory, input);
if (validation?.ok) {
return;
}
const message = validation?.errors
?.map((error) => error?.message)
.filter(Boolean)
.join('\n') || 'Failed to validate worktree creation';
throw new Error(message);
};
export async function previewWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
return {
name: candidate.name,
branch: mode === 'new' ? candidate.branch : preferredBranchName,
path: candidate.directory,
head: '',
};
}
async function attachGitWorktreeToCandidate(
context: Awaited<ReturnType<typeof resolveWorktreeProjectContext>>,
candidate: { name: string; directory: string; branch: string },
input: CreateGitWorktreePayload = {},
): Promise<GitWorktreeInfo> {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const startRef = normalizeStartRef(input?.startRef);
const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
let localBranch = '';
let inferredUpstream: { remote: string; branch: string } | null = null;
const worktreeAddArgs = ['worktree', 'add', '--no-checkout'];
if (mode === 'existing') {
const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch);
}
const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
localBranch = resolved.localBranch;
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
if (inUse) {
throw new Error(`Branch is already checked out in ${inUse.worktree}`);
}
if (resolved.createLocalBranch) {
worktreeAddArgs.push('-b', localBranch);
}
worktreeAddArgs.push(candidate.directory, resolved.checkoutRef);
if (resolved.remoteRef) {
inferredUpstream = {
remote: resolved.remoteRef.remote,
branch: resolved.remoteRef.branch,
};
}
} else {
localBranch = candidate.branch;
if (!localBranch) {
throw new Error('Failed to resolve branch name for new worktree');
}
const branchExists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${localBranch}`]);
if (branchExists.success) {
throw new Error(`Branch already exists: ${localBranch}`);
}
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
if (inUse) {
throw new Error(`Branch is already checked out in ${inUse.worktree}`);
}
worktreeAddArgs.push('-b', localBranch, candidate.directory);
if (startRef && startRef !== 'HEAD') {
worktreeAddArgs.push(startRef);
}
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
inferredUpstream = {
remote: parsedRemoteStartRef.remote,
branch: parsedRemoteStartRef.branch,
};
}
}
if (ensureRemoteName && ensureRemoteUrl) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
}
if (mode === 'new') {
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
}
}
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
const shouldSetUpstream = Boolean(input?.setUpstream);
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_PHASE_DIRECTORY_CREATED,
) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
error: null,
updatedAt: Date.now(),
};
queueWorktreeBootstrap({
directory: candidate.directory,
projectID: context.projectID,
primaryWorktree: context.primaryWorktree,
localBranch,
setUpstream: shouldSetUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
startCommand: input?.startCommand,
});
const headResult = await runGitCommand(candidate.directory, ['rev-parse', 'HEAD']);
const head = String(headResult.stdout || '').trim();
return {
head,
name: candidate.name,
branch: localBranch,
path: candidate.directory,
directoryCreated: true,
bootstrapStatus,
};
}
export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
if (input?.returnAfterDirectoryCreated === true) {
await assertWorktreeCreatePreflight(directory, input);
}
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
if (input?.returnAfterDirectoryCreated === true) {
await fs.promises.mkdir(candidate.directory, { recursive: false });
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_PHASE_DIRECTORY_CREATED,
) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
error: null,
updatedAt: Date.now(),
};
const localBranch = mode === 'existing'
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
: candidate.branch;
const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
setWorktreeBootstrapFailure(candidate.directory, error);
await cleanupFailedFastWorktreeCreate(context, candidate);
console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error));
});
trackWorktreeBootstrapTask(candidate.directory, task);
return {
head: '',
name: candidate.name,
branch: localBranch,
path: candidate.directory,
directoryCreated: true,
bootstrapStatus,
};
}
return attachGitWorktreeToCandidate(context, candidate, input);
}
export async function getWorktreeBootstrapStatus(directory: string): Promise<WorktreeBootstrapStatus> {
const key = toBootstrapStateKey(directory);
if (!key) {
throw new Error('Worktree directory is required');
}
const current = worktreeBootstrapState.get(key);
if (current) {
return current;
}
return {
status: WORKTREE_BOOTSTRAP_READY,
phase: WORKTREE_PHASE_SETUP_READY,
error: null,
updatedAt: Date.now(),
};
}
export async function removeWorktree(directory: string, input: RemoveGitWorktreePayload): Promise<boolean> {
const targetDirectory = normalizeDirectoryPath(input?.directory);
if (!targetDirectory) {
throw new Error('Worktree directory is required');
}
await waitForActiveWorktreeBootstrap(targetDirectory);
const context = await resolveWorktreeProjectContext(directory);
const deleteLocalBranch = input?.deleteLocalBranch === true;
const targetCanonical = await canonicalPath(targetDirectory);
const primaryCanonical = await canonicalPath(context.primaryWorktree);
if (targetCanonical === primaryCanonical) {
throw new Error('Cannot remove the primary workspace');
}
const entries = await listWorktreeEntries(context.primaryWorktree);
const matchedEntry = await (async () => {
for (const entry of entries) {
if (!entry?.worktree) {
continue;
}
const entryCanonical = await canonicalPath(entry.worktree);
if (entryCanonical === targetCanonical) {
return entry;
}
}
return null;
})();
if (!matchedEntry?.worktree) {
const targetExists = await checkPathExists(targetDirectory);
if (targetExists) {
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
}
clearWorktreeBootstrapState(targetDirectory);
return true;
}
await runGitCommandOrThrow(
context.primaryWorktree,
['worktree', 'remove', '--force', matchedEntry.worktree],
'Failed to remove git worktree'
);
if (deleteLocalBranch) {
const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim());
if (branchName) {
await runGitCommandOrThrow(
context.primaryWorktree,
['branch', '-D', branchName],
`Failed to delete local branch ${branchName}`
);
}
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;
}
// ============== 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);
}
}
let modified: string;
if (staged) {
const stagedResult = await execGit(['show', `:${filePath}`], directory);
modified = stagedResult.exitCode === 0 ? stagedResult.stdout : '';
} else {
const fileUri = vscode.Uri.file(path.join(directory, filePath));
const modifiedBytes = await vscode.workspace.fs.readFile(fileUri);
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,
options: { scope?: 'all' | 'working' } = {},
): Promise<void> {
const scope = options.scope === 'working' ? 'working' : 'all';
const tracked = await execGit(['ls-files', '--error-unmatch', '--', filePath], directory);
if (tracked.exitCode !== 0) {
const clean = await execGit(['clean', '-f', '-d', '--', filePath], directory);
if (clean.exitCode !== 0) {
const root = path.resolve(directory);
const target = path.resolve(directory, filePath);
if (target !== root && !target.startsWith(root + path.sep)) {
throw new Error(`Path is outside repository: ${filePath}`);
}
await fs.promises.rm(target, { recursive: true, force: true });
}
return;
}
if (scope === 'all') {
const unstage = await execGit(['restore', '--staged', '--', filePath], directory);
if (unstage.exitCode !== 0) {
await execGit(['reset', 'HEAD', '--', filePath], directory);
}
}
const restore = await execGit(['restore', '--', filePath], directory);
if (restore.exitCode === 0) {
return;
}
const fallback = await execGit(['checkout', '--', filePath], directory);
if (fallback.exitCode !== 0) {
throw new Error(fallback.stderr || restore.stderr || 'Failed to revert git file');
}
}
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
if (paths.length === 0) {
throw new Error('path is required');
}
const result = await execGit(['add', '--', ...paths], directory);
if (result.exitCode === 0) {
return;
}
const isPathspecError =
/pathspec/.test(result.stderr) && /did not match any files/.test(result.stderr);
if (!isPathspecError) {
throw new Error(result.stderr || 'Failed to stage git file');
}
// During rapid stage/unstage toggling the optimistic UI can request staging a
// path that a prior queued mutation already staged (most visibly a deletion,
// whose file is gone from the working tree). `git add` aborts the whole batch on
// a single unmatched pathspec, so retry per-path and skip the ones already in
// their target state rather than failing the entire "stage all".
for (const path of paths) {
const perPath = await execGit(['add', '--', path], directory);
if (perPath.exitCode === 0) {
continue;
}
const perPathIsPathspecError =
/pathspec/.test(perPath.stderr) && /did not match any files/.test(perPath.stderr);
if (!perPathIsPathspecError) {
throw new Error(perPath.stderr || 'Failed to stage git file');
}
}
}
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
if (paths.length === 0) {
throw new Error('path is required');
}
const result = await execGit(['restore', '--staged', '--', ...paths], directory);
if (result.exitCode === 0) {
return;
}
const fallback = await execGit(['reset', 'HEAD', '--', ...paths], directory);
if (fallback.exitCode !== 0) {
throw new Error(fallback.stderr || result.stderr || 'Failed to unstage git file');
}
}
const HUNK_ACTION_ARGS: Record<'stage' | 'unstage' | 'discard', string[]> = {
stage: ['--cached'],
unstage: ['--cached', '--reverse'],
discard: ['--reverse'],
};
const parsePatchPathToken = (line: string): string | null => {
const value = String(line || '').replace(/^(?:-{3}|\+{3})\s+/, '');
if (!value || value === '/dev/null') {
return null;
}
if (value.startsWith('"')) {
let token = '"';
let escaped = false;
for (let index = 1; index < value.length; index += 1) {
const char = value[index];
token += char;
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
break;
}
}
try {
return JSON.parse(token) as string;
} catch {
return token.slice(1, token.endsWith('"') ? -1 : undefined);
}
}
return value.split('\t', 1)[0] || null;
};
const normalizePatchTargetPath = (value: string | null): string | null => {
if (!value || value === '/dev/null') {
return null;
}
return value.replace(/^[ab]\//, '').replace(/\\/g, '/');
};
const extractPatchTargetPath = (patch: string): string | null => {
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
const realTargets = matches
.map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0] ?? '')))
.filter((value): value is string => Boolean(value));
return realTargets[0] || null;
};
const getRepoRelativePath = async (directory: string, filePath: string): Promise<string> => {
const normalizedFilePath = normalizePath(filePath).replace(/\\/g, '/');
if (!path.isAbsolute(normalizedFilePath)) {
return normalizedFilePath.replace(/^\.?\//, '');
}
const rootResult = await execGit(['rev-parse', '--show-toplevel'], directory);
if (rootResult.exitCode !== 0) {
throw new Error(rootResult.stderr || 'Failed to resolve repository root');
}
const repoRoot = normalizePath(rootResult.stdout.trim());
return path.relative(repoRoot, normalizedFilePath).replace(/\\/g, '/');
};
/**
* Apply a single-hunk patch to stage, unstage, or discard it.
* The patch is written to a temp file and applied with `git apply`.
*/
export async function applyGitHunk(
directory: string,
filePath: string,
patch: string,
action: 'stage' | 'unstage' | 'discard',
): Promise<void> {
if (!HUNK_ACTION_ARGS[action]) {
throw new Error('Invalid hunk action');
}
if (!filePath) {
throw new Error('path is required');
}
if (typeof patch !== 'string' || !patch.trim()) {
throw new Error('patch is required');
}
if (!/^@@\s/m.test(patch)) {
throw new Error('patch does not contain a hunk header');
}
const repoRelativePath = await getRepoRelativePath(directory, filePath);
const targetPath = extractPatchTargetPath(patch);
if (targetPath && targetPath !== repoRelativePath && targetPath !== filePath.replace(/\\/g, '/')) {
throw new Error('patch target path does not match the requested file');
}
const flags = HUNK_ACTION_ARGS[action];
const tmpDir = os.tmpdir();
const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
try {
await fs.promises.writeFile(tmpPath, patch, 'utf8');
const check = await execGit(['apply', ...flags, '--check', tmpPath], directory);
if (check.exitCode !== 0) {
const detail = (check.stderr || '').trim();
throw new Error(
detail
? `Hunk no longer applies — refresh and try again.\n${detail}`
: 'Hunk no longer applies — refresh and try again.'
);
}
const apply = await execGit(['apply', ...flags, tmpPath], directory);
if (apply.exitCode !== 0) {
throw new Error(apply.stderr || 'Failed to apply git hunk');
}
} finally {
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
}
}
// ============== 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[]; stageFiles?: string[] }
): Promise<GitCommitResult> {
if (options?.files?.length && options.stageFiles) {
const selectedFiles = new Set(options.files);
const stagedResult = await execGit(['diff', '--cached', '--name-only'], directory);
const temporarilyUnstagedFiles = stagedResult.stdout
.split('\n')
.map((line) => line.trim())
.filter((filePath) => filePath && !selectedFiles.has(filePath));
try {
if (temporarilyUnstagedFiles.length > 0) {
await execGit(['restore', '--staged', '--', ...temporarilyUnstagedFiles], directory);
}
if (options.stageFiles.length > 0) {
await execGit(['add', '--', ...options.stageFiles], 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 },
};
}
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 },
};
} finally {
if (temporarilyUnstagedFiles.length > 0) {
await execGit(['add', '--', ...temporarilyUnstagedFiles], directory);
}
}
}
const repo = await getRepository(directory);
if (repo) {
try {
if (options?.addAll) {
await repo.add(['.']);
} else if (options?.files?.length) {
const filesToStage = options.stageFiles ?? options.files;
if (filesToStage.length > 0) {
await repo.add(filesToStage);
}
}
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) {
const filesToStage = options.stageFiles ?? options.files;
if (filesToStage.length > 0) {
await execGit(['add', ...filesToStage], 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 remote = options?.remote?.trim();
const branch = options?.branch;
const gitOptions = options?.options;
const describePushFailure = (value: unknown): string => {
const message = String(
(value as { message?: string } | undefined)?.message ||
(value as { stderr?: string } | undefined)?.stderr ||
(value as { stdout?: string } | undefined)?.stdout ||
''
).trim();
return message || 'Failed to push to remote';
};
const buildUpstreamOptions = (raw?: string[] | Record<string, unknown>): string[] => {
const normalized = normalizeGitOptions(raw);
if (hasOption(normalized, '--set-upstream') || hasOption(normalized, '-u')) {
return normalized;
}
return [...normalized, '--set-upstream'];
};
const looksLikeMissingUpstream = (value: unknown): boolean => {
const message = String(
(value as { message?: string } | undefined)?.message ||
(value as { stderr?: string } | undefined)?.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 getCurrentBranch = async (): Promise<string> => {
const result = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory);
return String(result.stdout || '').trim();
};
const hasTrackingBranch = async (): Promise<boolean> => {
const result = await execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], directory);
return result.exitCode === 0 && Boolean(String(result.stdout || '').trim());
};
const getRemotes = async (): Promise<string[]> => {
const result = await execGit(['remote'], directory);
if (result.exitCode !== 0) {
return [];
}
return String(result.stdout || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
};
const pushRaw = async (args: string[]) => {
const result = await execGit(args, directory);
if (result.exitCode !== 0) {
throw new Error(describePushFailure(result));
}
};
const normalizePushResult = (local: string, remoteName: string) => ({
success: true,
pushed: [{ local, remote: remoteName }],
repo: directory,
ref: null,
});
if (!remote && !branch) {
try {
const args = ['push'];
const normalizedOptions = normalizeGitOptions(gitOptions);
if (normalizedOptions.length > 0) {
args.push(...normalizedOptions);
}
await pushRaw(args);
return {
success: true,
pushed: [],
repo: directory,
ref: null,
};
} catch (error) {
if (!looksLikeMissingUpstream(error)) {
throw new Error(describePushFailure(error));
}
const currentBranch = await getCurrentBranch();
const remotes = await getRemotes();
const fallbackRemote = remotes.includes('origin') ? 'origin' : remotes[0];
if (!currentBranch || !fallbackRemote) {
throw new Error(describePushFailure(error));
}
const args = ['push', ...buildUpstreamOptions(gitOptions), fallbackRemote, currentBranch];
await pushRaw(args);
return normalizePushResult(currentBranch, fallbackRemote);
}
}
const remoteName = remote || 'origin';
if (!branch) {
try {
const currentBranch = await getCurrentBranch();
const tracking = await hasTrackingBranch();
if (currentBranch && !tracking) {
const args = ['push', ...buildUpstreamOptions(gitOptions), remoteName, currentBranch];
await pushRaw(args);
return normalizePushResult(currentBranch, remoteName);
}
} catch (error) {
console.warn('[GitService] Failed to determine upstream state before push:', error);
}
}
try {
const args = ['push', ...normalizeGitOptions(gitOptions), remoteName];
if (branch) {
args.push(branch);
}
await pushRaw(args);
return normalizePushResult(branch || '', remoteName);
} catch (error) {
if (!looksLikeMissingUpstream(error)) {
throw new Error(describePushFailure(error));
}
const fallbackBranch = branch || await getCurrentBranch();
if (!fallbackBranch) {
throw new Error(describePushFailure(error));
}
const args = ['push', ...buildUpstreamOptions(gitOptions), remoteName, fallbackBranch];
await pushRaw(args);
return normalizePushResult(fallbackBranch, remoteName);
}
}
/**
* Pull from remote
*/
export async function gitPull(
directory: string,
options?: { remote?: string; branch?: string; rebase?: boolean }
): Promise<{ success: boolean; summary: { changes: number; insertions: number; deletions: number }; files: string[]; insertions: number; deletions: number }> {
const repo = await getRepository(directory);
if (repo && options?.rebase !== true) {
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 beforeHead = await execGit(['rev-parse', 'HEAD'], directory);
const args = ['pull'];
if (options?.rebase === true) args.push('--rebase');
if (options?.remote) args.push(options.remote);
if (options?.branch) args.push(options.branch);
const result = await execGit(args, directory);
if (result.exitCode !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to pull from remote');
}
const afterHead = await execGit(['rev-parse', 'HEAD'], directory);
const before = beforeHead.exitCode === 0 ? beforeHead.stdout.trim() : '';
const after = afterHead.exitCode === 0 ? afterHead.stdout.trim() : '';
const changedFiles = before && after && before !== after
? await execGit(['diff', '--name-only', before, after], directory)
: { stdout: '', stderr: '', exitCode: 0 };
const files = changedFiles.exitCode === 0
? changedFiles.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
: [];
return {
success: result.exitCode === 0,
summary: { changes: files.length, insertions: 0, deletions: 0 },
files,
insertions: 0,
deletions: 0,
};
}
export async function listGitStashes(directory: string): Promise<Array<{ ref: string; message: string; relativeTime: string; hash: string }>> {
const result = await execGit(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H'], directory);
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || 'Failed to list stashes');
return result.stdout.split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
const [ref = '', message = '', relativeTime = '', hash = ''] = line.split('\x1f');
return { ref, message, relativeTime, hash };
}).filter((entry) => entry.ref);
}
export async function countGitStashFiles(directory: string, refs: string[]): Promise<Record<string, number>> {
const uniqueRefs = Array.from(new Set(refs.map((ref) => String(ref || '').trim()).filter(Boolean)));
const counts: Record<string, number> = {};
const concurrency = 4;
let cursor = 0;
const worker = async () => {
while (cursor < uniqueRefs.length) {
const ref = uniqueRefs[cursor++];
if (!ref) continue;
const names = await execGit(['stash', 'show', '--name-only', ref], directory);
counts[ref] = names.exitCode === 0 ? names.stdout.split('\n').map((line) => line.trim()).filter(Boolean).length : 0;
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, uniqueRefs.length) }, () => worker()));
return counts;
}
export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> {
const message = options.message?.trim() || `OpenChamber stash ${new Date().toISOString()}`;
const result = await execGit(['stash', 'push', '--include-untracked', '-m', message], directory);
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || 'Failed to stash changes');
const output = result.stdout.trim() || result.stderr.trim();
return { success: true, created: !/no local changes/i.test(output), message, output };
}
export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const ref = options.ref || 'stash@{0}';
// Prefer --index so the staged/unstaged split captured in the stash is restored
// faithfully. Fall back to a plain apply when the index can't be reinstated
// cleanly (e.g. conflicts), which is the prior behavior.
const withIndex = await execGit(['stash', 'apply', '--index', ref], directory);
if (withIndex.exitCode === 0) {
return { success: true, ref };
}
const result = await execGit(['stash', 'apply', ref], directory);
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to apply stash');
return { success: true, ref };
}
export async function dropGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const ref = options.ref || 'stash@{0}';
const result = await execGit(['stash', 'drop', ref], directory);
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to drop stash');
return { success: true, ref };
}
export async function popGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const ref = options.ref || 'stash@{0}';
await applyGitStash(directory, { ref });
await dropGitStash(directory, { ref });
return { success: true, ref };
}
/**
* 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;
parents: string[];
}
/**
* Resolve a log base ref using local-first semantics (mirrors web service.js).
*
* - Returns undefined when `from` is falsy/whitespace.
* - Returns `from` unchanged when the local ref resolves.
* - Returns `origin/<from>` when local is absent but the remote-tracking ref exists.
* - Returns `from` unchanged when neither resolves (lets git surface the error).
*/
async function resolveBaseRefForLog(
from: string | undefined,
directory: string
): Promise<string | undefined> {
const normalized = typeof from === 'string' ? from.trim() : undefined;
if (!normalized) return undefined;
const checkRef = async (ref: string): Promise<boolean> => {
const result = await execGit(['rev-parse', '--verify', ref], directory);
return result.exitCode === 0 && Boolean(result.stdout.trim());
};
if (await checkRef(normalized)) return normalized;
const originRef = `refs/remotes/origin/${normalized}`;
if (await checkRef(originRef)) return `origin/${normalized}`;
return normalized;
}
/**
* Get git log
*/
export async function getGitLog(
directory: string,
options?: { maxCount?: number; from?: string; to?: string; file?: string; all?: boolean }
): Promise<{ all: GitLogEntry[]; latest: GitLogEntry | null; total: number }> {
const maxCount = options?.maxCount || 50;
if (options?.all) {
const logArgs = [
'log',
`--max-count=${maxCount}`,
'--all',
'--topo-order',
'--date=iso',
'--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D',
'--shortstat',
];
const result = await execGit(logArgs, directory);
if (result.exitCode !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log');
}
const records = result.stdout
.split('\x1e')
.map((e) => e.trim())
.filter(Boolean);
const entries: GitLogEntry[] = [];
for (const record of records) {
const lines = record.split('\n').filter((l) => l.trim().length > 0);
const header = lines.shift() || '';
const [hash, parentsRaw, author_name, author_email, date, message, refsRaw] =
header.split('\x1f');
if (!hash) continue;
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
const refs = refsRaw ? refsRaw.trim() : '';
let filesChanged = 0;
let insertions = 0;
let deletions = 0;
for (const line of lines) {
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);
}
entries.push({
hash,
date: date || '',
message: message || '',
refs,
body: '',
author_name: author_name || '',
author_email: author_email || '',
filesChanged,
insertions,
deletions,
parents,
});
}
return { all: entries, latest: entries[0] || null, total: entries.length };
}
// Prefer the local ref; fall back to origin/<from> only when the local ref
// cannot be resolved (e.g. user has never checked out the base branch).
const resolvedFrom = await resolveBaseRefForLog(options?.from, directory);
const args = [
'log',
`--max-count=${maxCount}`,
'--date=iso',
'--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D',
'--shortstat',
];
if (resolvedFrom && options?.to) {
args.push(`${resolvedFrom}..${options.to}`);
} else if (resolvedFrom) {
args.push(`${resolvedFrom}..HEAD`);
} else if (options?.to) {
args.push(options.to);
}
if (options?.file) {
args.push('--', options.file);
}
const result = await execGit(args, directory);
if (result.exitCode !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log');
}
const records = result.stdout
.split('\x1e')
.map((entry) => entry.trim())
.filter(Boolean);
const statsMap = new Map<string, { filesChanged: number; insertions: number; deletions: number; parents: string[] }>();
for (const record of records) {
const lines = record.split('\n').filter((line) => line.trim().length > 0);
const header = lines.shift() || '';
const [hash, parentsRaw] = header.split('\x1f');
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
if (!hash) continue;
let filesChanged = 0;
let insertions = 0;
let deletions = 0;
for (const line of lines) {
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, parents });
}
const entries: GitLogEntry[] = [];
for (const record of records) {
const header = record.split('\n').filter((l) => l.trim().length > 0)[0] || '';
const [hash] = header.split('\x1f');
if (!hash) continue;
const stats = statsMap.get(hash) || { filesChanged: 0, insertions: 0, deletions: 0, parents: [] };
// Need to re-parse header fields for the final entries array
const lines = record.split('\n').filter((l) => l.trim().length > 0);
const lineHeader = lines.shift() || '';
const [, , author_name, author_email, date, message, refs] = lineHeader.split('\x1f');
entries.push({
hash,
date: date || '',
message: message || '',
refs: refs?.trim() || '',
body: '',
author_name: author_name || '',
author_email: author_email || '',
filesChanged: stats.filesChanged,
insertions: stats.insertions,
deletions: stats.deletions,
parents: stats.parents,
});
}
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 numstatResult = await execGit(['show', '--numstat', '--format=', hash], directory);
if (numstatResult.exitCode !== 0) {
return { files: [] };
}
const files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> = [];
const lines = numstatResult.stdout.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 isBinary = insertionsRaw === '-' && deletionsRaw === '-';
const insertions = isBinary ? 0 : (parseInt(insertionsRaw, 10) || 0);
const deletions = isBinary ? 0 : (parseInt(deletionsRaw, 10) || 0);
let changeType = 'M';
if (filePath.includes(' => ')) {
changeType = 'R';
}
files.push({ path: filePath, insertions, deletions, isBinary, changeType });
}
// Get accurate change types from --name-status
const nameStatusResult = await execGit(['show', '--name-status', '--format=', hash], directory);
if (nameStatusResult.exitCode === 0) {
const statusMap = new Map<string, string>();
for (const line of nameStatusResult.stdout.trim().split('\n').filter(Boolean)) {
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
if (match) {
const [, status, pathPart] = match;
statusMap.set(extractGitStatusPath(status, pathPart), status);
}
}
for (const file of files) {
const basePath = extractGitNumstatDestinationPath(file.path);
const status = statusMap.get(basePath) ?? statusMap.get(file.path);
if (status) {
file.changeType = status;
}
}
}
return { files };
}
export async function getCommitFileDiff(
directory: string,
hash: string,
filePath: string,
isBinary: boolean
): Promise<{ original: string; modified: string; isBinary: boolean }> {
if (isBinary) {
return { original: '', modified: '', isBinary: true };
}
const [originalResult, modifiedResult] = await Promise.all([
execGit(['show', `${hash}^:${filePath}`], directory),
execGit(['show', `${hash}:${filePath}`], directory),
]);
if (originalResult.exitCode !== 0 && modifiedResult.exitCode !== 0) {
throw new Error(`Failed to read file content at commit ${hash}`);
}
return {
original: originalResult.exitCode === 0 ? originalResult.stdout : '',
modified: modifiedResult.exitCode === 0 ? modifiedResult.stdout : '',
isBinary: false,
};
}
// ============== 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,
signCommits?: boolean | null,
signingKey?: string | null
): Promise<{ success: boolean }> {
const repo = await getRepository(directory);
// Build SSH command once if needed
const sshCommand = sshKey ? buildSshCommand(sshKey) : null;
const shouldSignCommits = signCommits === true && typeof signingKey === 'string' && signingKey.trim().length > 0;
if (repo) {
try {
await repo.setConfig('user.name', userName);
await repo.setConfig('user.email', userEmail);
if (sshCommand) {
await repo.setConfig('core.sshCommand', sshCommand);
}
if (shouldSignCommits) {
await repo.setConfig('gpg.format', 'ssh');
await repo.setConfig('user.signingkey', signingKey.trim());
await repo.setConfig('commit.gpgsign', 'true');
}
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);
}
if (shouldSignCommits) {
await execGit(['config', 'gpg.format', 'ssh'], directory);
await execGit(['config', 'user.signingkey', signingKey.trim()], directory);
await execGit(['config', 'commit.gpgsign', 'true'], 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());
}
export async function removeRemote(directory: string, remote: string): Promise<{ success: boolean }> {
const remoteName = String(remote || '').trim();
if (!remoteName) {
throw new Error('Remote name is required');
}
if (remoteName === 'origin') {
throw new Error('Cannot remove origin remote');
}
const result = await execGit(['remote', 'remove', remoteName], directory);
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || `Failed to remove remote ${remoteName}`);
}
return { success: true };
}
// ============== 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');
}
// ============== Commit Actions ==============
export async function checkoutCommit(directory: string, hash: string): Promise<{ success: boolean }> {
if (!isValidCommitHash(hash)) {
throw new Error('Invalid commit hash');
}
const result = await execGit(['checkout', hash], directory);
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Failed to checkout commit');
}
return { success: true };
}
export async function cherryPick(directory: string, hash: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
if (!isValidCommitHash(hash)) {
throw new Error('Invalid commit hash');
}
const result = await execGit(['cherry-pick', hash], directory);
if (result.exitCode === 0) {
return { success: true, conflict: false };
}
const output = (result.stdout + result.stderr).toLowerCase();
const isConflict =
output.includes('conflict') ||
output.includes('patch does not apply');
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 || 'Cherry-pick failed');
}
export async function revertCommit(directory: string, hash: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
if (!isValidCommitHash(hash)) {
throw new Error('Invalid commit hash');
}
const result = await execGit(['revert', '--no-commit', hash], directory);
if (result.exitCode === 0) {
return { success: true, conflict: false };
}
const output = (result.stdout + result.stderr).toLowerCase();
const isConflict =
output.includes('conflict') ||
output.includes('revert 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 || 'Revert failed');
}
export async function resetToCommit(
directory: string,
hash: string,
mode: 'soft' | 'mixed' | 'hard',
force = false
): Promise<{ success: boolean }> {
if (!isValidCommitHash(hash)) {
throw new Error('Invalid commit hash');
}
if (mode === 'hard' && !force) {
const statusResult = await execGit(['status', '--porcelain'], directory);
const isDirty = statusResult.stdout.trim().length > 0;
if (isDirty) {
throw new Error('Cannot hard reset: uncommitted changes in working tree. Stash or commit first, or use force.');
}
}
const result = await execGit(['reset', `--${mode}`, hash], directory);
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Reset failed');
}
return { success: true };
}
// ============== Worktree Validation & Canonicalization ==============
/**
* Resolve a path to its canonical (real) absolute path.
*/
async function canonicalizePath(filePath: string): Promise<string> {
try {
const realPath = await fs.promises.realpath(filePath);
return realPath;
} catch {
return path.resolve(filePath);
}
}
/**
* Validate that a directory is inside a given worktree root.
*/
export async function validateWorktreeDirectory(
directory: string,
worktreeRoot: string
): Promise<{
valid: boolean;
insideWorktreeRoot: boolean;
resolvedWorktreeRoot: string | null;
resolvedCwd: string | null;
}> {
const directoryPath = normalizeDirectoryPath(directory);
const rootPath = normalizeDirectoryPath(worktreeRoot);
if (!directoryPath || !rootPath) {
return { valid: false, insideWorktreeRoot: false, resolvedWorktreeRoot: null, resolvedCwd: null };
}
const isRepo = await checkIsGitRepository(directoryPath);
if (!isRepo) {
return { valid: false, insideWorktreeRoot: false, resolvedWorktreeRoot: null, resolvedCwd: null };
}
const resolvedCwd = await canonicalizePath(directoryPath);
const resolvedRoot = await canonicalizePath(rootPath);
const inside = resolvedCwd.startsWith(resolvedRoot + path.sep) || resolvedCwd === resolvedRoot;
return {
valid: true,
insideWorktreeRoot: inside,
resolvedWorktreeRoot: resolvedRoot,
resolvedCwd,
};
}
/**
* Canonicalize the worktree state for a directory, returning branch, headState,
* worktreeStatus, and attentionReason (merge/rebase/cherry-pick/revert).
*/
export async function canonicalizeWorktreeState(
directory: string
): Promise<{
worktreeRoot: string | null;
cwd: string | null;
branch: string | null;
headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo';
legacy: boolean;
degraded: boolean;
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
}> {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath) {
return {
worktreeRoot: null, cwd: null, branch: null,
headState: 'detached', worktreeStatus: 'not-a-repo',
legacy: false, degraded: false, attentionReason: null,
};
}
const isRepo = await checkIsGitRepository(directoryPath);
if (!isRepo) {
return {
worktreeRoot: null, cwd: null, branch: null,
headState: 'detached', worktreeStatus: 'not-a-repo',
legacy: false, degraded: false, attentionReason: null,
};
}
const cwd = await canonicalizePath(directoryPath);
let worktreeRoot: string | null = null;
let worktreeStatus: 'ready' | 'missing' | 'invalid' = 'ready';
let headState: 'branch' | 'detached' | 'unborn' = 'branch';
let branch: string | null = null;
let attentionReason: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null = null;
// Resolve worktree project context (worktreeRoot)
try {
const context = await resolveWorktreeProjectContext(directoryPath);
worktreeRoot = await canonicalizePath(context.worktreeRoot);
} catch {
worktreeStatus = 'invalid';
}
// Resolve head state and branch
try {
const symbolicRef = await execGit(['symbolic-ref', '-q', 'HEAD'], directoryPath);
if (symbolicRef.exitCode === 0 && symbolicRef.stdout.trim()) {
headState = 'branch';
branch = cleanBranchName(symbolicRef.stdout.trim());
} else {
const revParse = await execGit(['rev-parse', 'HEAD'], directoryPath);
if (revParse.exitCode !== 0 || !revParse.stdout.trim()) {
headState = 'unborn';
branch = null;
} else {
headState = 'detached';
branch = revParse.stdout.trim().slice(0, 7);
}
}
} catch {
headState = 'unborn';
branch = null;
}
// Detect attention reasons (merge, rebase, cherry-pick, revert)
try {
const mergeHead = await execGit(['rev-parse', '--verify', 'MERGE_HEAD'], directoryPath);
if (mergeHead.exitCode === 0) {
attentionReason = 'merge';
} else {
const fsp = fs.promises;
const rebaseMerge = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false);
const rebaseApply = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false);
if (rebaseMerge || rebaseApply) {
attentionReason = 'rebase';
} else {
const cherryPickHead = await fsp.stat(path.join(directoryPath, '.git', 'CHERRY_PICK_HEAD')).then(() => true).catch(() => false);
const revertHead = await fsp.stat(path.join(directoryPath, '.git', 'REVERT_HEAD')).then(() => true).catch(() => false);
if (cherryPickHead) attentionReason = 'cherry-pick';
else if (revertHead) attentionReason = 'revert';
}
}
} catch {
// Status check failed — ignore
}
return {
worktreeRoot,
cwd,
branch,
headState,
worktreeStatus,
legacy: false,
degraded: false,
attentionReason,
};
}