import type { GitStatus, GitDiffResponse, GetGitDiffOptions, GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, GitRemoveRemotePayload, GeneratedCommitMessage, GitWorktreeInfo, CreateGitWorktreePayload, GitWorktreeCreateResult, RemoveGitWorktreePayload, GitWorktreeValidationResult, CreateGitCommitOptions, GitCommitResult, GitPushResult, GitPullResult, GitLogOptions, GitLogResponse, GitCommitFilesResponse, GitIdentityProfile, GitIdentitySummary, DiscoveredGitCredential, MergeConflictDetails, } from './api/types'; declare global { interface Window { __OPENCHAMBER_DESKTOP_SERVER__?: { origin: string; opencodePort: number | null; apiPrefix: string; cliAvailable: boolean; }; } } const resolveBaseOrigin = (): string => { if (typeof window === 'undefined') { return ''; } const desktopOrigin = window.__OPENCHAMBER_DESKTOP_SERVER__?.origin; if (desktopOrigin) { return desktopOrigin; } return window.location.origin; }; const API_BASE = '/api/git'; const GIT_STATUS_CACHE_TTL_MS = 1200; const GIT_REPO_CHECK_CACHE_TTL_MS = 5000; const gitStatusCache = new Map(); const gitStatusInFlight = new Map>(); const gitRepoCache = new Map(); const gitRepoInFlight = new Map>(); const normalizeDirectoryKey = (directory: string): string => directory.trim(); function buildUrl( path: string, directory: string | null | undefined, params?: Record ): string { const url = new URL(path, resolveBaseOrigin()); if (directory) { url.searchParams.set('directory', directory); } if (params) { for (const [key, value] of Object.entries(params)) { if (value === undefined) continue; url.searchParams.set(key, String(value)); } } return url.toString(); } export async function checkIsGitRepository(directory: string): Promise { const key = normalizeDirectoryKey(directory); const now = Date.now(); const cached = gitRepoCache.get(key); if (cached && cached.expiresAt > now) { return cached.value; } const inFlight = gitRepoInFlight.get(key); if (inFlight) { return inFlight; } const task = (async () => { const response = await fetch(buildUrl(`${API_BASE}/check`, directory)); if (!response.ok) { throw new Error(`Failed to check git repository: ${response.statusText}`); } const data = await response.json(); const isGitRepository = Boolean(data.isGitRepository); gitRepoCache.set(key, { value: isGitRepository, expiresAt: Date.now() + GIT_REPO_CHECK_CACHE_TTL_MS, }); return isGitRepository; })(); gitRepoInFlight.set(key, task); try { return await task; } finally { if (gitRepoInFlight.get(key) === task) { gitRepoInFlight.delete(key); } } } export async function getGitStatus(directory: string): Promise { const key = normalizeDirectoryKey(directory); const now = Date.now(); const cached = gitStatusCache.get(key); if (cached && cached.expiresAt > now) { return cached.value; } const inFlight = gitStatusInFlight.get(key); if (inFlight) { return inFlight; } const task = (async () => { const response = await fetch(buildUrl(`${API_BASE}/status`, directory)); if (!response.ok) { throw new Error(`Failed to get git status: ${response.statusText}`); } const payload = await response.json() as GitStatus; gitStatusCache.set(key, { value: payload, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS, }); return payload; })(); gitStatusInFlight.set(key, task); try { return await task; } finally { if (gitStatusInFlight.get(key) === task) { gitStatusInFlight.delete(key); } } } export async function getGitDiff(directory: string, options: GetGitDiffOptions): Promise { const { path, staged, contextLines } = options; if (!path) { throw new Error('path is required to fetch git diff'); } const response = await fetch( buildUrl(`${API_BASE}/diff`, directory, { path, staged: staged ? 'true' : undefined, context: contextLines, }) ); if (!response.ok) { throw new Error(`Failed to get git diff: ${response.statusText}`); } return response.json(); } export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise { const { path, staged } = options; if (!path) { throw new Error('path is required to fetch git file diff'); } const response = await fetch( buildUrl(`${API_BASE}/file-diff`, directory, { path, staged: staged ? 'true' : undefined, }) ); if (!response.ok) { throw new Error(`Failed to get git file diff: ${response.statusText}`); } return response.json(); } export async function revertGitFile(directory: string, filePath: string): Promise { if (!filePath) { throw new Error('path is required to revert git changes'); } const response = await fetch(buildUrl(`${API_BASE}/revert`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: filePath }), }); if (!response.ok) { const message = await response .json() .catch(() => ({ error: response.statusText })); throw new Error(message.error || 'Failed to revert git changes'); } } export async function isLinkedWorktree(directory: string): Promise { if (!directory) { return false; } const response = await fetch(buildUrl(`${API_BASE}/worktree-type`, directory)); if (!response.ok) { throw new Error(`Failed to detect worktree type: ${response.statusText}`); } const data = await response.json(); return Boolean(data.linked); } export async function getGitBranches(directory: string): Promise { const response = await fetch(buildUrl(`${API_BASE}/branches`, directory)); if (!response.ok) { throw new Error(`Failed to get branches: ${response.statusText}`); } return response.json(); } export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> { if (!payload?.branch) { throw new Error('branch is required to delete a branch'); } const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to delete branch'); } return response.json(); } export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> { if (!payload?.branch) { throw new Error('branch is required to delete remote branch'); } const response = await fetch(buildUrl(`${API_BASE}/remote-branches`, directory), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to delete remote branch'); } return response.json(); } export async function removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> { const remote = payload?.remote?.trim(); if (!remote) { throw new Error('remote is required to remove a remote'); } const response = await fetch(buildUrl(`${API_BASE}/remotes`, directory), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ remote }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to remove remote'); } return response.json(); } export async function generateCommitMessage( directory: string, files: string[], options?: { zenModel?: string; providerId?: string; modelId?: string } ): Promise<{ message: GeneratedCommitMessage }> { if (!Array.isArray(files) || files.length === 0) { throw new Error('No files provided to generate commit message'); } const body: Record = { files }; if (options?.zenModel) { body.zenModel = options.zenModel; } if (options?.providerId) { body.providerId = options.providerId; } if (options?.modelId) { body.modelId = options.modelId; } const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); console.error('[git-generation][browser] http error', { status: response.status, statusText: response.statusText, error, }); const traceSuffix = typeof error?.traceId === 'string' && error.traceId ? ` (traceId: ${error.traceId})` : ''; throw new Error(`${error.error || 'Failed to generate commit message'}${traceSuffix}`); } const data = await response.json(); if (!data?.message || typeof data.message !== 'object') { throw new Error('Malformed commit generation response'); } const subject = typeof data.message.subject === 'string' && data.message.subject.trim().length > 0 ? data.message.subject.trim() : ''; const highlights: string[] = Array.isArray(data.message.highlights) ? (data.message.highlights as unknown[]) .filter((item): item is string => typeof item === 'string' && item.trim().length > 0) .map((item) => (item as string).trim()) : []; return { message: { subject, highlights, }, }; } export async function generatePullRequestDescription( directory: string, payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } ): Promise<{ title: string; body: string }> { const { base, head, context, zenModel, providerId, modelId } = payload; if (!base || !head) { throw new Error('base and head are required'); } const requestBody: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = { base, head }; if (context?.trim()) { requestBody.context = context.trim(); } if (zenModel) { requestBody.zenModel = zenModel; } if (providerId) { requestBody.providerId = providerId; } if (modelId) { requestBody.modelId = modelId; } const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to generate PR description'); } const data = await response.json().catch(() => null); const title = typeof data?.title === 'string' ? data.title : ''; const body = typeof data?.body === 'string' ? data.body : ''; if (!title && !body) { throw new Error('Malformed PR description response'); } return { title, body }; } export async function listGitWorktrees(directory: string): Promise { const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory)); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to list worktrees'); } return response.json(); } export async function validateGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise { const response = await fetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload ?? {}), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to validate worktree'); } return response.json(); } export async function createGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise { const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload ?? {}), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to create worktree'); } return response.json(); } export async function deleteGitWorktree(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> { const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload ?? {}), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to delete worktree'); } return response.json(); } export async function createGitCommit( directory: string, message: string, options: CreateGitCommitOptions = {} ): Promise { const response = await fetch(buildUrl(`${API_BASE}/commit`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, addAll: options.addAll ?? false, files: options.files, }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to create commit'); } return response.json(); } export async function gitPush( directory: string, options: { remote?: string; branch?: string; options?: string[] | Record } = {} ): Promise { const response = await fetch(buildUrl(`${API_BASE}/push`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to push'); } return response.json(); } export async function gitPull( directory: string, options: { remote?: string; branch?: string } = {} ): Promise { const response = await fetch(buildUrl(`${API_BASE}/pull`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to pull'); } return response.json(); } export async function gitFetch( directory: string, options: { remote?: string; branch?: string } = {} ): Promise<{ success: boolean }> { const response = await fetch(buildUrl(`${API_BASE}/fetch`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to fetch'); } return response.json(); } export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { const response = await fetch(buildUrl(`${API_BASE}/checkout`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ branch }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to checkout branch'); } return response.json(); } export async function createBranch( directory: string, name: string, startPoint?: string ): Promise<{ success: boolean; branch: string }> { const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, startPoint }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to create branch'); } return response.json(); } export async function renameBranch( directory: string, oldName: string, newName: string ): Promise<{ success: boolean; branch: string }> { const response = await fetch(buildUrl(`${API_BASE}/branches/rename`, directory), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oldName, newName }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to rename branch'); } return response.json(); } export async function getGitLog( directory: string, options: GitLogOptions = {} ): Promise { const response = await fetch( buildUrl(`${API_BASE}/log`, directory, { maxCount: options.maxCount, from: options.from, to: options.to, file: options.file, }) ); if (!response.ok) { throw new Error(`Failed to get git log: ${response.statusText}`); } return response.json(); } export async function getCommitFiles( directory: string, hash: string ): Promise { const response = await fetch( buildUrl(`${API_BASE}/commit-files`, directory, { hash }) ); if (!response.ok) { throw new Error(`Failed to get commit files: ${response.statusText}`); } return response.json(); } export async function getGitIdentities(): Promise { const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined)); if (!response.ok) { throw new Error(`Failed to get git identities: ${response.statusText}`); } return response.json(); } export async function createGitIdentity(profile: GitIdentityProfile): Promise { const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to create git identity'); } return response.json(); } export async function updateGitIdentity(id: string, updates: GitIdentityProfile): Promise { const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to update git identity'); } return response.json(); } export async function deleteGitIdentity(id: string): Promise { const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), { method: 'DELETE', }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to delete git identity'); } } export async function getCurrentGitIdentity(directory: string): Promise { if (!directory) { return null; } const response = await fetch(buildUrl(`${API_BASE}/current-identity`, directory)); if (!response.ok) { throw new Error(`Failed to get current git identity: ${response.statusText}`); } const data = await response.json(); if (!data) { return null; } return { userName: data.userName ?? null, userEmail: data.userEmail ?? null, sshCommand: data.sshCommand ?? null, }; } export async function hasLocalIdentity(directory: string): Promise { if (!directory) { return false; } const response = await fetch(buildUrl(`${API_BASE}/has-local-identity`, directory)); if (!response.ok) { throw new Error(`Failed to check local identity: ${response.statusText}`); } const data = await response.json().catch(() => null); return data?.hasLocalIdentity === true; } export async function getGlobalGitIdentity(): Promise { const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined)); if (!response.ok) { throw new Error(`Failed to get global git identity: ${response.statusText}`); } const data = await response.json(); if (!data || (!data.userName && !data.userEmail)) { return null; } return { userName: data.userName ?? null, userEmail: data.userEmail ?? null, sshCommand: data.sshCommand ?? null, }; } export async function setGitIdentity( directory: string, profileId: string ): Promise<{ success: boolean; profile: GitIdentityProfile }> { const response = await fetch(buildUrl(`${API_BASE}/set-identity`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to set git identity'); } return response.json(); } export async function discoverGitCredentials(): Promise { const response = await fetch(buildUrl(`${API_BASE}/discover-credentials`, undefined)); if (!response.ok) { throw new Error(`Failed to discover git credentials: ${response.statusText}`); } return response.json(); } export async function getRemoteUrl(directory: string, remote?: string): Promise { if (!directory) { return null; } const response = await fetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote })); if (!response.ok) { return null; } const data = await response.json(); return data.url ?? null; } export async function getRemotes(directory: string): Promise> { const response = await fetch(buildUrl(`${API_BASE}/remotes`, directory)); if (!response.ok) { throw new Error(`Failed to get remotes: ${response.statusText}`); } return response.json(); } export async function rebase( directory: string, options: { onto: string } ): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> { const response = await fetch(buildUrl(`${API_BASE}/rebase`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to rebase'); } return response.json(); } export async function abortRebase(directory: string): Promise<{ success: boolean }> { const response = await fetch(buildUrl(`${API_BASE}/rebase/abort`, directory), { method: 'POST', }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to abort rebase'); } return response.json(); } export async function merge( directory: string, options: { branch: string } ): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> { const response = await fetch(buildUrl(`${API_BASE}/merge`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to merge'); } return response.json(); } export async function abortMerge(directory: string): Promise<{ success: boolean }> { const response = await fetch(buildUrl(`${API_BASE}/merge/abort`, directory), { method: 'POST', }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to abort merge'); } return response.json(); } export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { const response = await fetch(buildUrl(`${API_BASE}/rebase/continue`, directory), { method: 'POST', }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to continue rebase'); } return response.json(); } export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { const response = await fetch(buildUrl(`${API_BASE}/merge/continue`, directory), { method: 'POST', }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to continue merge'); } return response.json(); } export async function stash( directory: string, options?: { message?: string; includeUntracked?: boolean } ): Promise<{ success: boolean }> { const response = await fetch(buildUrl(`${API_BASE}/stash`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options || {}), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to stash'); } return response.json(); } export async function stashPop(directory: string): Promise<{ success: boolean }> { const response = await fetch(buildUrl(`${API_BASE}/stash/pop`, directory), { method: 'POST', }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to pop stash'); } return response.json(); } export async function getConflictDetails(directory: string): Promise { const response = await fetch(buildUrl(`${API_BASE}/conflict-details`, directory)); if (!response.ok) { throw new Error(`Failed to get conflict details: ${response.statusText}`); } return response.json(); }