Files
openchamber/packages/web/server/lib/git/routes.js
T
Erman HAVUÇandBohdan Triapitsyn 52ffe9daef feat(git-graph): VS Code-style git graph with commit actions in History modal (#1431)
* feat(types): add parents to GitLogEntry and new commit action types

* feat(git): add parent hashes and --all flag to getLog

* fix(git): move record separator to start of log format string

* feat(git): add checkoutCommit server function and route

* feat(git): add cherryPick server function and route

* feat(git): add revertCommit server function and route

* feat(git): add resetToCommit server function and route

* fix(tests): make git service tests branch-name portable, add error path tests

* feat(client): add checkoutCommit, cherryPick, revertCommit, resetToCommit API wrappers

* feat(git-graph): add lane assignment algorithm with tests

* feat(git-graph): add GitGraphSegment per-row SVG renderer

* feat(i18n): add locale strings for git graph action buttons

* fix(git-graph): handle lane convergence, fix SVG path coords, add connector tests

* feat(git-graph): add ref badges and action buttons to HistoryCommitRow

* fix(git-graph): add loading guards to reset actions, use theme tokens for ref badges

* fix(git-graph): conditional hooks, stale graph log, conflict handling, i18n

* fix(types): replace toBeDefined with toBeTruthy, fix toast API usage

* fix(lint): remove unused variables

* fix(git-graph): fix SVG height causing 150px row spacing

* fix(git-graph): smooth bezier curves, fill row height, round line caps

* fix(git-graph): non-scaling-stroke fixes bezier white spaces, sort curves on top

* fix(git-graph): remove viewBox scaling, match SVG height to actual row height

* fix(git-graph): ResizeObserver tracks actual row height, eliminates SVG height mismatch

* feat(git-graph): replace SVG with Canvas for graph rendering

* fix(git-graph): isolate canvas from flex layout to prevent replaced-element height leak

* feat(git-graph): align action buttons, add confirmation popups for all actions

* fix(git-graph): address code review findings CR-001 through CR-005

- CR-001: VS Code getGitLog now forwards 'all' option and parses %P parents
- CR-002: VS Code bridge/gitService implement checkoutCommit, cherryPick,
  revertCommit, resetToCommit with conflict detection and hard-reset guard
- CR-003: server-side commit hash validated with /^[0-9a-fA-F]{7,40}$/
  in both routes.js and service.js; 12 new rejection tests added
- CR-004: cherry-pick/revert conflict path now refreshes fetchStatus/
  fetchBranches/fetchLog; conflict toast uses i18n keys in all 7 locales
- CR-005: corrected O(n) comment to O(n x lanes)

* fix(i18n): add zh-TW locale and common.language.traditionalChinese key to all locales

upstream/main added zh-TW.ts after branch diverged; CI type-check fails
when PR is merged because zh-TW.ts was missing all gitView.history.actions.*
keys and loadMore/loadingMore. Also adds common.language.traditionalChinese
to en.ts and all 6 non-English files to match upstream en.ts.

* fix: harden git history actions

* feat: split git history graph view

* chore: remove git graph planning docs

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-05-27 00:13:25 +03:00

1099 lines
39 KiB
JavaScript

export function registerGitRoutes(app) {
let gitLibraries = null;
const getGitLibraries = async () => {
if (!gitLibraries) {
gitLibraries = await import('./index.js');
}
return gitLibraries;
};
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 = 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) {
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();
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 : '';
return [message, stderr, stdout]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join('\n');
};
try {
const directory = 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({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 });
}
const mode = req.query.mode === 'light' ? 'light' : undefined;
const status = await getStatus(directory, { mode });
res.json(status);
} catch (error) {
const errorText = extractGitErrorText(error);
if (/not a git repository/i.test(errorText)) {
return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 });
}
console.error('Failed to get git status:', error);
res.status(500).json({ error: error.message || 'Failed to get git status' });
}
});
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.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/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/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-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' });
}
});
}