export function registerGitRoutes(app) { let gitLibraries = null; const getGitLibraries = async () => { if (!gitLibraries) { gitLibraries = await import('./index.js'); } return gitLibraries; }; const resolveDirectoryQuery = (value, preserveWhitespace = false) => { const raw = Array.isArray(value) ? value[0] : value; if (typeof raw !== 'string') { return null; } const normalized = preserveWhitespace ? raw : raw.trim(); return normalized || null; }; const extractGitErrorText = (error) => { const message = typeof error?.message === 'string' ? error.message : ''; const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; const fallback = !message && error != null ? String(error) : ''; return [message, stderr, stdout, fallback] .map((value) => String(value || '').trim()) .filter(Boolean) .join('\n'); }; const isNonRepoGitError = (error) => /not a git repository/i.test(extractGitErrorText(error)); const nonRepoStatusPayload = () => ({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0, }); app.get('/api/git/identities', async (req, res) => { const { getProfiles } = await getGitLibraries(); try { const profiles = getProfiles(); res.json(profiles); } catch (error) { console.error('Failed to list git identity profiles:', error); res.status(500).json({ error: 'Failed to list git identity profiles' }); } }); app.post('/api/git/identities', async (req, res) => { const { createProfile } = await getGitLibraries(); try { const profile = createProfile(req.body); console.log(`Created git identity profile: ${profile.name} (${profile.id})`); res.json(profile); } catch (error) { console.error('Failed to create git identity profile:', error); res.status(400).json({ error: error.message || 'Failed to create git identity profile' }); } }); app.put('/api/git/identities/:id', async (req, res) => { const { updateProfile } = await getGitLibraries(); try { const profile = updateProfile(req.params.id, req.body); console.log(`Updated git identity profile: ${profile.name} (${profile.id})`); res.json(profile); } catch (error) { console.error('Failed to update git identity profile:', error); res.status(400).json({ error: error.message || 'Failed to update git identity profile' }); } }); app.delete('/api/git/identities/:id', async (req, res) => { const { deleteProfile } = await getGitLibraries(); try { deleteProfile(req.params.id); console.log(`Deleted git identity profile: ${req.params.id}`); res.json({ success: true }); } catch (error) { console.error('Failed to delete git identity profile:', error); res.status(400).json({ error: error.message || 'Failed to delete git identity profile' }); } }); app.get('/api/git/global-identity', async (req, res) => { const { getGlobalIdentity } = await getGitLibraries(); try { const identity = await getGlobalIdentity(); res.json(identity); } catch (error) { console.error('Failed to get global git identity:', error); res.status(500).json({ error: 'Failed to get global git identity' }); } }); app.get('/api/git/discover-credentials', async (req, res) => { try { const { discoverGitCredentials } = await import('./index.js'); const credentials = discoverGitCredentials(); res.json(credentials); } catch (error) { console.error('Failed to discover git credentials:', error); res.status(500).json({ error: 'Failed to discover git credentials' }); } }); app.get('/api/git/check', async (req, res) => { const { isGitRepository } = await getGitLibraries(); try { const directory = resolveDirectoryQuery(req.query.directory); if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const isRepo = await isGitRepository(directory); res.json({ isGitRepository: isRepo }); } catch (error) { if (isNonRepoGitError(error)) { console.warn('Git check treated non-repository path as not a git repo:', extractGitErrorText(error)); return res.json({ isGitRepository: false }); } console.error('Failed to check git repository:', error); res.status(500).json({ error: 'Failed to check git repository' }); } }); app.get('/api/git/remote-url', async (req, res) => { const { getRemoteUrl } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const remote = req.query.remote || 'origin'; const url = await getRemoteUrl(directory, remote); res.json({ url }); } catch (error) { console.error('Failed to get remote url:', error); res.status(500).json({ error: 'Failed to get remote url' }); } }); app.get('/api/git/current-identity', async (req, res) => { const { getCurrentIdentity } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const identity = await getCurrentIdentity(directory); res.json(identity); } catch (error) { console.error('Failed to get current git identity:', error); res.status(500).json({ error: 'Failed to get current git identity' }); } }); app.get('/api/git/has-local-identity', async (req, res) => { const { hasLocalIdentity } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const hasLocal = await hasLocalIdentity(directory); res.json({ hasLocalIdentity: hasLocal }); } catch (error) { console.error('Failed to check local git identity:', error); res.status(500).json({ error: 'Failed to check local git identity' }); } }); app.post('/api/git/set-identity', async (req, res) => { const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { profileId } = req.body; if (!profileId) { return res.status(400).json({ error: 'profileId is required' }); } let profile = null; if (profileId === 'global') { const globalIdentity = await getGlobalIdentity(); if (!globalIdentity?.userName || !globalIdentity?.userEmail) { return res.status(404).json({ error: 'Global identity is not configured' }); } profile = { id: 'global', name: 'Global Identity', userName: globalIdentity.userName, userEmail: globalIdentity.userEmail, sshKey: globalIdentity.sshCommand ? globalIdentity.sshCommand.replace('ssh -i ', '') : null, }; } else { profile = getProfile(profileId); if (!profile) { return res.status(404).json({ error: 'Profile not found' }); } } await setLocalIdentity(directory, profile); res.json({ success: true, profile }); } catch (error) { console.error('Failed to set git identity:', error); res.status(500).json({ error: error.message || 'Failed to set git identity' }); } }); app.get('/api/git/status', async (req, res) => { const { getStatus, isGitRepository } = await getGitLibraries(); try { const directory = resolveDirectoryQuery(req.query.directory); if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const isRepo = await isGitRepository(directory); if (!isRepo) { return res.json(nonRepoStatusPayload()); } const mode = req.query.mode === 'light' ? 'light' : undefined; const status = await getStatus(directory, { mode }); res.json(status); } catch (error) { // Non-repo / GitError must not abort callers that enumerate projects or // sessions (e.g. sidebar discovery). Log a warning and continue. if (isNonRepoGitError(error)) { console.warn('Git status skipped for non-repository path:', extractGitErrorText(error)); return res.json(nonRepoStatusPayload()); } console.error('Failed to get git status:', error); res.status(500).json({ error: error.message || 'Failed to get git status' }); } }); app.get('/api/git/primary-root', async (req, res) => { const { resolvePrimaryWorktreeRoot } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await resolvePrimaryWorktreeRoot(directory); res.json(result); } catch (error) { console.error('Failed to resolve git primary root:', error); res.status(500).json({ error: error.message || 'Failed to resolve git primary root' }); } }); app.get('/api/git/toplevel', async (req, res) => { const { resolveWorktreeTopLevel } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await resolveWorktreeTopLevel(directory); res.json(result); } catch (error) { console.error('Failed to resolve git worktree toplevel:', error); res.status(500).json({ error: error.message || 'Failed to resolve git worktree toplevel' }); } }); app.post('/api/git/commit-summaries', async (req, res) => { const { getCommitSummaries } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await getCommitSummaries(directory, req.body?.shas); res.json(result); } catch (error) { console.error('Failed to get git commit summaries:', error); res.status(400).json({ error: error.message || 'Failed to get git commit summaries' }); } }); const handleIntegrateAction = (action, loadHandler) => { app.post(`/api/git/integrate/${action}`, async (req, res) => { try { const handler = await loadHandler(); const result = await handler(req.body || {}); res.json(result); } catch (error) { console.error(`Failed to run git integrate ${action}:`, error); res.status(400).json({ error: error.message || `Failed to run git integrate ${action}` }); } }); }; handleIntegrateAction('plan', async () => { const { computeIntegratePlan } = await getGitLibraries(); return (body) => computeIntegratePlan(body); }); handleIntegrateAction('conflict-details', async () => { const { getIntegrateConflictDetails } = await getGitLibraries(); return (body) => getIntegrateConflictDetails(body?.tempWorktreePath); }); handleIntegrateAction('cherry-pick-status', async () => { const { isCherryPickInProgress } = await getGitLibraries(); return (body) => isCherryPickInProgress(body?.tempWorktreePath); }); handleIntegrateAction('run', async () => { const { integrateWorktreeCommits } = await getGitLibraries(); return (body) => integrateWorktreeCommits(body?.plan); }); handleIntegrateAction('abort', async () => { const { abortIntegrate } = await getGitLibraries(); return (body) => abortIntegrate(body?.state); }); handleIntegrateAction('continue', async () => { const { continueIntegrate } = await getGitLibraries(); return (body) => continueIntegrate(body?.state); }); app.get('/api/git/diff', async (req, res) => { const { getDiff } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const path = req.query.path; if (!path || typeof path !== 'string') { return res.status(400).json({ error: 'path parameter is required' }); } const staged = req.query.staged === 'true'; const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined; const diff = await getDiff(directory, { path, staged, contextLines: Number.isFinite(context) ? context : 3, }); res.json({ diff }); } catch (error) { console.error('Failed to get git diff:', error); res.status(500).json({ error: error.message || 'Failed to get git diff' }); } }); app.get('/api/git/file-diff', async (req, res) => { const { getFileDiff } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } const pathParam = req.query.path; if (!pathParam || typeof pathParam !== 'string') { return res.status(400).json({ error: 'path parameter is required' }); } const staged = req.query.staged === 'true'; const result = await getFileDiff(directory, { path: pathParam, staged, }); res.json({ original: result.original, modified: result.modified, path: result.path, isBinary: Boolean(result.isBinary), }); } catch (error) { console.error('Failed to get git file diff:', error); res.status(500).json({ error: error.message || 'Failed to get git file diff' }); } }); app.get('/api/git/range-diff', async (req, res) => { const { getRangeDiff } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } const base = req.query.base; const head = req.query.head; if (!base || typeof base !== 'string' || !head || typeof head !== 'string') { return res.status(400).json({ error: 'base and head parameters are required' }); } const pathParam = typeof req.query.path === 'string' && req.query.path ? req.query.path : undefined; const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined; const diff = await getRangeDiff(directory, { base, head, includeWorkingTree: req.query.includeWorkingTree === 'true', path: pathParam, contextLines: Number.isFinite(context) ? context : 3, }); res.json({ diff }); } catch (error) { console.error('Failed to get git range diff:', error); res.status(500).json({ error: error.message || 'Failed to get git range diff' }); } }); app.get('/api/git/branch-base', async (req, res) => { const { getBranchBase } = await getGitLibraries(); try { const directory = resolveDirectoryQuery(req.query.directory); if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const branch = resolveDirectoryQuery(req.query.branch); if (!branch) { return res.status(400).json({ error: 'branch parameter is required' }); } const result = await getBranchBase(directory, branch); res.json(result); } catch (error) { console.error('Failed to get branch base:', error); res.status(500).json({ error: error.message || 'Failed to get branch base' }); } }); app.get('/api/git/range-files', async (req, res) => { const { getRangeFiles } = await getGitLibraries(); try { const directory = resolveDirectoryQuery(req.query.directory); if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const base = resolveDirectoryQuery(req.query.base); const head = resolveDirectoryQuery(req.query.head); if (!base || !head) { return res.status(400).json({ error: 'base and head parameters are required' }); } const files = await getRangeFiles(directory, { base, head, includeWorkingTree: req.query.includeWorkingTree === 'true' }); res.json({ files }); } catch (error) { console.error('Failed to get git range files:', error); res.status(500).json({ error: error.message || 'Failed to get git range files' }); } }); app.post('/api/git/revert', async (req, res) => { const { revertFile } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { path, scope } = req.body || {}; if (!path || typeof path !== 'string') { return res.status(400).json({ error: 'path parameter is required' }); } await revertFile(directory, path, { scope }); res.json({ success: true }); } catch (error) { console.error('Failed to revert git file:', error); res.status(500).json({ error: error.message || 'Failed to revert git file' }); } }); app.post('/api/git/stage', async (req, res) => { const { stageFiles } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { path, paths } = req.body || {}; const filePaths = Array.isArray(paths) ? paths : [path]; if (!filePaths.some((value) => typeof value === 'string' && value.trim())) { return res.status(400).json({ error: 'path parameter is required' }); } await stageFiles(directory, filePaths); res.json({ success: true }); } catch (error) { console.error('Failed to stage git file:', error); res.status(500).json({ error: error.message || 'Failed to stage git file' }); } }); app.post('/api/git/unstage', async (req, res) => { const { unstageFiles } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { path, paths } = req.body || {}; const filePaths = Array.isArray(paths) ? paths : [path]; if (!filePaths.some((value) => typeof value === 'string' && value.trim())) { return res.status(400).json({ error: 'path parameter is required' }); } await unstageFiles(directory, filePaths); res.json({ success: true }); } catch (error) { console.error('Failed to unstage git file:', error); res.status(500).json({ error: error.message || 'Failed to unstage git file' }); } }); app.post('/api/git/apply-hunk', async (req, res) => { const { applyHunk } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { path: filePath, patch, action } = req.body || {}; if (!filePath || typeof filePath !== 'string') { return res.status(400).json({ error: 'path parameter is required' }); } if (typeof patch !== 'string' || !patch.trim()) { return res.status(400).json({ error: 'patch is required' }); } if (action !== 'stage' && action !== 'unstage' && action !== 'discard') { return res.status(400).json({ error: 'action must be stage, unstage, or discard' }); } await applyHunk(directory, filePath, { patch, action }); res.json({ success: true }); } catch (error) { console.error('Failed to apply git hunk:', error); res.status(500).json({ error: error.message || 'Failed to apply git hunk' }); } }); app.post('/api/git/pull', async (req, res) => { const { pull } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await pull(directory, req.body); res.json(result); } catch (error) { console.error('Failed to pull:', error); res.status(500).json({ error: error.message || 'Failed to pull from remote' }); } }); app.post('/api/git/push', async (req, res) => { const { push } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await push(directory, req.body); res.json(result); } catch (error) { console.error('Failed to push:', error); res.status(500).json({ error: error.message || 'Failed to push to remote' }); } }); app.get('/api/git/stashes', async (req, res) => { const { listStashes } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); res.json({ stashes: await listStashes(directory) }); } catch (error) { console.error('Failed to list stashes:', error); res.status(500).json({ error: error.message || 'Failed to list stashes' }); } }); app.post('/api/git/stashes/file-counts', async (req, res) => { const { countStashFiles } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); res.json({ counts: await countStashFiles(directory, req.body?.refs) }); } catch (error) { console.error('Failed to count stash files:', error); res.status(500).json({ error: error.message || 'Failed to count stash files' }); } }); app.post('/api/git/stash', async (req, res) => { const { stashPush } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); res.json(await stashPush(directory, req.body)); } catch (error) { console.error('Failed to stash changes:', error); res.status(500).json({ error: error.message || 'Failed to stash changes' }); } }); app.post('/api/git/stash/apply', async (req, res) => { const { stashApply } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); res.json(await stashApply(directory, req.body)); } catch (error) { console.error('Failed to apply stash:', error); res.status(500).json({ error: error.message || 'Failed to apply stash' }); } }); app.post('/api/git/stash/pop', async (req, res) => { const { stashPop } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); res.json(await stashPop(directory, req.body)); } catch (error) { console.error('Failed to pop stash:', error); res.status(500).json({ error: error.message || 'Failed to pop stash' }); } }); app.post('/api/git/stash/drop', async (req, res) => { const { stashDrop } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); res.json(await stashDrop(directory, req.body)); } catch (error) { console.error('Failed to drop stash:', error); res.status(500).json({ error: error.message || 'Failed to drop stash' }); } }); app.post('/api/git/fetch', async (req, res) => { const { fetch: gitFetch } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await gitFetch(directory, req.body); res.json(result); } catch (error) { console.error('Failed to fetch:', error); res.status(500).json({ error: error.message || 'Failed to fetch from remote' }); } }); app.get('/api/git/remotes', async (req, res) => { const { getRemotes } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const remotes = await getRemotes(directory); res.json(remotes); } catch (error) { console.error('Failed to get remotes:', error); res.status(500).json({ error: error.message || 'Failed to get remotes' }); } }); app.delete('/api/git/remotes', async (req, res) => { const { removeRemote } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const remote = String(req.body?.remote || '').trim(); if (!remote) { return res.status(400).json({ error: 'remote is required' }); } const result = await removeRemote(directory, { remote }); res.json(result); } catch (error) { console.error('Failed to remove remote:', error); res.status(500).json({ error: error.message || 'Failed to remove remote' }); } }); app.post('/api/git/rebase', async (req, res) => { const { rebase } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await rebase(directory, req.body); res.json(result); } catch (error) { console.error('Failed to rebase:', error); res.status(500).json({ error: error.message || 'Failed to rebase' }); } }); app.post('/api/git/rebase/abort', async (req, res) => { const { abortRebase } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await abortRebase(directory); res.json(result); } catch (error) { console.error('Failed to abort rebase:', error); res.status(500).json({ error: error.message || 'Failed to abort rebase' }); } }); app.post('/api/git/merge', async (req, res) => { const { merge } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await merge(directory, req.body); res.json(result); } catch (error) { console.error('Failed to merge:', error); res.status(500).json({ error: error.message || 'Failed to merge' }); } }); app.post('/api/git/merge/abort', async (req, res) => { const { abortMerge } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await abortMerge(directory); res.json(result); } catch (error) { console.error('Failed to abort merge:', error); res.status(500).json({ error: error.message || 'Failed to abort merge' }); } }); app.post('/api/git/rebase/continue', async (req, res) => { const { continueRebase } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await continueRebase(directory); res.json(result); } catch (error) { console.error('Failed to continue rebase:', error); res.status(500).json({ error: error.message || 'Failed to continue rebase' }); } }); app.post('/api/git/merge/continue', async (req, res) => { const { continueMerge } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await continueMerge(directory); res.json(result); } catch (error) { console.error('Failed to continue merge:', error); res.status(500).json({ error: error.message || 'Failed to continue merge' }); } }); app.get('/api/git/conflict-details', async (req, res) => { const { getConflictDetails } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await getConflictDetails(directory); res.json(result); } catch (error) { console.error('Failed to get conflict details:', error); res.status(500).json({ error: error.message || 'Failed to get conflict details' }); } }); app.post('/api/git/commit', async (req, res) => { const { commit } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { message, addAll, files, stageFiles } = req.body; if (!message) { return res.status(400).json({ error: 'message is required' }); } const result = await commit(directory, message, { addAll, files, stageFiles, }); res.json(result); } catch (error) { console.error('Failed to commit:', error); res.status(500).json({ error: error.message || 'Failed to create commit' }); } }); app.get('/api/git/branches', async (req, res) => { const { getBranches } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const branches = await getBranches(directory); res.json(branches); } catch (error) { console.error('Failed to get branches:', error); res.status(500).json({ error: error.message || 'Failed to get branches' }); } }); app.post('/api/git/branch-push-status', async (req, res) => { const { getUnpushedBranchCounts } = await getGitLibraries(); try { const directory = req.query.directory; const branches = req.body?.branches; if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) { return res.status(400).json({ error: 'branches must be an array of branch names' }); } res.json(await getUnpushedBranchCounts(directory, branches)); } catch (error) { console.error('Failed to get branch push status:', error); res.status(500).json({ error: error.message || 'Failed to get branch push status' }); } }); app.post('/api/git/branches', async (req, res) => { const { createBranch } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { name, startPoint } = req.body; if (!name) { return res.status(400).json({ error: 'name is required' }); } const result = await createBranch(directory, name, { startPoint }); res.json(result); } catch (error) { console.error('Failed to create branch:', error); res.status(500).json({ error: error.message || 'Failed to create branch' }); } }); app.delete('/api/git/branches', async (req, res) => { const { deleteBranch } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { branch, force } = req.body; if (!branch) { return res.status(400).json({ error: 'branch is required' }); } const result = await deleteBranch(directory, branch, { force }); res.json(result); } catch (error) { console.error('Failed to delete branch:', error); res.status(500).json({ error: error.message || 'Failed to delete branch' }); } }); app.put('/api/git/branches/rename', async (req, res) => { const { renameBranch } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { oldName, newName } = req.body; if (!oldName) { return res.status(400).json({ error: 'oldName is required' }); } if (!newName) { return res.status(400).json({ error: 'newName is required' }); } const result = await renameBranch(directory, oldName, newName); res.json(result); } catch (error) { console.error('Failed to rename branch:', error); res.status(500).json({ error: error.message || 'Failed to rename branch' }); } }); app.delete('/api/git/remote-branches', async (req, res) => { const { deleteRemoteBranch } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { branch, remote } = req.body; if (!branch) { return res.status(400).json({ error: 'branch is required' }); } const result = await deleteRemoteBranch(directory, { branch, remote }); res.json(result); } catch (error) { console.error('Failed to delete remote branch:', error); res.status(500).json({ error: error.message || 'Failed to delete remote branch' }); } }); app.post('/api/git/checkout', async (req, res) => { const { checkoutBranch } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { branch } = req.body; if (!branch) { return res.status(400).json({ error: 'branch is required' }); } const result = await checkoutBranch(directory, branch); res.json(result); } catch (error) { console.error('Failed to checkout branch:', error); res.status(500).json({ error: error.message || 'Failed to checkout branch' }); } }); app.post('/api/git/checkout-commit', async (req, res) => { const { checkoutCommit } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { hash } = req.body; if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) { return res.status(400).json({ error: 'Invalid commit hash' }); } const result = await checkoutCommit(directory, hash); res.json(result); } catch (error) { console.error('Failed to checkout commit:', error); res.status(500).json({ error: error.message || 'Failed to checkout commit' }); } }); app.post('/api/git/cherry-pick', async (req, res) => { const { cherryPick } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { hash } = req.body; if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) { return res.status(400).json({ error: 'Invalid commit hash' }); } const result = await cherryPick(directory, hash); res.json(result); } catch (error) { console.error('Failed to cherry-pick:', error); res.status(500).json({ error: error.message || 'Failed to cherry-pick' }); } }); app.post('/api/git/revert-commit', async (req, res) => { const { revertCommit } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { hash } = req.body; if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) { return res.status(400).json({ error: 'Invalid commit hash' }); } const result = await revertCommit(directory, hash); res.json(result); } catch (error) { console.error('Failed to revert commit:', error); res.status(500).json({ error: error.message || 'Failed to revert commit' }); } }); app.post('/api/git/reset-to-commit', async (req, res) => { const { resetToCommit } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { hash, mode, force } = req.body; if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) { return res.status(400).json({ error: 'Invalid commit hash' }); } if (!['soft', 'mixed', 'hard'].includes(mode)) { return res.status(400).json({ error: 'mode must be soft, mixed, or hard' }); } const result = await resetToCommit(directory, hash, mode, force === true); res.json(result); } catch (error) { console.error('Failed to reset to commit:', error); res.status(500).json({ error: error.message || 'Failed to reset' }); } }); app.get('/api/git/worktrees', async (req, res) => { const { getWorktrees } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const worktrees = await getWorktrees(directory); res.json(worktrees); } catch (error) { // Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries) // when the directory isn't a git repo or uses shell shorthand like "~/". console.warn('Failed to get worktrees, returning empty list:', error?.message || error); res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable'); res.json([]); } }); app.post('/api/git/worktrees/validate', async (req, res) => { const { validateWorktreeCreate } = await getGitLibraries(); if (typeof validateWorktreeCreate !== 'function') { return res.status(501).json({ error: 'Worktree validation is not available' }); } try { const directory = req.query.directory; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } const result = await validateWorktreeCreate(directory, req.body || {}); res.json(result); } catch (error) { console.error('Failed to validate worktree creation:', error); res.status(500).json({ error: error.message || 'Failed to validate worktree creation' }); } }); app.post('/api/git/worktrees', async (req, res) => { const { createWorktree } = await getGitLibraries(); if (typeof createWorktree !== 'function') { return res.status(501).json({ error: 'Worktree creation is not available' }); } try { const directory = req.query.directory; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } const created = await createWorktree(directory, req.body || {}); res.json(created); } catch (error) { console.error('Failed to create worktree:', error); res.status(500).json({ error: error.message || 'Failed to create worktree' }); } }); app.post('/api/git/worktrees/preview', async (req, res) => { const { previewWorktreeCreate } = await getGitLibraries(); if (typeof previewWorktreeCreate !== 'function') { return res.status(501).json({ error: 'Worktree preview is not available' }); } try { const directory = req.query.directory; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } const preview = await previewWorktreeCreate(directory, req.body || {}); res.json(preview); } catch (error) { console.error('Failed to preview worktree:', error); res.status(500).json({ error: error.message || 'Failed to preview worktree' }); } }); app.get('/api/git/worktrees/bootstrap-status', async (req, res) => { const { getWorktreeBootstrapStatus } = await getGitLibraries(); if (typeof getWorktreeBootstrapStatus !== 'function') { return res.status(501).json({ error: 'Worktree bootstrap status is not available' }); } try { const directory = req.query.directory; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } const status = await getWorktreeBootstrapStatus(directory); res.json(status); } catch (error) { console.error('Failed to get worktree bootstrap status:', error); res.status(500).json({ error: error.message || 'Failed to get worktree bootstrap status' }); } }); app.delete('/api/git/worktrees', async (req, res) => { const { removeWorktree } = await getGitLibraries(); if (typeof removeWorktree !== 'function') { return res.status(501).json({ error: 'Worktree removal is not available' }); } try { const directory = req.query.directory; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : ''; if (!worktreeDirectory) { return res.status(400).json({ error: 'worktree directory is required' }); } const result = await removeWorktree(directory, { directory: worktreeDirectory, deleteLocalBranch: req.body?.deleteLocalBranch === true, }); res.json({ success: Boolean(result) }); } catch (error) { console.error('Failed to remove worktree:', error); res.status(500).json({ error: error.message || 'Failed to remove worktree' }); } }); app.get('/api/git/worktree-type', async (req, res) => { const { isLinkedWorktree } = await getGitLibraries(); try { const { directory } = req.query; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } const linked = await isLinkedWorktree(directory); res.json({ linked }); } catch (error) { console.error('Failed to determine worktree type:', error); res.status(500).json({ error: error.message || 'Failed to determine worktree type' }); } }); app.post('/api/git/validate-directory', async (req, res) => { const { validateWorktreeDirectory } = await getGitLibraries(); if (typeof validateWorktreeDirectory !== 'function') { return res.status(501).json({ error: 'validateWorktreeDirectory is not available' }); } try { const { directory, worktreeRoot } = req.body || {}; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory is required' }); } if (!worktreeRoot || typeof worktreeRoot !== 'string') { return res.status(400).json({ error: 'worktreeRoot is required' }); } const result = await validateWorktreeDirectory(directory, worktreeRoot); res.json(result); } catch (error) { console.error('Failed to validate worktree directory:', error); res.status(500).json({ error: error.message || 'Failed to validate worktree directory' }); } }); app.post('/api/git/canonicalize-worktree-state', async (req, res) => { const { canonicalizeWorktreeState } = await getGitLibraries(); if (typeof canonicalizeWorktreeState !== 'function') { return res.status(501).json({ error: 'canonicalizeWorktreeState is not available' }); } try { const { directory } = req.body || {}; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory is required' }); } const result = await canonicalizeWorktreeState(directory); res.json(result); } catch (error) { console.error('Failed to canonicalize worktree state:', error); res.status(500).json({ error: error.message || 'Failed to canonicalize worktree state' }); } }); app.get('/api/git/log', async (req, res) => { const { getLog } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const { maxCount, from, to, file } = req.query; const all = req.query.all === 'true'; const log = await getLog(directory, { maxCount: maxCount ? parseInt(maxCount) : undefined, from, to, file, all }); res.json(log); } catch (error) { console.error('Failed to get log:', error); res.status(500).json({ error: error.message || 'Failed to get commit log' }); } }); app.get('/api/git/commit-files', async (req, res) => { const { getCommitFiles } = await getGitLibraries(); try { const { directory, hash } = req.query; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } if (!hash) { return res.status(400).json({ error: 'hash parameter is required' }); } const result = await getCommitFiles(directory, hash); res.json(result); } catch (error) { console.error('Failed to get commit files:', error); res.status(500).json({ error: error.message || 'Failed to get commit files' }); } }); app.get('/api/git/commit-diff', async (req, res) => { const { getCommitDiff } = await getGitLibraries(); try { const directory = resolveDirectoryQuery(req.query.directory); const hash = resolveDirectoryQuery(req.query.hash); if (!directory || !hash) return res.status(400).json({ error: 'directory and hash are required' }); const context = Number(req.query.context ?? 3); const diff = await getCommitDiff(directory, { hash, path: resolveDirectoryQuery(req.query.path, true) ?? undefined, previousPath: resolveDirectoryQuery(req.query.previousPath, true) ?? undefined, contextLines: Number.isFinite(context) ? context : 3, }); res.json({ diff }); } catch (error) { res.status(500).json({ error: error.message || 'Failed to get commit diff' }); } }); app.get('/api/git/commit-file-diff', async (req, res) => { const { getCommitFileDiff } = await getGitLibraries(); try { const { directory, hash, path: filePath } = req.query; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory parameter is required' }); } if (!hash || typeof hash !== 'string') { return res.status(400).json({ error: 'hash parameter is required' }); } if (!/^[0-9a-fA-F]{7,40}$/.test(hash)) { return res.status(400).json({ error: 'hash must be a valid commit SHA' }); } if (!filePath || typeof filePath !== 'string') { return res.status(400).json({ error: 'path parameter is required' }); } const isBinary = req.query.binary === 'true'; const result = await getCommitFileDiff(directory, hash, filePath, isBinary); res.json(result); } catch (error) { console.error('Failed to get commit file diff:', error); res.status(500).json({ error: error.message || 'Failed to get commit file diff' }); } }); }