Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 7 additions & 21 deletions apps/server/src/routes/worktree/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
* Common utilities for worktree routes
*/

import { createLogger, isValidBranchName, MAX_BRANCH_NAME_LENGTH } from '@automaker/utils';
import {
createLogger,
isValidBranchName,
isValidRemoteName,
MAX_BRANCH_NAME_LENGTH,
} from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
Expand All @@ -16,7 +21,7 @@ export const execAsync = promisify(exec);

// Re-export git validation utilities from the canonical shared module so
// existing consumers that import from this file continue to work.
export { isValidBranchName, MAX_BRANCH_NAME_LENGTH };
export { isValidBranchName, isValidRemoteName, MAX_BRANCH_NAME_LENGTH };

// ============================================================================
// Extended PATH configuration for Electron apps
Expand Down Expand Up @@ -60,25 +65,6 @@ export const execEnv = {
PATH: extendedPath,
};

/**
* Validate git remote name to prevent command injection.
* Matches the strict validation used in add-remote.ts:
* - Rejects empty strings and names that are too long
* - Disallows names that start with '-' or '.'
* - Forbids the substring '..'
* - Rejects '/' characters
* - Rejects NUL bytes
* - Must consist only of alphanumerics, hyphens, underscores, and dots
*/
export function isValidRemoteName(name: string): boolean {
if (!name || name.length === 0 || name.length >= MAX_BRANCH_NAME_LENGTH) return false;
if (name.startsWith('-') || name.startsWith('.')) return false;
if (name.includes('..')) return false;
if (name.includes('/')) return false;
if (name.includes('\0')) return false;
return /^[a-zA-Z0-9._-]+$/.test(name);
}

/**
* Check if gh CLI is available on the system
*/
Expand Down
104 changes: 35 additions & 69 deletions apps/server/src/routes/worktree/routes/create-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { spawnProcess } from '@automaker/platform';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
import { resolvePrTarget } from '../../../services/pr-service.js';

const logger = createLogger('CreatePR');

Expand All @@ -32,6 +33,7 @@ export function createCreatePRHandler() {
baseBranch,
draft,
remote,
targetRemote,
} = req.body as {
worktreePath: string;
projectPath?: string;
Expand All @@ -41,6 +43,8 @@ export function createCreatePRHandler() {
baseBranch?: string;
draft?: boolean;
remote?: string;
/** Remote to create the PR against (e.g. upstream). If not specified, inferred from repo setup. */
targetRemote?: string;
};

if (!worktreePath) {
Expand Down Expand Up @@ -119,14 +123,21 @@ export function createCreatePRHandler() {
}
}

// Validate remote name before use to prevent command injection
// Validate remote names before use to prevent command injection
if (remote !== undefined && !isValidRemoteName(remote)) {
res.status(400).json({
success: false,
error: 'Invalid remote name contains unsafe characters',
});
return;
}
if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) {
res.status(400).json({
success: false,
error: 'Invalid target remote name contains unsafe characters',
});
return;
}

// Push the branch to remote (use selected remote or default to 'origin')
const pushRemote = remote || 'origin';
Expand Down Expand Up @@ -164,80 +175,32 @@ export function createCreatePRHandler() {
const base = baseBranch || 'main';
const title = prTitle || branchName;
const body = prBody || `Changes from branch ${branchName}`;
const draftFlag = draft ? '--draft' : '';

let prUrl: string | null = null;
let prError: string | null = null;
let browserUrl: string | null = null;
let ghCliAvailable = false;

// Get repository URL and detect fork workflow FIRST
// This is needed for both the existing PR check and PR creation
// Resolve repository URL, fork workflow, and target remote information.
// This is needed for both the existing PR check and PR creation.
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const { stdout: remotes } = await execAsync('git remote -v', {
cwd: worktreePath,
env: execEnv,
const prTarget = await resolvePrTarget({
worktreePath,
pushRemote,
targetRemote,
});

// Parse remotes to detect fork workflow and get repo URL
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
for (const line of lines) {
// Try multiple patterns to match different remote URL formats
// Pattern 1: git@github.com:owner/repo.git (fetch)
// Pattern 2: https://github.com/owner/repo.git (fetch)
// Pattern 3: https://github.com/owner/repo (fetch)
let match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
if (!match) {
// Try SSH format: git@github.com:owner/repo.git
match = line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
}
if (!match) {
// Try HTTPS format: https://github.com/owner/repo.git
match = line.match(
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
);
}

if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === 'upstream') {
upstreamRepo = `${owner}/${repo}`;
repoUrl = `https://github.com/${owner}/${repo}`;
} else if (remoteName === 'origin') {
originOwner = owner;
if (!repoUrl) {
repoUrl = `https://github.com/${owner}/${repo}`;
}
}
}
}
} catch {
// Couldn't parse remotes - will try fallback
}

// Fallback: Try to get repo URL from git config if remote parsing failed
if (!repoUrl) {
try {
const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
cwd: worktreePath,
env: execEnv,
});
const url = originUrl.trim();

// Parse URL to extract owner/repo
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
if (match) {
const [, owner, repo] = match;
originOwner = owner;
repoUrl = `https://github.com/${owner}/${repo}`;
}
} catch {
// Failed to get repo URL from config
}
repoUrl = prTarget.repoUrl;
upstreamRepo = prTarget.upstreamRepo;
originOwner = prTarget.originOwner;
} catch (resolveErr) {
// resolvePrTarget throws for validation errors (unknown targetRemote, missing pushRemote)
res.status(400).json({
success: false,
error: getErrorMessage(resolveErr),
});
return;
}

