Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)

* Changes from fix/fetch-before-pull-fetch

* feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count

* feat: Add validation for remote names and improve error handling

* Address PR comments and mobile layout fixes

* ```
refactor: Extract PR target resolution logic into dedicated service
```

* feat: Add app shell UI and improve service imports. Address PR comments

* fix: Improve security validation and cache handling in git operations

* feat: Add GET /list endpoint and improve parameter handling

* chore: Improve validation, accessibility, and error handling across apps

* chore: Format vite server port configuration

* fix: Add error handling for gh pr list command and improve offline fallbacks

* fix: Preserve existing PR creation time and improve remote handling
This commit is contained in:
gsxdsm
2026-02-19 21:55:12 -08:00
committed by GitHub
parent ee52333636
commit 7df2182818
80 changed files with 4729 additions and 1107 deletions

View File

@@ -33,6 +33,11 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.get(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post(
'/create',

View File

@@ -1,5 +1,7 @@
/**
* POST /list endpoint - List all features for a project
* POST/GET /list endpoint - List all features for a project
*
* projectPath may come from req.body (POST) or req.query (GET fallback).
*
* Also performs orphan detection when a project is loaded to identify
* features whose branches no longer exist. This runs on every project load/switch.
@@ -19,7 +21,17 @@ export function createListHandler(
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
const bodyProjectPath =
typeof req.body === 'object' && req.body !== null
? (req.body as { projectPath?: unknown }).projectPath
: undefined;
const queryProjectPath = req.query.projectPath;
const projectPath =
typeof bodyProjectPath === 'string'
? bodyProjectPath
: typeof queryProjectPath === 'string'
? queryProjectPath
: undefined;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });

View File

@@ -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';
@@ -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
@@ -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
*/

View File

@@ -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');
@@ -32,6 +33,7 @@ export function createCreatePRHandler() {
baseBranch,
draft,
remote,
targetRemote,
} = req.body as {
worktreePath: string;
projectPath?: string;
@@ -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) {
@@ -71,6 +75,52 @@ export function createCreatePRHandler() {
return;
}
// --- Input validation: run all validation before any git write operations ---
// 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;
}
const pushRemote = remote || 'origin';
// Resolve repository URL, fork workflow, and target remote information.
// This is needed for both the existing PR check and PR creation.
// Resolve early so validation errors are caught before any writes.
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const prTarget = await resolvePrTarget({
worktreePath,
pushRemote,
targetRemote,
});
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;
}
// --- Validation complete — proceed with git operations ---
// Check for uncommitted changes
logger.debug(`Checking for uncommitted changes in: ${worktreePath}`);
const { stdout: status } = await execAsync('git status --porcelain', {
@@ -119,30 +169,19 @@ export function createCreatePRHandler() {
}
}
// Validate remote name 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;
}
// Push the branch to remote (use selected remote or default to 'origin')
const pushRemote = remote || 'origin';
// Uses array-based execGitCommand to avoid shell injection from pushRemote/branchName.
let pushError: string | null = null;
try {
await execAsync(`git push ${pushRemote} ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
await execGitCommand(['push', pushRemote, branchName], worktreePath, execEnv);
} catch {
// If push fails, try with --set-upstream
try {
await execAsync(`git push --set-upstream ${pushRemote} ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
await execGitCommand(
['push', '--set-upstream', pushRemote, branchName],
worktreePath,
execEnv
);
} catch (error2: unknown) {
// Capture push error for reporting
const err = error2 as { stderr?: string; message?: string };
@@ -164,82 +203,11 @@ 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
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,
});
// 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
}
}
// Check if gh CLI is available (cross-platform)
ghCliAvailable = await isGhCliAvailable();
@@ -247,13 +215,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}`;
}
}
@@ -263,18 +234,40 @@ 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}"` : '';
logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
try {
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
logger.debug(`Running: ${listCmd}`);
const { stdout: existingPrOutput } = await execAsync(listCmd, {
const listArgs = ['pr', 'list'];
if (upstreamRepo) {
listArgs.push('--repo', upstreamRepo);
}
listArgs.push(
'--head',
headRef,
'--json',
'number,title,url,state,createdAt',
'--limit',
'1'
);
logger.debug(`Running: gh ${listArgs.join(' ')}`);
const listResult = await spawnProcess({
command: 'gh',
args: listArgs,
cwd: worktreePath,
env: execEnv,
});
if (listResult.exitCode !== 0) {
logger.error(
`gh pr list failed with exit code ${listResult.exitCode}: ` +
`stderr=${listResult.stderr}, stdout=${listResult.stdout}`
);
throw new Error(
`gh pr list failed (exit code ${listResult.exitCode}): ${listResult.stderr || listResult.stdout}`
);
}
const existingPrOutput = listResult.stdout;
logger.debug(`gh pr list output: ${existingPrOutput}`);
const existingPrs = JSON.parse(existingPrOutput);
@@ -294,7 +287,7 @@ export function createCreatePRHandler() {
url: existingPr.url,
title: existingPr.title || title,
state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(),
createdAt: existingPr.createdAt || new Date().toISOString(),
});
logger.debug(
`Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
@@ -372,11 +365,26 @@ export function createCreatePRHandler() {
if (errorMessage.toLowerCase().includes('already exists')) {
logger.debug(`PR already exists error - trying to fetch existing PR`);
try {
const { stdout: viewOutput } = await execAsync(
`gh pr view --json number,title,url,state`,
{ cwd: worktreePath, env: execEnv }
);
const existingPr = JSON.parse(viewOutput);
// Build args as an array to avoid shell injection.
// When upstreamRepo is set (fork/cross-remote workflow) we must
// query the upstream repository so we find the correct PR.
const viewArgs = ['pr', 'view', '--json', 'number,title,url,state,createdAt'];
if (upstreamRepo) {
viewArgs.push('--repo', upstreamRepo);
}
logger.debug(`Running: gh ${viewArgs.join(' ')}`);
const viewResult = await spawnProcess({
command: 'gh',
args: viewArgs,
cwd: worktreePath,
env: execEnv,
});
if (viewResult.exitCode !== 0) {
throw new Error(
`gh pr view failed (exit code ${viewResult.exitCode}): ${viewResult.stderr || viewResult.stdout}`
);
}
const existingPr = JSON.parse(viewResult.stdout);
if (existingPr.url) {
prUrl = existingPr.url;
prNumber = existingPr.number;
@@ -388,7 +396,7 @@ export function createCreatePRHandler() {
url: existingPr.url,
title: existingPr.title || title,
state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(),
createdAt: existingPr.createdAt || new Date().toISOString(),
});
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
}

View File

@@ -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();
@@ -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) {
@@ -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 {

View File

@@ -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(
@@ -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`],
@@ -174,6 +181,7 @@ export function createListBranchesHandler() {
behindCount,
hasRemoteBranch,
hasAnyRemotes,
trackingRemote,
},
});
} catch (error) {

View File

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

View File

@@ -14,17 +14,19 @@
import type { Request, Response } from 'express';
import path from 'path';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { getErrorMessage, logError, isValidBranchName, isValidRemoteName } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
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) {
@@ -55,6 +57,15 @@ export function createRebaseHandler(events: EventEmitter) {
return;
}
// Validate optional remote name to reject unsafe characters at the route layer
if (remote !== undefined && !isValidRemoteName(remote)) {
res.status(400).json({
success: false,
error: `Invalid remote name: "${remote}"`,
});
return;
}
// Emit started event
events.emit('rebase:started', {
worktreePath: resolvedWorktreePath,
@@ -62,7 +73,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