Merge pull request #649 from AutoMaker-Org/feat/detect-no-remote-branch

fix: detect no remote branch
This commit is contained in:
Shirone
2026-01-21 21:44:19 +00:00
committed by GitHub
14 changed files with 1158 additions and 115 deletions

View File

@@ -53,6 +53,7 @@ import {
} from './routes/init-script.js'; } from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js'; import { createDiscardChangesHandler } from './routes/discard-changes.js';
import { createListRemotesHandler } from './routes/list-remotes.js'; import { createListRemotesHandler } from './routes/list-remotes.js';
import { createAddRemoteHandler } from './routes/add-remote.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes( export function createWorktreeRoutes(
@@ -178,5 +179,13 @@ export function createWorktreeRoutes(
createListRemotesHandler() createListRemotesHandler()
); );
// Add remote route
router.post(
'/add-remote',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createAddRemoteHandler()
);
return router; return router;
} }

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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 // Get ahead/behind count for current branch and check if remote branch exists
let aheadCount = 0; let aheadCount = 0;
let behindCount = 0; let behindCount = 0;
@@ -154,6 +166,7 @@ export function createListBranchesHandler() {
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch, hasRemoteBranch,
hasAnyRemotes,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -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<typeof import('child_process')>();
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<typeof import('util')>();
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),
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { import {
Dialog, Dialog,
@@ -9,6 +9,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Select, Select,
@@ -18,8 +19,9 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { getErrorMessage } from '@/lib/utils';
import { toast } from 'sonner'; 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 { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types'; import type { WorktreeInfo } from '../worktree-panel/types';
@@ -49,18 +51,76 @@ export function PushToRemoteDialog({
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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<string | null>(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 // Fetch remotes when dialog opens
useEffect(() => { useEffect(() => {
if (open && worktree) { if (open && worktree) {
fetchRemotes(); fetchRemotes();
} }
}, [open, worktree]); }, [open, worktree, fetchRemotes]);
// Reset state when dialog closes // Reset state when dialog closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setSelectedRemote(''); setSelectedRemote('');
setError(null); setError(null);
setShowAddRemoteForm(false);
setNewRemoteName('origin');
setNewRemoteUrl('');
setAddRemoteError(null);
} }
}, [open]); }, [open]);
@@ -73,36 +133,12 @@ export function PushToRemoteDialog({
} }
}, [remotes, selectedRemote]); }, [remotes, selectedRemote]);
const fetchRemotes = async () => { // Show add remote form when no remotes (but not when there's an error)
if (!worktree) return; useEffect(() => {
if (!isLoading && remotes.length === 0 && !error) {
setIsLoading(true); setShowAddRemoteForm(true);
setError(null);
try {
const api = getHttpApiClient();
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.');
} }
} else { }, [isLoading, remotes.length, error]);
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
} finally {
setIsLoading(false);
}
};
const handleRefresh = async () => { const handleRefresh = async () => {
if (!worktree) return; if (!worktree) return;
@@ -115,70 +151,139 @@ export function PushToRemoteDialog({
const result = await api.worktree.listRemotes(worktree.path); const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) { if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({ const remoteInfos = transformRemoteData(result.result.remotes);
name: r.name, updateRemotesState(remoteInfos);
url: r.url,
}));
setRemotes(remoteInfos);
toast.success('Remotes refreshed'); toast.success('Remotes refreshed');
} else { } else {
toast.error(result.error || 'Failed to refresh remotes'); toast.error(result.error || 'Failed to refresh remotes');
} }
} catch (err) { } catch (err) {
logger.error('Failed to refresh remotes:', err); logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes'); toast.error(getErrorMessage(err));
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
}; };
const handleAddRemote = async () => {
if (!worktree || !newRemoteName.trim() || !newRemoteUrl.trim()) return;
setIsAddingRemote(true);
setAddRemoteError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.addRemote(
worktree.path,
newRemoteName.trim(),
newRemoteUrl.trim()
);
if (result.success && result.result) {
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 {
setAddRemoteError(result.error || 'Failed to add remote');
}
} catch (err) {
logger.error('Failed to add remote:', err);
setAddRemoteError(getErrorMessage(err));
} finally {
setIsAddingRemote(false);
}
};
const handleConfirm = () => { const handleConfirm = () => {
if (!worktree || !selectedRemote) return; if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote); onConfirm(worktree, selectedRemote);
onOpenChange(false); onOpenChange(false);
}; };
return ( const renderAddRemoteForm = () => (
<Dialog open={open} onOpenChange={onOpenChange}> <div className="grid gap-4 py-4">
<DialogContent className="sm:max-w-[450px]"> <div className="flex items-center gap-2 text-muted-foreground mb-2">
<DialogHeader> <Link className="w-4 h-4" />
<DialogTitle className="flex items-center gap-2"> <span className="text-sm">
<Upload className="w-5 h-5 text-primary" /> {remotes.length === 0
Push New Branch to Remote ? 'No remotes found. Add a remote to push your branch.'
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2"> : 'Add a new remote'}
<Sparkles className="w-3 h-3" />
new
</span> </span>
</DialogTitle> </div>
<DialogDescription>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
</DialogDescription>
</DialogHeader>
{isLoading ? ( <div className="grid gap-2">
<div className="flex items-center justify-center py-8"> <Label htmlFor="remote-name">Remote Name</Label>
<Spinner size="lg" /> <Input
id="remote-name"
placeholder="origin"
value={newRemoteName}
onChange={(e) => {
setNewRemoteName(e.target.value);
setAddRemoteError(null);
}}
disabled={isAddingRemote}
/>
</div> </div>
) : error ? (
<div className="flex flex-col items-center gap-4 py-6"> <div className="grid gap-2">
<Label htmlFor="remote-url">Remote URL</Label>
<Input
id="remote-url"
placeholder="https://github.com/user/repo.git"
value={newRemoteUrl}
onChange={(e) => {
setNewRemoteUrl(e.target.value);
setAddRemoteError(null);
}}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
newRemoteName.trim() &&
newRemoteUrl.trim() &&
!isAddingRemote
) {
handleAddRemote();
}
}}
disabled={isAddingRemote}
/>
<p className="text-xs text-muted-foreground">
Supports HTTPS, SSH (git@github.com:user/repo.git), or git:// URLs
</p>
</div>
{addRemoteError && (
<div className="flex items-center gap-2 text-destructive"> <div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" /> <AlertTriangle className="w-4 h-4" />
<span className="text-sm">{error}</span> <span className="text-sm">{addRemoteError}</span>
</div> </div>
<Button variant="outline" size="sm" onClick={fetchRemotes}> )}
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div> </div>
) : ( );
const renderRemoteSelector = () => (
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label> <Label htmlFor="remote-select">Select Remote</Label>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAddRemoteForm(true)}
className="h-6 px-2 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -194,6 +299,7 @@ export function PushToRemoteDialog({
Refresh Refresh
</Button> </Button>
</div> </div>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}> <Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select"> <SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" /> <SelectValue placeholder="Select a remote" />
@@ -225,8 +331,45 @@ export function PushToRemoteDialog({
</div> </div>
)} )}
</div> </div>
)} );
const renderFooter = () => {
if (showAddRemoteForm) {
return (
<DialogFooter>
{remotes.length > 0 && (
<Button
variant="outline"
onClick={() => setShowAddRemoteForm(false)}
disabled={isAddingRemote}
>
Back
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAddingRemote}>
Cancel
</Button>
<Button
onClick={handleAddRemote}
disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || isAddingRemote}
>
{isAddingRemote ? (
<>
<Spinner size="sm" className="mr-2" />
Adding...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Add Remote
</>
)}
</Button>
</DialogFooter>
);
}
return (
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
@@ -236,6 +379,67 @@ export function PushToRemoteDialog({
Push to {selectedRemote || 'Remote'} Push to {selectedRemote || 'Remote'}
</Button> </Button>
</DialogFooter> </DialogFooter>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{showAddRemoteForm ? (
<>
<Plus className="w-5 h-5 text-primary" />
Add Remote
</>
) : (
<>
<Upload className="w-5 h-5 text-primary" />
Push New Branch to Remote
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
<Sparkles className="w-3 h-3" />
new
</span>
</>
)}
</DialogTitle>
<DialogDescription>
{showAddRemoteForm ? (
<>Add a remote repository to push your changes to.</>
) : (
<>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
</>
)}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error && !showAddRemoteForm ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
<span className="text-sm">{error}</span>
</div>
<Button variant="outline" size="sm" onClick={fetchRemotes}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : showAddRemoteForm ? (
renderAddRemoteForm()
) : (
renderRemoteSelector()
)}
{renderFooter()}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -27,7 +27,7 @@ import {
Copy, Copy,
Eye, Eye,
ScrollText, ScrollText,
Sparkles, CloudOff,
Terminal, Terminal,
SquarePlus, SquarePlus,
SplitSquareHorizontal, SplitSquareHorizontal,
@@ -365,9 +365,9 @@ export function WorktreeActionsDropdown({
{isPushing ? 'Pushing...' : 'Push'} {isPushing ? 'Pushing...' : 'Push'}
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />} {!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
{canPerformGitOps && !hasRemoteBranch && ( {canPerformGitOps && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded"> <span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<Sparkles className="w-2.5 h-2.5" /> <CloudOff className="w-2.5 h-2.5" />
new local only
</span> </span>
)} )}
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && ( {canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (

View File

@@ -151,7 +151,7 @@ export function useWorktreeDiffs(projectPath: string | undefined, featureId: str
interface BranchInfo { interface BranchInfo {
name: string; name: string;
isCurrent: boolean; isCurrent: boolean;
isRemote?: boolean; isRemote: boolean;
lastCommit?: string; lastCommit?: string;
upstream?: string; upstream?: string;
} }
@@ -161,6 +161,7 @@ interface BranchesResult {
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
hasRemoteBranch: boolean; hasRemoteBranch: boolean;
hasAnyRemotes: boolean;
isGitRepo: boolean; isGitRepo: boolean;
hasCommits: boolean; hasCommits: boolean;
} }
@@ -188,6 +189,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
aheadCount: 0, aheadCount: 0,
behindCount: 0, behindCount: 0,
hasRemoteBranch: false, hasRemoteBranch: false,
hasAnyRemotes: false,
isGitRepo: false, isGitRepo: false,
hasCommits: false, hasCommits: false,
}; };
@@ -198,6 +200,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
aheadCount: 0, aheadCount: 0,
behindCount: 0, behindCount: 0,
hasRemoteBranch: false, hasRemoteBranch: false,
hasAnyRemotes: result.result?.hasAnyRemotes ?? false,
isGitRepo: true, isGitRepo: true,
hasCommits: false, hasCommits: false,
}; };
@@ -212,6 +215,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
aheadCount: result.result?.aheadCount ?? 0, aheadCount: result.result?.aheadCount ?? 0,
behindCount: result.result?.behindCount ?? 0, behindCount: result.result?.behindCount ?? 0,
hasRemoteBranch: result.result?.hasRemoteBranch ?? false, hasRemoteBranch: result.result?.hasRemoteBranch ?? false,
hasAnyRemotes: result.result?.hasAnyRemotes ?? false,
isGitRepo: true, isGitRepo: true,
hasCommits: true, hasCommits: true,
}; };

View File

@@ -1782,6 +1782,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
aheadCount: 2, aheadCount: 2,
behindCount: 0, behindCount: 0,
hasRemoteBranch: true, hasRemoteBranch: true,
hasAnyRemotes: true,
}, },
}; };
}, },

