Files
openchamber/packages/vscode/src/gitService.ts
T
Iuliia Ivashko 081be1b7d0 feat(worktrees): ship upstream-first worktree flow across web + vscode (#418)
* feat: add worktree validation and deleteLocalBranch option

Add API to validate and create worktrees with new payload types
Allow deleting local branches when removing worktrees via UI and API
Introduce OpenCode style random names for worktrees when not provided

* feat: enable SSH/HTTPS transport detection for PR picker

Load remotes for the current project directory to inform PR picker options.
Determine preferred push transport from remotes and apply it.
Expose sshUrl in API for frontend to build SSH clone URLs

* feat: extend head repo with sshUrl and improve push error messages

Add sshUrl field to head repo mapping
Enhance push failure handling to display stderr or stdout details
Return push details on success

* fix: worktree path

* feat: worktree set upstream on creation

Enable pushing to upstream by default when no remote is specified
Remove per-remote dropdown for push actions and auto-use first/upstream remote
Update server and VSCode git services to support push without explicit remote and set upstream

* fix: worktree-name sanitization

* feat: rename worktree path field and branch prefix

* feat(worktrees): add git.worktree facade, validation endpoint, upstream/remote-aware creation, and non-blocking setup execution

* refactor(git): use git.worktree namespace in branch picker

* feat(worktrees): sync OpenCode sandbox metadata on create/remove

* fix(worktrees): accept new path key in workspace guard and validate remote startRef

* chore(docs): remove temporary worktree testing plan

* feat: add git worktree management API (list/create/delete/validate) for vscode

* feat: wire root tracking remote and defaults for new worktrees

Add resolveRootTrackingRemote to detect upstream remote for root branch
Apply upstream defaults when creating new worktrees to auto-set upstream
Replace validation and creation flow to use new worktreeCreate APIs

* feat(worktrees): enable root tracking remote handling
2026-02-13 19:18:22 +02:00

2742 lines
82 KiB
TypeScript

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