mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
feat: add remote management functionality
- Introduced a new route for adding remotes to git worktrees. - Enhanced the PushToRemoteDialog component to support adding new remotes, including form handling and error management. - Updated the API client to include an endpoint for adding remotes. - Modified the worktree state management to track the presence of remotes. - Improved the list branches handler to check for configured remotes. This update allows users to easily add remotes through the UI, enhancing the overall git workflow experience.
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;
|
||||
}
|
||||
|
||||
171
apps/server/src/routes/worktree/routes/add-remote.ts
Normal file
171
apps/server/src/routes/worktree/routes/add-remote.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!remoteName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'remoteName required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!remoteUrl) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'remoteUrl 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 {
|
||||
// If git remote fails, continue with adding the remote
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Fetch failed (maybe offline or invalid URL), but remote was added successfully
|
||||
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,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
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';
|
||||
|
||||
@@ -30,6 +31,16 @@ interface RemoteInfo {
|
||||
|
||||
const logger = createLogger('PushToRemoteDialog');
|
||||
|
||||
/**
|
||||
* Extracts error message from unknown error type
|
||||
*/
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
interface PushToRemoteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -49,6 +60,13 @@ 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);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
@@ -61,6 +79,10 @@ export function PushToRemoteDialog({
|
||||
if (!open) {
|
||||
setSelectedRemote('');
|
||||
setError(null);
|
||||
setShowAddRemoteForm(false);
|
||||
setNewRemoteName('origin');
|
||||
setNewRemoteUrl('');
|
||||
setAddRemoteError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -73,6 +95,36 @@ export function PushToRemoteDialog({
|
||||
}
|
||||
}, [remotes, selectedRemote]);
|
||||
|
||||
// Show add remote form when no remotes
|
||||
useEffect(() => {
|
||||
if (!isLoading && remotes.length === 0) {
|
||||
setShowAddRemoteForm(true);
|
||||
}
|
||||
}, [isLoading, remotes.length]);
|
||||
|
||||
/**
|
||||
* 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 = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
@@ -84,21 +136,14 @@ export function PushToRemoteDialog({
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
// Extract just the remote info (name and URL), not the branches
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
if (remoteInfos.length === 0) {
|
||||
setError('No remotes found in this repository. Please add a remote first.');
|
||||
}
|
||||
const remoteInfos = transformRemoteData(result.result.remotes);
|
||||
updateRemotesState(remoteInfos);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch remotes:', err);
|
||||
setError('Failed to fetch remotes');
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -115,47 +160,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 +431,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 +442,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'),
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user