View File

@@ -1878,6 +1878,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/switch-branch', { worktreePath, branchName }), this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
listRemotes: (worktreePath: string) => listRemotes: (worktreePath: string) =>
this.post('/api/worktree/list-remotes', { worktreePath }), 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) => openInEditor: (worktreePath: string, editorCommand?: string) =>
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
getDefaultEditor: () => this.get('/api/worktree/default-editor'), getDefaultEditor: () => this.get('/api/worktree/default-editor'),

View File

@@ -7,6 +7,12 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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 * Determine if the current model supports extended thinking controls
* Note: This is for Claude's "thinking levels" only, not Codex's "reasoning effort" * Note: This is for Claude's "thinking levels" only, not Codex's "reasoning effort"

View File

@@ -951,6 +951,7 @@ export interface WorktreeAPI {
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
hasRemoteBranch: boolean; hasRemoteBranch: boolean;
hasAnyRemotes: boolean;
}; };
error?: string; error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues 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'; 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 // Open a worktree directory in the editor
openInEditor: ( openInEditor: (
worktreePath: string, worktreePath: string,

View File

@@ -330,7 +330,14 @@ export type {
export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js'; export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js';
// Worktree and PR types // 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'; export { PR_STATES, validatePRState } from './worktree.js';
// Terminal types // Terminal types

View File

@@ -30,3 +30,47 @@ export interface WorktreePRInfo {
state: PRState; state: PRState;
createdAt: string; 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;
}

View File

@@ -17,6 +17,10 @@
"./debounce": { "./debounce": {
"types": "./dist/debounce.d.ts", "types": "./dist/debounce.d.ts",
"default": "./dist/debounce.js" "default": "./dist/debounce.js"
},
"./error-handler": {
"types": "./dist/error-handler.d.ts",
"default": "./dist/error-handler.js"
} }
}, },
"scripts": { "scripts": {