From 33aea3f7e8499a483d5108328e058c3c7128e108 Mon Sep 17 00:00:00 2001 From: viper151 Date: Mon, 14 Jul 2025 17:46:11 +0200 Subject: [PATCH] feat: Publish branch functionality (#66) --- server/routes/git.js | 96 ++++++++++++++++++++++++- src/components/GitPanel.jsx | 137 ++++++++++++++++++++++++++---------- src/components/Sidebar.jsx | 4 +- 3 files changed, 194 insertions(+), 43 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index a4a3c53..b56b3e4 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -444,10 +444,25 @@ router.get('/remote-status', async (req, res) => { trackingBranch = stdout.trim(); remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") } catch (error) { - // No upstream branch configured + // No upstream branch configured - but check if we have remotes + let hasRemote = false; + let remoteName = null; + try { + const { stdout } = await execAsync('git remote', { cwd: projectPath }); + const remotes = stdout.trim().split('\n').filter(r => r.trim()); + if (remotes.length > 0) { + hasRemote = true; + remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; + } + } catch (remoteError) { + // No remotes configured + } + return res.json({ - hasRemote: false, + hasRemote, + hasUpstream: false, branch, + remoteName, message: 'No remote tracking branch configured' }); } @@ -462,6 +477,7 @@ router.get('/remote-status', async (req, res) => { res.json({ hasRemote: true, + hasUpstream: true, branch, remoteBranch: trackingBranch, remoteName, @@ -653,6 +669,82 @@ router.post('/push', async (req, res) => { } }); +// Publish branch to remote (set upstream and push) +router.post('/publish', async (req, res) => { + const { project, branch } = req.body; + + if (!project || !branch) { + return res.status(400).json({ error: 'Project name and branch are required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + + // Get current branch to verify it matches the requested branch + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const currentBranchName = currentBranch.trim(); + + if (currentBranchName !== branch) { + return res.status(400).json({ + error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` + }); + } + + // Check if remote exists + let remoteName = 'origin'; + try { + const { stdout } = await execAsync('git remote', { cwd: projectPath }); + const remotes = stdout.trim().split('\n').filter(r => r.trim()); + if (remotes.length === 0) { + return res.status(400).json({ + error: 'No remote repository configured. Add a remote with: git remote add origin ' + }); + } + remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; + } catch (error) { + return res.status(400).json({ + error: 'No remote repository configured. Add a remote with: git remote add origin ' + }); + } + + // Publish the branch (set upstream and push) + const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath }); + + res.json({ + success: true, + output: stdout || 'Branch published successfully', + remoteName, + branch + }); + } catch (error) { + console.error('Git publish error:', error); + + // Enhanced error handling for common publish scenarios + let errorMessage = 'Publish failed'; + let details = error.message; + + if (error.message.includes('rejected')) { + errorMessage = 'Publish rejected'; + details = 'The remote branch already exists and has different commits. Use push instead.'; + } else if (error.message.includes('Could not resolve hostname')) { + errorMessage = 'Network error'; + details = 'Unable to connect to remote repository. Check your internet connection.'; + } else if (error.message.includes('Permission denied')) { + errorMessage = 'Authentication failed'; + details = 'Permission denied. Check your credentials or SSH keys.'; + } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) { + errorMessage = 'Remote not configured'; + details = 'Remote repository not properly configured. Check your remote URL.'; + } + + res.status(500).json({ + error: errorMessage, + details: details + }); + } +}); + // Discard changes for a specific file router.post('/discard', async (req, res) => { const { project, file } = req.body; diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx index 8ad2062..06d6217 100755 --- a/src/components/GitPanel.jsx +++ b/src/components/GitPanel.jsx @@ -28,6 +28,7 @@ function GitPanel({ selectedProject, isMobile }) { const [isFetching, setIsFetching] = useState(false); const [isPulling, setIsPulling] = useState(false); const [isPushing, setIsPushing] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string } const textareaRef = useRef(null); @@ -266,6 +267,34 @@ function GitPanel({ selectedProject, isMobile }) { } }; + const handlePublish = async () => { + setIsPublishing(true); + try { + const response = await authenticatedFetch('/api/git/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project: selectedProject.name, + branch: currentBranch + }) + }); + + const data = await response.json(); + if (data.success) { + // Refresh status after successful publish + fetchGitStatus(); + fetchRemoteStatus(); + } else { + console.error('Publish failed:', data.error); + // TODO: Show user-friendly error message + } + } catch (error) { + console.error('Error publishing branch:', error); + } finally { + setIsPublishing(false); + } + }; + const discardChanges = async (filePath) => { try { const response = await authenticatedFetch('/api/git/discard', { @@ -345,6 +374,9 @@ function GitPanel({ selectedProject, isMobile }) { case 'push': await handlePush(); break; + case 'publish': + await handlePublish(); + break; } } catch (error) { console.error(`Error executing ${type}:`, error); @@ -764,51 +796,72 @@ function GitPanel({ selectedProject, isMobile }) {
{/* Remote action buttons - smart logic based on ahead/behind status */} - {remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && ( + {remoteStatus?.hasRemote && ( <> - {/* Pull button - show when behind (primary action) */} - {remoteStatus.behind > 0 && ( + {/* Publish button - show when branch doesn't exist on remote */} + {!remoteStatus?.hasUpstream && ( )} - {/* Push button - show when ahead (primary action when ahead only) */} - {remoteStatus.ahead > 0 && ( - - )} - - {/* Fetch button - show when ahead only or when diverged (secondary action) */} - {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && ( - + {/* Show normal push/pull buttons only if branch has upstream */} + {remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && ( + <> + {/* Pull button - show when behind (primary action) */} + {remoteStatus.behind > 0 && ( + + )} + + {/* Push button - show when ahead (primary action when ahead only) */} + {remoteStatus.ahead > 0 && ( + + )} + + {/* Fetch button - show when ahead only or when diverged (secondary action) */} + {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && ( + + )} + )} )} @@ -1178,7 +1231,8 @@ function GitPanel({ selectedProject, isMobile }) { {confirmAction.type === 'discard' ? 'Discard Changes' : confirmAction.type === 'delete' ? 'Delete File' : confirmAction.type === 'commit' ? 'Confirm Commit' : - confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'} + confirmAction.type === 'pull' ? 'Confirm Pull' : + confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
@@ -1202,6 +1256,8 @@ function GitPanel({ selectedProject, isMobile }) { ? 'bg-blue-600 hover:bg-blue-700' : confirmAction.type === 'pull' ? 'bg-green-600 hover:bg-green-700' + : confirmAction.type === 'publish' + ? 'bg-purple-600 hover:bg-purple-700' : 'bg-orange-600 hover:bg-orange-700' } flex items-center space-x-2`} > @@ -1225,6 +1281,11 @@ function GitPanel({ selectedProject, isMobile }) { Pull + ) : confirmAction.type === 'publish' ? ( + <> + + Publish + ) : ( <> diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 1cca47c..9a1e111 100755 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -374,9 +374,7 @@ function Sidebar({ try { const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0); - const response = await fetch( - `/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}` - ); + const response = await api.sessions(project.name, 5, currentSessionCount); if (response.ok) { const result = await response.json();