diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index abf9c5221..94d64e1b1 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -53,6 +53,7 @@ import { } from './routes/init-script.js'; import { createDiscardChangesHandler } from './routes/discard-changes.js'; import { createListRemotesHandler } from './routes/list-remotes.js'; +import { createAddRemoteHandler } from './routes/add-remote.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createWorktreeRoutes( @@ -178,5 +179,13 @@ export function createWorktreeRoutes( createListRemotesHandler() ); + // Add remote route + router.post( + '/add-remote', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createAddRemoteHandler() + ); + return router; } diff --git a/apps/server/src/routes/worktree/routes/add-remote.ts b/apps/server/src/routes/worktree/routes/add-remote.ts new file mode 100644 index 000000000..293892036 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/add-remote.ts @@ -0,0 +1,166 @@ +/** + * POST /add-remote endpoint - Add a new remote to a git repository + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logWorktreeError } from '../common.js'; + +const execFileAsync = promisify(execFile); + +/** Maximum allowed length for remote names */ +const MAX_REMOTE_NAME_LENGTH = 250; + +/** Maximum allowed length for remote URLs */ +const MAX_REMOTE_URL_LENGTH = 2048; + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30000; + +/** + * Validate remote name - must be alphanumeric with dashes/underscores + * Git remote names have similar restrictions to branch names + */ +function isValidRemoteName(name: string): boolean { + // Remote names should be alphanumeric, may contain dashes, underscores, periods + // Cannot start with a dash or period, cannot be empty + if (!name || name.length === 0 || name.length > MAX_REMOTE_NAME_LENGTH) { + return false; + } + return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name); +} + +/** + * Validate remote URL - basic validation for git remote URLs + * Supports HTTPS, SSH, and git:// protocols + */ +function isValidRemoteUrl(url: string): boolean { + if (!url || url.length === 0 || url.length > MAX_REMOTE_URL_LENGTH) { + return false; + } + // Support common git URL formats: + // - https://github.com/user/repo.git + // - git@github.com:user/repo.git + // - git://github.com/user/repo.git + // - ssh://git@github.com/user/repo.git + const httpsPattern = /^https?:\/\/.+/; + const sshPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:.+/; + const gitProtocolPattern = /^git:\/\/.+/; + const sshProtocolPattern = /^ssh:\/\/.+/; + + return ( + httpsPattern.test(url) || + sshPattern.test(url) || + gitProtocolPattern.test(url) || + sshProtocolPattern.test(url) + ); +} + +export function createAddRemoteHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, remoteName, remoteUrl } = req.body as { + worktreePath: string; + remoteName: string; + remoteUrl: string; + }; + + // Validate required fields + const requiredFields = { worktreePath, remoteName, remoteUrl }; + for (const [key, value] of Object.entries(requiredFields)) { + if (!value) { + res.status(400).json({ success: false, error: `${key} required` }); + return; + } + } + + // Validate remote name + if (!isValidRemoteName(remoteName)) { + res.status(400).json({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + return; + } + + // Validate remote URL + if (!isValidRemoteUrl(remoteUrl)) { + res.status(400).json({ + success: false, + error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).', + }); + return; + } + + // Check if remote already exists + try { + const { stdout: existingRemotes } = await execFileAsync('git', ['remote'], { + cwd: worktreePath, + }); + const remoteNames = existingRemotes + .trim() + .split('\n') + .filter((r) => r.trim()); + if (remoteNames.includes(remoteName)) { + res.status(400).json({ + success: false, + error: `Remote '${remoteName}' already exists`, + code: 'REMOTE_EXISTS', + }); + return; + } + } catch (error) { + // If git remote fails, continue with adding the remote. Log for debugging. + logWorktreeError( + error, + 'Checking for existing remotes failed, proceeding to add.', + worktreePath + ); + } + + // Add the remote using execFile with array arguments to prevent command injection + await execFileAsync('git', ['remote', 'add', remoteName, remoteUrl], { + cwd: worktreePath, + }); + + // Optionally fetch from the new remote to get its branches + let fetchSucceeded = false; + try { + await execFileAsync('git', ['fetch', remoteName, '--quiet'], { + cwd: worktreePath, + timeout: FETCH_TIMEOUT_MS, + }); + fetchSucceeded = true; + } catch (fetchError) { + // Fetch failed (maybe offline or invalid URL), but remote was added successfully + logWorktreeError( + fetchError, + `Fetch from new remote '${remoteName}' failed (remote added successfully)`, + worktreePath + ); + fetchSucceeded = false; + } + + res.json({ + success: true, + result: { + remoteName, + remoteUrl, + fetched: fetchSucceeded, + message: fetchSucceeded + ? `Successfully added remote '${remoteName}' and fetched its branches` + : `Successfully added remote '${remoteName}' (fetch failed - you may need to fetch manually)`, + }, + }); + } catch (error) { + const worktreePath = req.body?.worktreePath; + logWorktreeError(error, 'Add remote failed', worktreePath); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 6c9995526..2e6a34f50 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -110,6 +110,18 @@ export function createListBranchesHandler() { } } + // Check if any remotes are configured for this repository + let hasAnyRemotes = false; + try { + const { stdout: remotesOutput } = await execAsync('git remote', { + cwd: worktreePath, + }); + hasAnyRemotes = remotesOutput.trim().length > 0; + } catch { + // If git remote fails, assume no remotes + hasAnyRemotes = false; + } + // Get ahead/behind count for current branch and check if remote branch exists let aheadCount = 0; let behindCount = 0; @@ -154,6 +166,7 @@ export function createListBranchesHandler() { aheadCount, behindCount, hasRemoteBranch, + hasAnyRemotes, }, }); } catch (error) { diff --git a/apps/server/tests/unit/routes/worktree/add-remote.test.ts b/apps/server/tests/unit/routes/worktree/add-remote.test.ts new file mode 100644 index 000000000..9eb3e828c --- /dev/null +++ b/apps/server/tests/unit/routes/worktree/add-remote.test.ts @@ -0,0 +1,565 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import type { Request, Response } from 'express'; +import { createMockExpressContext } from '../../../utils/mocks.js'; + +// Mock child_process with importOriginal to keep other exports +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: vi.fn(), + }; +}); + +// Mock util.promisify to return the function as-is so we can mock execFile +vi.mock('util', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promisify: (fn: unknown) => fn, + }; +}); + +// Import handler after mocks are set up +import { createAddRemoteHandler } from '@/routes/worktree/routes/add-remote.js'; +import { execFile } from 'child_process'; + +// Get the mocked execFile +const mockExecFile = execFile as Mock; + +/** + * Helper to create a standard mock implementation for git commands + */ +function createGitMock(options: { + existingRemotes?: string[]; + addRemoteFails?: boolean; + addRemoteError?: string; + fetchFails?: boolean; +}): (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }> { + const { + existingRemotes = [], + addRemoteFails = false, + addRemoteError = 'git remote add failed', + fetchFails = false, + } = options; + + return (command: string, args: string[]) => { + if (command === 'git' && args[0] === 'remote' && args.length === 1) { + return Promise.resolve({ stdout: existingRemotes.join('\n'), stderr: '' }); + } + if (command === 'git' && args[0] === 'remote' && args[1] === 'add') { + if (addRemoteFails) { + return Promise.reject(new Error(addRemoteError)); + } + return Promise.resolve({ stdout: '', stderr: '' }); + } + if (command === 'git' && args[0] === 'fetch') { + if (fetchFails) { + return Promise.reject(new Error('fetch failed')); + } + return Promise.resolve({ stdout: '', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }; +} + +describe('add-remote route', () => { + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + + const context = createMockExpressContext(); + req = context.req; + res = context.res; + }); + + describe('input validation', () => { + it('should return 400 if worktreePath is missing', async () => { + req.body = { remoteName: 'origin', remoteUrl: 'https://github.com/user/repo.git' }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'worktreePath required', + }); + }); + + it('should return 400 if remoteName is missing', async () => { + req.body = { worktreePath: '/test/path', remoteUrl: 'https://github.com/user/repo.git' }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'remoteName required', + }); + }); + + it('should return 400 if remoteUrl is missing', async () => { + req.body = { worktreePath: '/test/path', remoteName: 'origin' }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'remoteUrl required', + }); + }); + }); + + describe('remote name validation', () => { + it('should return 400 for empty remote name', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: '', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'remoteName required', + }); + }); + + it('should return 400 for remote name starting with dash', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: '-invalid', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + }); + + it('should return 400 for remote name starting with period', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: '.invalid', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + }); + + it('should return 400 for remote name with invalid characters', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'invalid name', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + }); + + it('should return 400 for remote name exceeding 250 characters', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'a'.repeat(251), + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + }); + + it('should accept valid remote names with alphanumeric, dashes, underscores, and periods', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'my-remote_name.1', + remoteUrl: 'https://github.com/user/repo.git', + }; + + // Mock git remote to return empty list (no existing remotes) + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + // Should not return 400 for invalid name + expect(res.status).not.toHaveBeenCalledWith(400); + }); + }); + + describe('remote URL validation', () => { + it('should return 400 for empty remote URL', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: '', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'remoteUrl required', + }); + }); + + it('should return 400 for invalid remote URL', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'not-a-valid-url', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).', + }); + }); + + it('should return 400 for URL exceeding 2048 characters', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/' + 'a'.repeat(2049) + '.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).', + }); + }); + + it('should accept HTTPS URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + it('should accept HTTP URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'http://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + it('should accept SSH URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'git@github.com:user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + it('should accept git:// protocol URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'git://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + it('should accept ssh:// protocol URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'ssh://git@github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + }); + + describe('remote already exists check', () => { + it('should return 400 with REMOTE_EXISTS code when remote already exists', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin', 'upstream'] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: "Remote 'origin' already exists", + code: 'REMOTE_EXISTS', + }); + }); + + it('should proceed if remote does not exist', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'new-remote', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin'] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + // Should call git remote add with array arguments + expect(mockExecFile).toHaveBeenCalledWith( + 'git', + ['remote', 'add', 'new-remote', 'https://github.com/user/repo.git'], + expect.any(Object) + ); + }); + }); + + describe('successful remote addition', () => { + it('should add remote successfully with successful fetch', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'upstream', + remoteUrl: 'https://github.com/other/repo.git', + }; + + mockExecFile.mockImplementation( + createGitMock({ existingRemotes: ['origin'], fetchFails: false }) + ); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: { + remoteName: 'upstream', + remoteUrl: 'https://github.com/other/repo.git', + fetched: true, + message: "Successfully added remote 'upstream' and fetched its branches", + }, + }); + }); + + it('should add remote successfully even if fetch fails', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'upstream', + remoteUrl: 'https://github.com/other/repo.git', + }; + + mockExecFile.mockImplementation( + createGitMock({ existingRemotes: ['origin'], fetchFails: true }) + ); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: { + remoteName: 'upstream', + remoteUrl: 'https://github.com/other/repo.git', + fetched: false, + message: + "Successfully added remote 'upstream' (fetch failed - you may need to fetch manually)", + }, + }); + }); + + it('should pass correct cwd option to git commands', async () => { + req.body = { + worktreePath: '/custom/worktree/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const execCalls: { command: string; args: string[]; options: unknown }[] = []; + mockExecFile.mockImplementation((command: string, args: string[], options: unknown) => { + execCalls.push({ command, args, options }); + if (command === 'git' && args[0] === 'remote' && args.length === 1) { + return Promise.resolve({ stdout: '', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + // Check that git remote was called with correct cwd + expect((execCalls[0].options as { cwd: string }).cwd).toBe('/custom/worktree/path'); + // Check that git remote add was called with correct cwd + expect((execCalls[1].options as { cwd: string }).cwd).toBe('/custom/worktree/path'); + }); + }); + + describe('error handling', () => { + it('should return 500 when git remote add fails', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation( + createGitMock({ + existingRemotes: [], + addRemoteFails: true, + addRemoteError: 'git remote add failed', + }) + ); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'git remote add failed', + }); + }); + + it('should continue adding remote if git remote check fails', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation((command: string, args: string[]) => { + if (command === 'git' && args[0] === 'remote' && args.length === 1) { + return Promise.reject(new Error('not a git repo')); + } + if (command === 'git' && args[0] === 'remote' && args[1] === 'add') { + return Promise.resolve({ stdout: '', stderr: '' }); + } + if (command === 'git' && args[0] === 'fetch') { + return Promise.resolve({ stdout: '', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + // Should still try to add remote with array arguments + expect(mockExecFile).toHaveBeenCalledWith( + 'git', + ['remote', 'add', 'origin', 'https://github.com/user/repo.git'], + expect.any(Object) + ); + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: expect.objectContaining({ + remoteName: 'origin', + }), + }); + }); + + it('should handle non-Error exceptions', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation((command: string, args: string[]) => { + if (command === 'git' && args[0] === 'remote' && args.length === 1) { + return Promise.resolve({ stdout: '', stderr: '' }); + } + if (command === 'git' && args[0] === 'remote' && args[1] === 'add') { + return Promise.reject('String error'); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: expect.any(String), + }); + }); + }); +}); diff --git a/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx index 4e02b4e1b..0871d267e 100644 --- a/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { Dialog, @@ -9,6 +9,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, @@ -18,8 +19,9 @@ import { SelectValue, } from '@/components/ui/select'; import { getHttpApiClient } from '@/lib/http-api-client'; +import { getErrorMessage } from '@/lib/utils'; import { toast } from 'sonner'; -import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react'; +import { Upload, RefreshCw, AlertTriangle, Sparkles, Plus, Link } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import type { WorktreeInfo } from '../worktree-panel/types'; @@ -49,18 +51,76 @@ export function PushToRemoteDialog({ const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); + // Add remote form state + const [showAddRemoteForm, setShowAddRemoteForm] = useState(false); + const [newRemoteName, setNewRemoteName] = useState('origin'); + const [newRemoteUrl, setNewRemoteUrl] = useState(''); + const [isAddingRemote, setIsAddingRemote] = useState(false); + const [addRemoteError, setAddRemoteError] = useState(null); + + /** + * Transforms API remote data to RemoteInfo format + */ + const transformRemoteData = useCallback( + (remotes: Array<{ name: string; url: string }>): RemoteInfo[] => { + return remotes.map((r) => ({ + name: r.name, + url: r.url, + })); + }, + [] + ); + + /** + * Updates remotes state and hides add form if remotes exist + */ + const updateRemotesState = useCallback((remoteInfos: RemoteInfo[]) => { + setRemotes(remoteInfos); + if (remoteInfos.length > 0) { + setShowAddRemoteForm(false); + } + }, []); + + const fetchRemotes = useCallback(async () => { + if (!worktree) return; + + setIsLoading(true); + setError(null); + + try { + const api = getHttpApiClient(); + const result = await api.worktree.listRemotes(worktree.path); + + if (result.success && result.result) { + const remoteInfos = transformRemoteData(result.result.remotes); + updateRemotesState(remoteInfos); + } else { + setError(result.error || 'Failed to fetch remotes'); + } + } catch (err) { + logger.error('Failed to fetch remotes:', err); + setError(getErrorMessage(err)); + } finally { + setIsLoading(false); + } + }, [worktree, transformRemoteData, updateRemotesState]); + // Fetch remotes when dialog opens useEffect(() => { if (open && worktree) { fetchRemotes(); } - }, [open, worktree]); + }, [open, worktree, fetchRemotes]); // Reset state when dialog closes useEffect(() => { if (!open) { setSelectedRemote(''); setError(null); + setShowAddRemoteForm(false); + setNewRemoteName('origin'); + setNewRemoteUrl(''); + setAddRemoteError(null); } }, [open]); @@ -73,10 +133,17 @@ export function PushToRemoteDialog({ } }, [remotes, selectedRemote]); - const fetchRemotes = async () => { + // Show add remote form when no remotes (but not when there's an error) + useEffect(() => { + if (!isLoading && remotes.length === 0 && !error) { + setShowAddRemoteForm(true); + } + }, [isLoading, remotes.length, error]); + + const handleRefresh = async () => { if (!worktree) return; - setIsLoading(true); + setIsRefreshing(true); setError(null); try { @@ -84,51 +151,54 @@ export function PushToRemoteDialog({ const result = await api.worktree.listRemotes(worktree.path); if (result.success && result.result) { - // Extract just the remote info (name and URL), not the branches - const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({ - name: r.name, - url: r.url, - })); - setRemotes(remoteInfos); - if (remoteInfos.length === 0) { - setError('No remotes found in this repository. Please add a remote first.'); - } + const remoteInfos = transformRemoteData(result.result.remotes); + updateRemotesState(remoteInfos); + toast.success('Remotes refreshed'); } else { - setError(result.error || 'Failed to fetch remotes'); + toast.error(result.error || 'Failed to refresh remotes'); } } catch (err) { - logger.error('Failed to fetch remotes:', err); - setError('Failed to fetch remotes'); + logger.error('Failed to refresh remotes:', err); + toast.error(getErrorMessage(err)); } finally { - setIsLoading(false); + setIsRefreshing(false); } }; - const handleRefresh = async () => { - if (!worktree) return; + const handleAddRemote = async () => { + if (!worktree || !newRemoteName.trim() || !newRemoteUrl.trim()) return; - setIsRefreshing(true); - setError(null); + setIsAddingRemote(true); + setAddRemoteError(null); try { const api = getHttpApiClient(); - const result = await api.worktree.listRemotes(worktree.path); + const result = await api.worktree.addRemote( + worktree.path, + newRemoteName.trim(), + newRemoteUrl.trim() + ); if (result.success && result.result) { - const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({ - name: r.name, - url: r.url, - })); - setRemotes(remoteInfos); - toast.success('Remotes refreshed'); + toast.success(result.result.message); + // Add the new remote to the list and select it + const newRemote: RemoteInfo = { + name: result.result.remoteName, + url: result.result.remoteUrl, + }; + setRemotes((prev) => [...prev, newRemote]); + setSelectedRemote(newRemote.name); + setShowAddRemoteForm(false); + setNewRemoteName('origin'); + setNewRemoteUrl(''); } else { - toast.error(result.error || 'Failed to refresh remotes'); + setAddRemoteError(result.error || 'Failed to add remote'); } } catch (err) { - logger.error('Failed to refresh remotes:', err); - toast.error('Failed to refresh remotes'); + logger.error('Failed to add remote:', err); + setAddRemoteError(getErrorMessage(err)); } finally { - setIsRefreshing(false); + setIsAddingRemote(false); } }; @@ -138,24 +208,213 @@ export function PushToRemoteDialog({ onOpenChange(false); }; + const renderAddRemoteForm = () => ( +
+
+ + + {remotes.length === 0 + ? 'No remotes found. Add a remote to push your branch.' + : 'Add a new remote'} + +
+ +
+ + { + setNewRemoteName(e.target.value); + setAddRemoteError(null); + }} + disabled={isAddingRemote} + /> +
+ +
+ + { + setNewRemoteUrl(e.target.value); + setAddRemoteError(null); + }} + onKeyDown={(e) => { + if ( + e.key === 'Enter' && + newRemoteName.trim() && + newRemoteUrl.trim() && + !isAddingRemote + ) { + handleAddRemote(); + } + }} + disabled={isAddingRemote} + /> +