// Check if gh CLI is available (cross-platform)
Expand All @@ -247,13 +210,16 @@ export function createCreatePRHandler() {
if (repoUrl) {
const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body);
// Encode base branch and head branch to handle special chars like # or %
const encodedBase = encodeURIComponent(base);
const encodedBranch = encodeURIComponent(branchName);

if (upstreamRepo && originOwner) {
// Fork workflow: PR to upstream from origin
browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
// Fork workflow (or cross-remote PR): PR to target from push remote
browserUrl = `https://github.com/${upstreamRepo}/compare/${encodedBase}...${originOwner}:${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
} else {
// Regular repo
browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
browserUrl = `${repoUrl}/compare/${encodedBase}...${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
}
}

Expand All @@ -263,7 +229,7 @@ export function createCreatePRHandler() {
if (ghCliAvailable) {
// First, check if a PR already exists for this branch using gh pr list
// This is more reliable than gh pr view as it explicitly searches by branch name
// For forks, we need to use owner:branch format for the head parameter
// For forks/cross-remote, we need to use owner:branch format for the head parameter
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : '';

Expand Down
59 changes: 58 additions & 1 deletion apps/server/src/routes/worktree/routes/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,41 @@ async function findExistingWorktreeForBranch(
}
}

/**
* Detect whether a base branch reference is a remote branch (e.g. "origin/main").
* Returns the remote name if it matches a known remote, otherwise null.
*/
async function detectRemoteBranch(
projectPath: string,
baseBranch: string
): Promise<{ remote: string; branch: string } | null> {
const slashIndex = baseBranch.indexOf('/');
if (slashIndex <= 0) return null;

const possibleRemote = baseBranch.substring(0, slashIndex);

try {
// Check if this is actually a remote name by listing remotes
const stdout = await execGitCommand(['remote'], projectPath);
const remotes = stdout
.trim()
.split('\n')
.map((r: string) => r.trim())
.filter(Boolean);

if (remotes.includes(possibleRemote)) {
return {
remote: possibleRemote,
branch: baseBranch.substring(slashIndex + 1),
};
}
} catch {
// Not a git repo or no remotes β€” fall through
}

return null;
}

export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
const worktreeService = new WorktreeService();

Expand All @@ -91,7 +126,7 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
const { projectPath, branchName, baseBranch } = req.body as {
projectPath: string;
branchName: string;
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD). Can be a remote branch like "origin/main".
};

if (!projectPath || !branchName) {
Expand Down Expand Up @@ -171,6 +206,28 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
// Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true });

// If a base branch is specified and it's a remote branch, fetch from that remote first
// This ensures we have the latest refs before creating the worktree
if (baseBranch && baseBranch !== 'HEAD') {
const remoteBranchInfo = await detectRemoteBranch(projectPath, baseBranch);
if (remoteBranchInfo) {
logger.info(
`Fetching from remote "${remoteBranchInfo.remote}" before creating worktree (base: ${baseBranch})`
);
try {
await execGitCommand(
['fetch', remoteBranchInfo.remote, remoteBranchInfo.branch],
projectPath
);
} catch (fetchErr) {
// Non-fatal: log but continue β€” the ref might already be cached locally
logger.warn(
`Failed to fetch from remote "${remoteBranchInfo.remote}": ${getErrorMessage(fetchErr)}`
);
}
}
}

// Check if branch exists (using array arguments to prevent injection)
let branchExists = false;
try {
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/routes/worktree/routes/list-branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export function createListBranchesHandler() {
let aheadCount = 0;
let behindCount = 0;
let hasRemoteBranch = false;
let trackingRemote: string | undefined;
try {
// First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execFileAsync(
Expand All @@ -138,8 +139,14 @@ export function createListBranchesHandler() {
{ cwd: worktreePath }
);

if (upstreamOutput.trim()) {
const upstreamRef = upstreamOutput.trim();
if (upstreamRef) {
hasRemoteBranch = true;
// Extract the remote name from the upstream ref (e.g. "origin/main" -> "origin")
const slashIndex = upstreamRef.indexOf('/');
if (slashIndex !== -1) {
trackingRemote = upstreamRef.slice(0, slashIndex);
}
const { stdout: aheadBehindOutput } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`],
Expand Down Expand Up @@ -174,6 +181,7 @@ export function createListBranchesHandler() {
behindCount,
hasRemoteBranch,
hasAnyRemotes,
trackingRemote,
},
});
} catch (error) {
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/routes/worktree/routes/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ export function createMergeHandler(events: EventEmitter) {
branchName: string;
worktreePath: string;
targetBranch?: string; // Branch to merge into (defaults to 'main')
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
options?: {
squash?: boolean;
message?: string;
deleteWorktreeAndBranch?: boolean;
remote?: string;
};
};

if (!projectPath || !branchName || !worktreePath) {
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/routes/worktree/routes/rebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import { runRebase } from '../../../services/rebase-service.js';
export function createRebaseHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, ontoBranch } = req.body as {
const { worktreePath, ontoBranch, remote } = req.body as {
worktreePath: string;
/** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */
ontoBranch: string;
/** Remote name to fetch from before rebasing (defaults to 'origin') */
remote?: string;
};

if (!worktreePath) {
Expand Down Expand Up @@ -62,7 +64,7 @@ export function createRebaseHandler(events: EventEmitter) {
});

// Execute the rebase via the service
const result = await runRebase(resolvedWorktreePath, ontoBranch);
const result = await runRebase(resolvedWorktreePath, ontoBranch, { remote });

if (result.success) {
// Emit success event
Expand Down
Loading