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';
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;
}

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
let aheadCount = 0;
let behindCount = 0;
@@ -154,6 +166,7 @@ export function createListBranchesHandler() {
aheadCount,
behindCount,
hasRemoteBranch,
hasAnyRemotes,
},
});
} 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 {
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<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
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,36 +133,12 @@ export function PushToRemoteDialog({
}
}, [remotes, selectedRemote]);
const fetchRemotes = 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) {
// 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 {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
} finally {
setIsLoading(false);
// 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;
@@ -115,47 +151,270 @@ export function PushToRemoteDialog({
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
const remoteInfos = transformRemoteData(result.result.remotes);
updateRemotesState(remoteInfos);
toast.success('Remotes refreshed');
} else {
toast.error(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes');
toast.error(getErrorMessage(err));
} finally {
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 = () => {
if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote);
onOpenChange(false);
};
const renderAddRemoteForm = () => (
<div className="grid gap-4 py-4">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<Link className="w-4 h-4" />
<span className="text-sm">
{remotes.length === 0
? 'No remotes found. Add a remote to push your branch.'
: 'Add a new remote'}
</span>
</div>
<div className="grid gap-2">
<Label htmlFor="remote-name">Remote Name</Label>
<Input
id="remote-name"
placeholder="origin"
value={newRemoteName}
onChange={(e) => {
setNewRemoteName(e.target.value);
setAddRemoteError(null);
}}
disabled={isAddingRemote}
/>
</div>
<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">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{addRemoteError}</span>
</div>
)}
</div>
);
const renderRemoteSelector = () => (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<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
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a new remote branch{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
and set up tracking.
</p>
</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>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Upload className="w-4 h-4 mr-2" />
Push to {selectedRemote || 'Remote'}
</Button>
</DialogFooter>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<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>
{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>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
{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>
@@ -163,7 +422,7 @@ export function PushToRemoteDialog({
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
) : 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" />
@@ -174,68 +433,13 @@ export function PushToRemoteDialog({
Retry
</Button>
</div>
) : showAddRemoteForm ? (
renderAddRemoteForm()
) : (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a new remote branch{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
and set up tracking.
</p>
</div>
)}
</div>
renderRemoteSelector()
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Upload className="w-4 h-4 mr-2" />
Push to {selectedRemote || 'Remote'}
</Button>
</DialogFooter>
{renderFooter()}
</DialogContent>
</Dialog>
);

View File

@@ -27,7 +27,7 @@ import {
Copy,
Eye,
ScrollText,
Sparkles,
CloudOff,
Terminal,
SquarePlus,
SplitSquareHorizontal,
@@ -365,9 +365,9 @@ export function WorktreeActionsDropdown({
{isPushing ? 'Pushing...' : 'Push'}
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
{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">
<Sparkles className="w-2.5 h-2.5" />
new
<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">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (

View File

@@ -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,
};

View File

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

View File

@@ -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'),

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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": {