mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge pull request #649 from AutoMaker-Org/feat/detect-no-remote-branch
fix: detect no remote branch
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
166
apps/server/src/routes/worktree/routes/add-remote.ts
Normal file
166
apps/server/src/routes/worktree/routes/add-remote.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
565
apps/server/tests/unit/routes/worktree/add-remote.test.ts
Normal file
565
apps/server/tests/unit/routes/worktree/add-remote.test.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1782,6 +1782,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
aheadCount: 2,
|
||||
behindCount: 0,
|
||||
hasRemoteBranch: true,
|
||||
hasAnyRemotes: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"
|
||||
|
||||
18
apps/ui/src/types/electron.d.ts
vendored
18
apps/ui/src/types/electron.d.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user