mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -168,6 +168,20 @@ ${feature.spec}
|
||||
feature = await this.loadFeatureFn(projectPath, featureId);
|
||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||
|
||||
// Update status to in_progress immediately after acquiring the feature.
|
||||
// This prevents a race condition where the UI reloads features and sees the
|
||||
// feature still in 'backlog' status while it's actually being executed.
|
||||
// Only do this for the initial call (not internal/recursive calls which would
|
||||
// redundantly update the status).
|
||||
if (
|
||||
!options?._calledInternally &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted')
|
||||
) {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
||||
}
|
||||
|
||||
if (!options?.continuationPrompt) {
|
||||
if (feature.planSpec?.status === 'approved') {
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
|
||||
@@ -199,7 +213,18 @@ ${feature.spec}
|
||||
validateWorkingDirectory(workDir);
|
||||
tempRunningFeature.worktreePath = worktreePath;
|
||||
tempRunningFeature.branchName = branchName ?? null;
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
||||
// Ensure status is in_progress (may already be set from the early update above,
|
||||
// but internal/recursive calls skip the early update and need it here).
|
||||
// Mirror the external guard: only transition when the feature is still in
|
||||
// backlog, ready, or interrupted to avoid overwriting a concurrent terminal status.
|
||||
if (
|
||||
options?._calledInternally &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted')
|
||||
) {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
||||
featureId,
|
||||
projectPath,
|
||||
|
||||
@@ -225,6 +225,14 @@ export class FeatureLoader {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clear transient runtime flag - titleGenerating is only meaningful during
|
||||
// the current session's async title generation. If it was persisted (e.g.,
|
||||
// app closed before generation completed), it would cause the UI to show
|
||||
// "Generating title..." indefinitely.
|
||||
if (feature.titleGenerating) {
|
||||
delete feature.titleGenerating;
|
||||
}
|
||||
|
||||
return feature;
|
||||
});
|
||||
|
||||
@@ -323,7 +331,14 @@ export class FeatureLoader {
|
||||
|
||||
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||
|
||||
return result.data;
|
||||
const feature = result.data;
|
||||
|
||||
// Clear transient runtime flag (same as in getAll)
|
||||
if (feature?.titleGenerating) {
|
||||
delete feature.titleGenerating;
|
||||
}
|
||||
|
||||
return feature;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -367,8 +382,15 @@ export class FeatureLoader {
|
||||
descriptionHistory: initialHistory,
|
||||
};
|
||||
|
||||
// Remove transient runtime fields before persisting to disk.
|
||||
// titleGenerating is UI-only state that tracks in-flight async title generation.
|
||||
// Persisting it can cause cards to show "Generating title..." indefinitely
|
||||
// if the app restarts before generation completes.
|
||||
const featureToWrite = { ...feature };
|
||||
delete featureToWrite.titleGenerating;
|
||||
|
||||
// Write feature.json atomically with backup support
|
||||
await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
logger.info(`Created feature ${featureId}`);
|
||||
return feature;
|
||||
@@ -452,9 +474,13 @@ export class FeatureLoader {
|
||||
descriptionHistory: updatedHistory,
|
||||
};
|
||||
|
||||
// Remove transient runtime fields before persisting (same as create)
|
||||
const featureToWrite = { ...updatedFeature };
|
||||
delete featureToWrite.titleGenerating;
|
||||
|
||||
// Write back to file atomically with backup support
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
logger.info(`Updated feature ${featureId}`);
|
||||
return updatedFeature;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Extracted from worktree merge route to allow internal service calls.
|
||||
*/
|
||||
|
||||
import { createLogger, isValidBranchName } from '@automaker/utils';
|
||||
import { createLogger, isValidBranchName, isValidRemoteName } from '@automaker/utils';
|
||||
import { type EventEmitter } from '../lib/events.js';
|
||||
import { execGitCommand } from '@automaker/git-utils';
|
||||
const logger = createLogger('MergeService');
|
||||
@@ -13,6 +13,8 @@ export interface MergeOptions {
|
||||
squash?: boolean;
|
||||
message?: string;
|
||||
deleteWorktreeAndBranch?: boolean;
|
||||
/** Remote name to fetch from before merging (defaults to 'origin') */
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
export interface MergeServiceResult {
|
||||
@@ -35,7 +37,11 @@ export interface MergeServiceResult {
|
||||
* @param branchName - Source branch to merge
|
||||
* @param worktreePath - Path to the worktree (used for deletion if requested)
|
||||
* @param targetBranch - Branch to merge into (defaults to 'main')
|
||||
* @param options - Merge options (squash, message, deleteWorktreeAndBranch)
|
||||
* @param options - Merge options
|
||||
* @param options.squash - If true, perform a squash merge
|
||||
* @param options.message - Custom merge commit message
|
||||
* @param options.deleteWorktreeAndBranch - If true, delete worktree and branch after merge
|
||||
* @param options.remote - Remote name to fetch from before merging (defaults to 'origin')
|
||||
*/
|
||||
export async function performMerge(
|
||||
projectPath: string,
|
||||
@@ -88,6 +94,33 @@ export async function performMerge(
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the remote name to prevent git option injection.
|
||||
// Reject invalid remote names so the caller knows their input was wrong,
|
||||
// consistent with how invalid branch names are handled above.
|
||||
const remote = options?.remote || 'origin';
|
||||
if (!isValidRemoteName(remote)) {
|
||||
logger.warn('Invalid remote name supplied to merge-service', {
|
||||
remote,
|
||||
projectPath,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid remote name: "${remote}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch latest from remote before merging to ensure we have up-to-date refs
|
||||
try {
|
||||
await execGitCommand(['fetch', remote], projectPath);
|
||||
} catch (fetchError) {
|
||||
logger.warn('Failed to fetch from remote before merge; proceeding with local refs', {
|
||||
remote,
|
||||
projectPath,
|
||||
error: (fetchError as Error).message,
|
||||
});
|
||||
// Non-fatal: proceed with local refs if fetch fails (e.g. offline)
|
||||
}
|
||||
|
||||
// Emit merge:start after validating inputs
|
||||
emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });
|
||||
|
||||
|
||||
225
apps/server/src/services/pr-service.ts
Normal file
225
apps/server/src/services/pr-service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Service for resolving PR target information from git remotes.
|
||||
*
|
||||
* Extracts remote-parsing and target-resolution logic that was previously
|
||||
* inline in the create-pr route handler.
|
||||
*/
|
||||
|
||||
// TODO: Move execAsync/execEnv to a shared lib (lib/exec.ts or @automaker/utils) so that
|
||||
// services no longer depend on route internals. Tracking issue: route-to-service dependency
|
||||
// inversion. For now, a local thin wrapper is used within the service boundary.
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { createLogger, isValidRemoteName } from '@automaker/utils';
|
||||
|
||||
// Thin local wrapper — duplicates the route-level execAsync/execEnv until a
|
||||
// shared lib/exec.ts (or @automaker/utils export) is created.
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const _additionalPaths: string[] = [];
|
||||
if (process.platform === 'win32') {
|
||||
if (process.env.LOCALAPPDATA)
|
||||
_additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
if (process.env.PROGRAMFILES) _additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
if (process.env['ProgramFiles(x86)'])
|
||||
_additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
||||
} else {
|
||||
_additionalPaths.push(
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
`${process.env.HOME}/.local/bin`
|
||||
);
|
||||
}
|
||||
const execEnv = {
|
||||
...process.env,
|
||||
PATH: [process.env.PATH, ..._additionalPaths.filter(Boolean)].filter(Boolean).join(pathSeparator),
|
||||
};
|
||||
|
||||
const logger = createLogger('PRService');
|
||||
|
||||
export interface ParsedRemote {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export interface PrTargetResult {
|
||||
repoUrl: string | null;
|
||||
targetRepo: string | null;
|
||||
pushOwner: string | null;
|
||||
upstreamRepo: string | null;
|
||||
originOwner: string | null;
|
||||
parsedRemotes: Map<string, ParsedRemote>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all git remotes for the given repo path and resolve the PR target.
|
||||
*
|
||||
* @param worktreePath - Working directory of the repository / worktree
|
||||
* @param pushRemote - Remote used for pushing (e.g. "origin")
|
||||
* @param targetRemote - Explicit remote to target the PR against (optional)
|
||||
*
|
||||
* @throws {Error} When targetRemote is specified but not found among repository remotes
|
||||
* @throws {Error} When pushRemote is not found among parsed remotes (when targetRemote is specified)
|
||||
*/
|
||||
export async function resolvePrTarget({
|
||||
worktreePath,
|
||||
pushRemote,
|
||||
targetRemote,
|
||||
}: {
|
||||
worktreePath: string;
|
||||
pushRemote: string;
|
||||
targetRemote?: string;
|
||||
}): Promise<PrTargetResult> {
|
||||
// Validate remote names — pushRemote is a required string so the undefined
|
||||
// guard is unnecessary, but targetRemote is optional.
|
||||
if (!isValidRemoteName(pushRemote)) {
|
||||
throw new Error(`Invalid push remote name: "${pushRemote}"`);
|
||||
}
|
||||
if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) {
|
||||
throw new Error(`Invalid target remote name: "${targetRemote}"`);
|
||||
}
|
||||
|
||||
let repoUrl: string | null = null;
|
||||
let upstreamRepo: string | null = null;
|
||||
let originOwner: string | null = null;
|
||||
const parsedRemotes: Map<string, ParsedRemote> = new Map();
|
||||
|
||||
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(
|
||||
/^([a-zA-Z0-9._-]+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
);
|
||||
if (!match) {
|
||||
// Try SSH format: git@github.com:owner/repo.git
|
||||
match = line.match(
|
||||
/^([a-zA-Z0-9._-]+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
);
|
||||
}
|
||||
if (!match) {
|
||||
// Try HTTPS format: https://github.com/owner/repo.git
|
||||
match = line.match(
|
||||
/^([a-zA-Z0-9._-]+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
);
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const [, remoteName, owner, repo] = match;
|
||||
parsedRemotes.set(remoteName, { owner, repo });
|
||||
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 (err) {
|
||||
// Log the failure for debugging — control flow falls through to auto-detection
|
||||
logger.debug('Failed to parse git remotes', { worktreePath, error: err });
|
||||
}
|
||||
|
||||
// When targetRemote is explicitly provided but remote parsing failed entirely
|
||||
// (parsedRemotes is empty), we cannot validate or resolve the requested remote.
|
||||
// Silently proceeding to auto-detection would ignore the caller's explicit intent,
|
||||
// so we fail fast with a clear error instead.
|
||||
if (targetRemote && parsedRemotes.size === 0) {
|
||||
throw new Error(
|
||||
`targetRemote "${targetRemote}" was specified but no remotes could be parsed from the repository. ` +
|
||||
`Ensure the repository has at least one configured remote (parsedRemotes is empty).`
|
||||
);
|
||||
}
|
||||
|
||||
// When a targetRemote is explicitly specified, validate that it is known
|
||||
// before using it. Silently falling back to auto-detection when the caller
|
||||
// explicitly requested a remote that doesn't exist is misleading, so we
|
||||
// fail fast here instead.
|
||||
if (targetRemote && parsedRemotes.size > 0 && !parsedRemotes.has(targetRemote)) {
|
||||
throw new Error(`targetRemote "${targetRemote}" not found in repository remotes`);
|
||||
}
|
||||
|
||||
// When a targetRemote is explicitly specified, override fork detection
|
||||
// to use the specified remote as the PR target
|
||||
let targetRepo: string | null = null;
|
||||
let pushOwner: string | null = null;
|
||||
if (targetRemote && parsedRemotes.size > 0) {
|
||||
const targetInfo = parsedRemotes.get(targetRemote);
|
||||
const pushInfo = parsedRemotes.get(pushRemote);
|
||||
|
||||
// If the push remote is not found in the parsed remotes, we cannot
|
||||
// determine the push owner and would build incorrect URLs. Fail fast
|
||||
// instead of silently proceeding with null values.
|
||||
if (!pushInfo) {
|
||||
logger.warn('Push remote not found in parsed remotes', {
|
||||
pushRemote,
|
||||
targetRemote,
|
||||
availableRemotes: [...parsedRemotes.keys()],
|
||||
});
|
||||
throw new Error(`Push remote "${pushRemote}" not found in repository remotes`);
|
||||
}
|
||||
|
||||
if (targetInfo) {
|
||||
targetRepo = `${targetInfo.owner}/${targetInfo.repo}`;
|
||||
repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`;
|
||||
}
|
||||
pushOwner = pushInfo.owner;
|
||||
|
||||
// Override the auto-detected upstream/origin with explicit targetRemote
|
||||
// Only treat as cross-remote if target differs from push remote
|
||||
if (targetRemote !== pushRemote && targetInfo) {
|
||||
upstreamRepo = targetRepo;
|
||||
originOwner = pushOwner;
|
||||
} else if (targetInfo) {
|
||||
// Same remote for push and target - regular (non-fork) workflow
|
||||
upstreamRepo = null;
|
||||
originOwner = targetInfo.owner;
|
||||
repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
const 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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repoUrl,
|
||||
targetRepo,
|
||||
pushOwner,
|
||||
upstreamRepo,
|
||||
originOwner,
|
||||
parsedRemotes,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||
import { createLogger, getErrorMessage, isValidRemoteName } from '@automaker/utils';
|
||||
import { execGitCommand, getCurrentBranch, getConflictFiles } from '@automaker/git-utils';
|
||||
|
||||
const logger = createLogger('RebaseService');
|
||||
@@ -16,6 +16,11 @@ const logger = createLogger('RebaseService');
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface RebaseOptions {
|
||||
/** Remote name to fetch from before rebasing (defaults to 'origin') */
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
export interface RebaseResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -36,9 +41,14 @@ export interface RebaseResult {
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @param ontoBranch - The branch to rebase onto (e.g., 'origin/main')
|
||||
* @param options - Optional rebase options (remote name for fetch)
|
||||
* @returns RebaseResult with success/failure information
|
||||
*/
|
||||
export async function runRebase(worktreePath: string, ontoBranch: string): Promise<RebaseResult> {
|
||||
export async function runRebase(
|
||||
worktreePath: string,
|
||||
ontoBranch: string,
|
||||
options?: RebaseOptions
|
||||
): Promise<RebaseResult> {
|
||||
// Reject empty, whitespace-only, or dash-prefixed branch names.
|
||||
const normalizedOntoBranch = ontoBranch?.trim() ?? '';
|
||||
if (normalizedOntoBranch === '' || normalizedOntoBranch.startsWith('-')) {
|
||||
@@ -59,6 +69,33 @@ export async function runRebase(worktreePath: string, ontoBranch: string): Promi
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the remote name to prevent git option injection.
|
||||
// Reject invalid remote names so the caller knows their input was wrong,
|
||||
// consistent with how invalid branch names are handled above.
|
||||
const remote = options?.remote || 'origin';
|
||||
if (!isValidRemoteName(remote)) {
|
||||
logger.warn('Invalid remote name supplied to rebase-service', {
|
||||
remote,
|
||||
worktreePath,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid remote name: "${remote}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch latest from remote before rebasing to ensure we have up-to-date refs
|
||||
try {
|
||||
await execGitCommand(['fetch', remote], worktreePath);
|
||||
} catch (fetchError) {
|
||||
logger.warn('Failed to fetch from remote before rebase; proceeding with local refs', {
|
||||
remote,
|
||||
worktreePath,
|
||||
error: getErrorMessage(fetchError),
|
||||
});
|
||||
// Non-fatal: proceed with local refs if fetch fails (e.g. offline)
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass ontoBranch after '--' so git treats it as a ref, not an option.
|
||||
// Set LC_ALL=C so git always emits English output regardless of the system
|
||||
|
||||
Reference in New Issue
Block a user