+ Supports HTTPS, SSH (git@github.com:user/repo.git), or git:// URLs +

+
+ + {addRemoteError && ( +
+ + {addRemoteError} +
+ )} +
+ ); + + const renderRemoteSelector = () => ( +
+
+
+ +
+ + +
+
+ +
+ + {selectedRemote && ( +
+

+ This will create a new remote branch{' '} + + {selectedRemote}/{worktree?.branch} + {' '} + and set up tracking. +

+
+ )} +
+ ); + + const renderFooter = () => { + if (showAddRemoteForm) { + return ( + + {remotes.length > 0 && ( + + )} + + + + ); + } + + return ( + + + + + ); + }; + return ( - - Push New Branch to Remote - - - new - + {showAddRemoteForm ? ( + <> + + Add Remote + + ) : ( + <> + + Push New Branch to Remote + + + new + + + )} - Push{' '} - - {worktree?.branch || 'current branch'} - {' '} - to a remote repository for the first time. + {showAddRemoteForm ? ( + <>Add a remote repository to push your changes to. + ) : ( + <> + Push{' '} + + {worktree?.branch || 'current branch'} + {' '} + to a remote repository for the first time. + + )} @@ -163,7 +422,7 @@ export function PushToRemoteDialog({
- ) : error ? ( + ) : error && !showAddRemoteForm ? (
@@ -174,68 +433,13 @@ export function PushToRemoteDialog({ Retry
+ ) : showAddRemoteForm ? ( + renderAddRemoteForm() ) : ( -
-
-
- - -
- -
- - {selectedRemote && ( -
-

- This will create a new remote branch{' '} - - {selectedRemote}/{worktree?.branch} - {' '} - and set up tracking. -

-
- )} -
+ renderRemoteSelector() )} - - - - + {renderFooter()}
); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 2a87d3e1d..97d8da979 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -27,7 +27,7 @@ import { Copy, Eye, ScrollText, - Sparkles, + CloudOff, Terminal, SquarePlus, SplitSquareHorizontal, @@ -365,9 +365,9 @@ export function WorktreeActionsDropdown({ {isPushing ? 'Pushing...' : 'Push'} {!canPerformGitOps && } {canPerformGitOps && !hasRemoteBranch && ( - - - new + + + local only )} {canPerformGitOps && hasRemoteBranch && aheadCount > 0 && ( diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts index cc75dafe1..fc57c3549 100644 --- a/apps/ui/src/hooks/queries/use-worktrees.ts +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -151,7 +151,7 @@ export function useWorktreeDiffs(projectPath: string | undefined, featureId: str interface BranchInfo { name: string; isCurrent: boolean; - isRemote?: boolean; + isRemote: boolean; lastCommit?: string; upstream?: string; } @@ -161,6 +161,7 @@ interface BranchesResult { aheadCount: number; behindCount: number; hasRemoteBranch: boolean; + hasAnyRemotes: boolean; isGitRepo: boolean; hasCommits: boolean; } @@ -188,6 +189,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem aheadCount: 0, behindCount: 0, hasRemoteBranch: false, + hasAnyRemotes: false, isGitRepo: false, hasCommits: false, }; @@ -198,6 +200,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem aheadCount: 0, behindCount: 0, hasRemoteBranch: false, + hasAnyRemotes: result.result?.hasAnyRemotes ?? false, isGitRepo: true, hasCommits: false, }; @@ -212,6 +215,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem aheadCount: result.result?.aheadCount ?? 0, behindCount: result.result?.behindCount ?? 0, hasRemoteBranch: result.result?.hasRemoteBranch ?? false, + hasAnyRemotes: result.result?.hasAnyRemotes ?? false, isGitRepo: true, hasCommits: true, }; diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index f3f8939bc..cc01cd8b6 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1782,6 +1782,7 @@ function createMockWorktreeAPI(): WorktreeAPI { aheadCount: 2, behindCount: 0, hasRemoteBranch: true, + hasAnyRemotes: true, }, }; }, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 3d818da3b..8ba3abf31 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1878,6 +1878,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/switch-branch', { worktreePath, branchName }), listRemotes: (worktreePath: string) => this.post('/api/worktree/list-remotes', { worktreePath }), + addRemote: (worktreePath: string, remoteName: string, remoteUrl: string) => + this.post('/api/worktree/add-remote', { worktreePath, remoteName, remoteUrl }), openInEditor: (worktreePath: string, editorCommand?: string) => this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), getDefaultEditor: () => this.get('/api/worktree/default-editor'), diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index bdaaa9cff..a0dd8d440 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -7,6 +7,12 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +// Re-export getErrorMessage from @automaker/utils to maintain backward compatibility +// for components that already import it from here +// NOTE: Using subpath export to avoid pulling in Node.js-specific dependencies +// (the main @automaker/utils barrel imports modules that depend on @automaker/platform) +export { getErrorMessage } from '@automaker/utils/error-handler'; + /** * Determine if the current model supports extended thinking controls * Note: This is for Claude's "thinking levels" only, not Codex's "reasoning effort" diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 5c53da9af..8f6745557 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -951,6 +951,7 @@ export interface WorktreeAPI { aheadCount: number; behindCount: number; hasRemoteBranch: boolean; + hasAnyRemotes: boolean; }; error?: string; code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues @@ -988,6 +989,23 @@ export interface WorktreeAPI { code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; }>; + // Add a new remote to a git repository + addRemote: ( + worktreePath: string, + remoteName: string, + remoteUrl: string + ) => Promise<{ + success: boolean; + result?: { + remoteName: string; + remoteUrl: string; + fetched: boolean; + message: string; + }; + error?: string; + code?: 'REMOTE_EXISTS'; + }>; + // Open a worktree directory in the editor openInEditor: ( worktreePath: string, diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index d29981eff..802f95ce3 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -330,7 +330,14 @@ export type { export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js'; // Worktree and PR types -export type { PRState, WorktreePRInfo } from './worktree.js'; +export type { + PRState, + WorktreePRInfo, + AddRemoteRequest, + AddRemoteResult, + AddRemoteResponse, + AddRemoteErrorResponse, +} from './worktree.js'; export { PR_STATES, validatePRState } from './worktree.js'; // Terminal types diff --git a/libs/types/src/worktree.ts b/libs/types/src/worktree.ts index b81a075d4..a3edff4c0 100644 --- a/libs/types/src/worktree.ts +++ b/libs/types/src/worktree.ts @@ -30,3 +30,47 @@ export interface WorktreePRInfo { state: PRState; createdAt: string; } + +/** + * Request payload for adding a git remote + */ +export interface AddRemoteRequest { + /** Path to the git worktree/repository */ + worktreePath: string; + /** Name for the remote (e.g., 'origin', 'upstream') */ + remoteName: string; + /** URL of the remote repository (HTTPS, SSH, or git:// protocol) */ + remoteUrl: string; +} + +/** + * Result data from a successful add-remote operation + */ +export interface AddRemoteResult { + /** Name of the added remote */ + remoteName: string; + /** URL of the added remote */ + remoteUrl: string; + /** Whether the initial fetch was successful */ + fetched: boolean; + /** Human-readable status message */ + message: string; +} + +/** + * Successful response from add-remote endpoint + */ +export interface AddRemoteResponse { + success: true; + result: AddRemoteResult; +} + +/** + * Error response from add-remote endpoint + */ +export interface AddRemoteErrorResponse { + success: false; + error: string; + /** Optional error code for specific error types (e.g., 'REMOTE_EXISTS') */ + code?: string; +} diff --git a/libs/utils/package.json b/libs/utils/package.json index d4240d8cf..0d7d5149d 100644 --- a/libs/utils/package.json +++ b/libs/utils/package.json @@ -17,6 +17,10 @@ "./debounce": { "types": "./dist/debounce.d.ts", "default": "./dist/debounce.js" + }, + "./error-handler": { + "types": "./dist/error-handler.d.ts", + "default": "./dist/error-handler.js" } }, "scripts": {