mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Add quick-add feature with improved workflows (#802)
* Changes from feature/quick-add * feat: Clarify system prompt and improve error handling across services. Address PR Feedback * feat: Improve PR description parsing and refactor event handling * feat: Add context options to pipeline orchestrator initialization * fix: Deduplicate React and handle CJS interop for use-sync-external-store Resolve "Cannot read properties of null (reading 'useState')" errors by deduplicating React/react-dom and ensuring use-sync-external-store is bundled together with React to prevent CJS packages from resolving to different React instances.
This commit is contained in:
@@ -114,9 +114,20 @@ export function mapBacklogPlanError(rawMessage: string): string {
|
||||
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
|
||||
}
|
||||
|
||||
// Claude Code process crash
|
||||
// Claude Code process crash - extract exit code for diagnostics
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
|
||||
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
|
||||
const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown';
|
||||
logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`);
|
||||
return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`;
|
||||
}
|
||||
|
||||
// Claude Code process killed by signal
|
||||
if (rawMessage.includes('Claude Code process terminated by signal')) {
|
||||
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
|
||||
const signal = signalMatch ? signalMatch[1] : 'unknown';
|
||||
logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`);
|
||||
return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*
|
||||
* Model is configurable via phaseModels.backlogPlanningModel in settings
|
||||
* (defaults to Sonnet). Can be overridden per-call via model parameter.
|
||||
*
|
||||
* Includes automatic retry for transient CLI failures (e.g., "Claude Code
|
||||
* process exited unexpectedly") to improve reliability.
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
@@ -12,8 +15,10 @@ import {
|
||||
isCursorModel,
|
||||
stripProviderPrefix,
|
||||
type ThinkingLevel,
|
||||
type SystemPromptPreset,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { getCurrentBranch } from '@automaker/git-utils';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
@@ -27,10 +32,27 @@ import {
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getUseClaudeCodeSystemPromptSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
/** Maximum number of retry attempts for transient CLI failures */
|
||||
const MAX_RETRIES = 2;
|
||||
/** Delay between retries in milliseconds */
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Check if an error is retryable (transient CLI process failure)
|
||||
*/
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('Claude Code process exited') ||
|
||||
message.includes('Claude Code process terminated by signal')
|
||||
);
|
||||
}
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
/**
|
||||
@@ -84,6 +106,53 @@ function parsePlanResponse(response: string): BacklogPlanResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a valid plan response without fallback behavior.
|
||||
* Returns null if parsing fails.
|
||||
*/
|
||||
function tryParsePlanResponse(response: string): BacklogPlanResult | null {
|
||||
if (!response || response.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return extractJsonWithArray<BacklogPlanResult>(response, 'changes', { logger });
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the most reliable response text between streamed assistant chunks
|
||||
* and provider final result payload.
|
||||
*/
|
||||
function selectBestResponseText(accumulatedText: string, providerResultText: string): string {
|
||||
const hasAccumulated = accumulatedText.trim().length > 0;
|
||||
const hasProviderResult = providerResultText.trim().length > 0;
|
||||
|
||||
if (!hasProviderResult) {
|
||||
return accumulatedText;
|
||||
}
|
||||
if (!hasAccumulated) {
|
||||
return providerResultText;
|
||||
}
|
||||
|
||||
const accumulatedParsed = tryParsePlanResponse(accumulatedText);
|
||||
const providerParsed = tryParsePlanResponse(providerResultText);
|
||||
|
||||
if (providerParsed && !accumulatedParsed) {
|
||||
logger.info('[BacklogPlan] Using provider result (parseable JSON)');
|
||||
return providerResultText;
|
||||
}
|
||||
if (accumulatedParsed && !providerParsed) {
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)');
|
||||
return accumulatedText;
|
||||
}
|
||||
|
||||
if (providerResultText.length > accumulatedText.length) {
|
||||
logger.info('[BacklogPlan] Using provider result (longer content)');
|
||||
return providerResultText;
|
||||
}
|
||||
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (longer content)');
|
||||
return accumulatedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a backlog modification plan based on user prompt
|
||||
*/
|
||||
@@ -93,11 +162,40 @@ export async function generateBacklogPlan(
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
model?: string
|
||||
model?: string,
|
||||
branchName?: string
|
||||
): Promise<BacklogPlanResult> {
|
||||
try {
|
||||
// Load current features
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
|
||||
// Filter features by branch if specified (worktree-scoped backlog)
|
||||
let features: Feature[];
|
||||
if (branchName) {
|
||||
// Determine the primary branch so unassigned features show for the main worktree
|
||||
let primaryBranch: string | null = null;
|
||||
try {
|
||||
primaryBranch = await getCurrentBranch(projectPath);
|
||||
} catch {
|
||||
// If git fails, fall back to 'main' so unassigned features are visible
|
||||
// when branchName matches a common default branch name
|
||||
primaryBranch = 'main';
|
||||
}
|
||||
const isMainBranch = branchName === primaryBranch;
|
||||
|
||||
features = allFeatures.filter((f) => {
|
||||
if (!f.branchName) {
|
||||
// Unassigned features belong to the main/primary worktree
|
||||
return isMainBranch;
|
||||
}
|
||||
return f.branchName === branchName;
|
||||
});
|
||||
logger.info(
|
||||
`[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}`
|
||||
);
|
||||
} else {
|
||||
features = allFeatures;
|
||||
}
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
@@ -162,17 +260,23 @@ export async function generateBacklogPlan(
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(effectiveModel);
|
||||
|
||||
// Get autoLoadClaudeMd setting
|
||||
// Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
|
||||
// For Cursor models, we need to combine prompts with explicit instructions
|
||||
// because Cursor doesn't support systemPrompt separation like Claude SDK
|
||||
let finalPrompt = userPrompt;
|
||||
let finalSystemPrompt: string | undefined = systemPrompt;
|
||||
let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
|
||||
let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
|
||||
|
||||
if (isCursorModel(effectiveModel)) {
|
||||
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
|
||||
@@ -187,54 +291,141 @@ CRITICAL INSTRUCTIONS:
|
||||
|
||||
${userPrompt}`;
|
||||
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
||||
} else if (useClaudeCodeSystemPrompt) {
|
||||
// Use claude_code preset for Claude models so the SDK subprocess
|
||||
// authenticates via CLI OAuth or API key the same way all other SDK calls do.
|
||||
finalSystemPrompt = {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append: systemPrompt,
|
||||
};
|
||||
}
|
||||
// Include settingSources when autoLoadClaudeMd is enabled
|
||||
if (autoLoadClaudeMd) {
|
||||
finalSettingSources = ['user', 'project'];
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
const stream = provider.executeQuery({
|
||||
// Execute the query with retry logic for transient CLI failures
|
||||
const queryOptions = {
|
||||
prompt: finalPrompt,
|
||||
model: bareModel,
|
||||
cwd: projectPath,
|
||||
systemPrompt: finalSystemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [], // No tools needed for this
|
||||
tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
|
||||
abortController,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
||||
settingSources: finalSettingSources,
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
};
|
||||
|
||||
let responseText = '';
|
||||
let bestResponseText = ''; // Preserve best response across all retry attempts
|
||||
let recoveredResult: BacklogPlanResult | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for await (const msg of stream) {
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Generation aborted');
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
if (msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
if (attempt > 0) {
|
||||
logger.info(
|
||||
`[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
|
||||
);
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
}
|
||||
|
||||
let accumulatedText = '';
|
||||
let providerResultText = '';
|
||||
|
||||
try {
|
||||
const stream = provider.executeQuery(queryOptions);
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Generation aborted');
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
if (msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
accumulatedText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
providerResultText = msg.result;
|
||||
logger.info(
|
||||
'[BacklogPlan] Received result from provider, length:',
|
||||
providerResultText.length
|
||||
);
|
||||
logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message (from Cursor provider)
|
||||
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length);
|
||||
logger.info('[BacklogPlan] Previous responseText length:', responseText.length);
|
||||
if (msg.result.length > responseText.length) {
|
||||
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)');
|
||||
responseText = msg.result;
|
||||
} else {
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)');
|
||||
|
||||
responseText = selectBestResponseText(accumulatedText, providerResultText);
|
||||
|
||||
// If we got here, the stream completed successfully
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
responseText = selectBestResponseText(accumulatedText, providerResultText);
|
||||
|
||||
// Preserve the best response text across all attempts so that if a retry
|
||||
// crashes immediately (empty response), we can still recover from an earlier attempt
|
||||
bestResponseText = selectBestResponseText(bestResponseText, responseText);
|
||||
|
||||
// Claude SDK can occasionally exit non-zero after emitting a complete response.
|
||||
// If we already have valid JSON, recover instead of failing the entire planning flow.
|
||||
if (isRetryableError(error)) {
|
||||
const parsed = tryParsePlanResponse(bestResponseText);
|
||||
if (parsed) {
|
||||
logger.warn(
|
||||
'[BacklogPlan] Recovered from transient CLI exit using accumulated valid response'
|
||||
);
|
||||
recoveredResult = parsed;
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
|
||||
// On final retryable failure, degrade gracefully if we have text from any attempt.
|
||||
if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) {
|
||||
logger.warn(
|
||||
'[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse'
|
||||
);
|
||||
recoveredResult = parsePlanResponse(bestResponseText);
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry on transient CLI failures, not on user aborts or other errors
|
||||
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhausted retries, throw the last error
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
const result = parsePlanResponse(responseText);
|
||||
const result = recoveredResult ?? parsePlanResponse(responseText);
|
||||
|
||||
await saveBacklogPlan(projectPath, {
|
||||
savedAt: new Date().toISOString(),
|
||||
|
||||
@@ -17,10 +17,11 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, prompt, model } = req.body as {
|
||||
const { projectPath, prompt, model, branchName } = req.body as {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
branchName?: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
@@ -42,28 +43,30 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
setRunningDetails({
|
||||
projectPath,
|
||||
prompt,
|
||||
model,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
// Note: generateBacklogPlan handles its own error event emission,
|
||||
// so we only log here to avoid duplicate error toasts
|
||||
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
|
||||
.catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
});
|
||||
// Note: generateBacklogPlan handles its own error event emission
|
||||
// and state cleanup in its finally block, so we only log here
|
||||
generateBacklogPlan(
|
||||
projectPath,
|
||||
prompt,
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
model,
|
||||
branchName
|
||||
).catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): {
|
||||
|
||||
if (!rawMessage) return baseResponse;
|
||||
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
if (
|
||||
rawMessage.includes('Claude Code process exited') ||
|
||||
rawMessage.includes('Claude Code process terminated by signal')
|
||||
) {
|
||||
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
|
||||
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
|
||||
const detail = exitCodeMatch
|
||||
? ` (exit code: ${exitCodeMatch[1]})`
|
||||
: signalMatch
|
||||
? ` (signal: ${signalMatch[1]})`
|
||||
: '';
|
||||
|
||||
// Crash/OS-kill signals suggest a process crash, not an auth failure —
|
||||
// omit auth recovery advice and suggest retry/reporting instead.
|
||||
const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP'];
|
||||
const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false;
|
||||
|
||||
if (isCrashSignal) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 503,
|
||||
userMessage:
|
||||
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.',
|
||||
userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -170,127 +170,125 @@ export function createGeneratePRDescriptionHandler(
|
||||
// Determine the base branch for comparison
|
||||
const base = baseBranch || 'main';
|
||||
|
||||
// Get the diff between current branch and base branch (committed changes)
|
||||
// Track whether the diff method used only includes committed changes.
|
||||
// `git diff base...HEAD` and `git diff origin/base...HEAD` only show committed changes,
|
||||
// while the fallback methods (`git diff HEAD`, `git diff --cached + git diff`) already
|
||||
// include uncommitted working directory changes.
|
||||
let diff = '';
|
||||
let diffIncludesUncommitted = false;
|
||||
// Collect diffs in three layers and combine them:
|
||||
// 1. Committed changes on the branch: `git diff base...HEAD`
|
||||
// 2. Staged (cached) changes not yet committed: `git diff --cached`
|
||||
// 3. Unstaged changes to tracked files: `git diff` (no --cached flag)
|
||||
//
|
||||
// Untracked files are intentionally excluded — they are typically build artifacts,
|
||||
// planning files, hidden dotfiles, or other files unrelated to the PR.
|
||||
// `git diff` and `git diff --cached` only show changes to files already tracked by git,
|
||||
// which is exactly the correct scope.
|
||||
//
|
||||
// We combine all three sources and deduplicate by file path so that a file modified
|
||||
// in commits AND with additional uncommitted changes is not double-counted.
|
||||
|
||||
/** Parse a unified diff into per-file hunks keyed by file path */
|
||||
function parseDiffIntoFileHunks(diffText: string): Map<string, string> {
|
||||
const fileHunks = new Map<string, string>();
|
||||
if (!diffText.trim()) return fileHunks;
|
||||
|
||||
// Split on "diff --git" boundaries (keep the delimiter)
|
||||
const sections = diffText.split(/(?=^diff --git )/m);
|
||||
for (const section of sections) {
|
||||
if (!section.trim()) continue;
|
||||
// Use a back-reference pattern so the "b/" side must match the "a/" capture,
|
||||
// correctly handling paths that contain " b/" in their name.
|
||||
// Falls back to a two-capture pattern to handle renames (a/ and b/ differ).
|
||||
const backrefMatch = section.match(/^diff --git a\/(.+) b\/\1$/m);
|
||||
const renameMatch = !backrefMatch ? section.match(/^diff --git a\/(.+) b\/(.+)$/m) : null;
|
||||
const match = backrefMatch || renameMatch;
|
||||
if (match) {
|
||||
// Prefer the backref capture (identical paths); for renames use the destination (match[2])
|
||||
const filePath = backrefMatch ? match[1] : match[2];
|
||||
// Merge hunks if the same file appears in multiple diff sources
|
||||
const existing = fileHunks.get(filePath) ?? '';
|
||||
fileHunks.set(filePath, existing + section);
|
||||
}
|
||||
}
|
||||
return fileHunks;
|
||||
}
|
||||
|
||||
// --- Step 1: committed changes (branch vs base) ---
|
||||
let committedDiff = '';
|
||||
try {
|
||||
// First, try to get diff against the base branch
|
||||
const { stdout: branchDiff } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
|
||||
const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
diff = branchDiff;
|
||||
// git diff base...HEAD only shows committed changes
|
||||
diffIncludesUncommitted = false;
|
||||
committedDiff = stdout;
|
||||
} catch {
|
||||
// If branch comparison fails (e.g., base branch doesn't exist locally),
|
||||
// try fetching and comparing against remote base
|
||||
// Base branch may not exist locally; try the remote tracking branch
|
||||
try {
|
||||
const { stdout: remoteDiff } = await execFileAsync(
|
||||
'git',
|
||||
['diff', `origin/${base}...HEAD`],
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
}
|
||||
);
|
||||
diff = remoteDiff;
|
||||
// git diff origin/base...HEAD only shows committed changes
|
||||
diffIncludesUncommitted = false;
|
||||
const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
committedDiff = stdout;
|
||||
} catch {
|
||||
// Fall back to getting all uncommitted + committed changes
|
||||
try {
|
||||
const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
diff = allDiff;
|
||||
// git diff HEAD includes uncommitted changes
|
||||
diffIncludesUncommitted = true;
|
||||
} catch {
|
||||
// Last resort: get staged + unstaged changes
|
||||
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
diff = stagedDiff + unstagedDiff;
|
||||
// These already include uncommitted changes
|
||||
diffIncludesUncommitted = true;
|
||||
}
|
||||
// Cannot compare against base — leave committedDiff empty; the uncommitted
|
||||
// changes gathered below will still be included.
|
||||
logger.warn(`Could not get committed diff against ${base} or origin/${base}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for uncommitted changes (staged + unstaged) to include in the description.
|
||||
// When creating a PR, uncommitted changes will be auto-committed, so they should be
|
||||
// reflected in the generated description. We only need to fetch uncommitted diffs
|
||||
// when the primary diff method (base...HEAD) was used, since it only shows committed changes.
|
||||
let hasUncommittedChanges = false;
|
||||
// --- Step 2: staged changes (tracked files only) ---
|
||||
let stagedDiff = '';
|
||||
try {
|
||||
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
const { stdout } = await execFileAsync('git', ['diff', '--cached'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
hasUncommittedChanges = statusOutput.trim().length > 0;
|
||||
|
||||
if (hasUncommittedChanges && !diffIncludesUncommitted) {
|
||||
logger.info('Uncommitted changes detected, including in PR description context');
|
||||
|
||||
let uncommittedDiff = '';
|
||||
|
||||
// Get staged changes
|
||||
try {
|
||||
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
if (stagedDiff.trim()) {
|
||||
uncommittedDiff += stagedDiff;
|
||||
}
|
||||
} catch {
|
||||
// Ignore staged diff errors
|
||||
}
|
||||
|
||||
// Get unstaged changes (tracked files only)
|
||||
try {
|
||||
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
if (unstagedDiff.trim()) {
|
||||
uncommittedDiff += unstagedDiff;
|
||||
}
|
||||
} catch {
|
||||
// Ignore unstaged diff errors
|
||||
}
|
||||
|
||||
// Get list of untracked files for context
|
||||
const untrackedFiles = statusOutput
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('??'))
|
||||
.map((line) => line.substring(3).trim());
|
||||
|
||||
if (untrackedFiles.length > 0) {
|
||||
// Add a summary of untracked (new) files as context
|
||||
uncommittedDiff += `\n# New untracked files:\n${untrackedFiles.map((f) => `# + ${f}`).join('\n')}\n`;
|
||||
}
|
||||
|
||||
// Append uncommitted changes to the committed diff
|
||||
if (uncommittedDiff.trim()) {
|
||||
diff = diff + uncommittedDiff;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors checking for uncommitted changes
|
||||
stagedDiff = stdout;
|
||||
} catch (err) {
|
||||
// Non-fatal — staged diff is a best-effort supplement
|
||||
logger.debug('Failed to get staged diff', err);
|
||||
}
|
||||
|
||||
// Also get the commit log for context
|
||||
// --- Step 3: unstaged changes (tracked files only) ---
|
||||
let unstagedDiff = '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['diff'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
unstagedDiff = stdout;
|
||||
} catch (err) {
|
||||
// Non-fatal — unstaged diff is a best-effort supplement
|
||||
logger.debug('Failed to get unstaged diff', err);
|
||||
}
|
||||
|
||||
// --- Combine and deduplicate ---
|
||||
// Build a map of filePath → diff content by concatenating hunks from all sources
|
||||
// in chronological order (committed → staged → unstaged) so that no changes
|
||||
// are lost when a file appears in multiple diff sources.
|
||||
const combinedFileHunks = new Map<string, string>();
|
||||
|
||||
for (const source of [committedDiff, stagedDiff, unstagedDiff]) {
|
||||
const hunks = parseDiffIntoFileHunks(source);
|
||||
for (const [filePath, hunk] of hunks) {
|
||||
if (combinedFileHunks.has(filePath)) {
|
||||
combinedFileHunks.set(filePath, combinedFileHunks.get(filePath)! + hunk);
|
||||
} else {
|
||||
combinedFileHunks.set(filePath, hunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diff = Array.from(combinedFileHunks.values()).join('');
|
||||
|
||||
// Log what files were included for observability
|
||||
if (combinedFileHunks.size > 0) {
|
||||
logger.info(`PR description scope: ${combinedFileHunks.size} file(s)`);
|
||||
logger.debug(
|
||||
`PR description scope files: ${Array.from(combinedFileHunks.keys()).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Also get the commit log for context — always scoped to the selected base branch
|
||||
// so the log only contains commits that are part of this PR.
|
||||
// We do NOT fall back to an unscoped `git log` because that would include commits
|
||||
// from the base branch itself and produce misleading AI context.
|
||||
let commitLog = '';
|
||||
try {
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
@@ -303,11 +301,11 @@ export function createGeneratePRDescriptionHandler(
|
||||
);
|
||||
commitLog = logOutput.trim();
|
||||
} catch {
|
||||
// If comparing against base fails, fall back to recent commits
|
||||
// Base branch not available locally — try the remote tracking branch
|
||||
try {
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
'git',
|
||||
['log', '--oneline', '-10', '--no-decorate'],
|
||||
['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'],
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024,
|
||||
@@ -315,7 +313,9 @@ export function createGeneratePRDescriptionHandler(
|
||||
);
|
||||
commitLog = logOutput.trim();
|
||||
} catch {
|
||||
// Ignore commit log errors
|
||||
// Cannot scope commit log to base branch — leave empty rather than
|
||||
// including unscoped commits that would pollute the AI context.
|
||||
logger.warn(`Could not get commit log against ${base} or origin/${base}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,10 +341,6 @@ export function createGeneratePRDescriptionHandler(
|
||||
userPrompt += `\nCommit History:\n${commitLog}\n`;
|
||||
}
|
||||
|
||||
if (hasUncommittedChanges) {
|
||||
userPrompt += `\nNote: This branch has uncommitted changes that will be included in the PR.\n`;
|
||||
}
|
||||
|
||||
if (truncatedDiff) {
|
||||
userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Request, Response } from 'express';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logWorktreeError } from '../common.js';
|
||||
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -131,6 +132,8 @@ export function createListBranchesHandler() {
|
||||
let behindCount = 0;
|
||||
let hasRemoteBranch = false;
|
||||
let trackingRemote: string | undefined;
|
||||
// List of remote names that have a branch matching the current branch name
|
||||
let remotesWithBranch: string[] = [];
|
||||
try {
|
||||
// First check if there's a remote tracking branch
|
||||
const { stdout: upstreamOutput } = await execFileAsync(
|
||||
@@ -172,6 +175,12 @@ export function createListBranchesHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check which remotes have a branch matching the current branch name.
|
||||
// This helps the UI distinguish between "branch exists on tracking remote" vs
|
||||
// "branch was pushed to a different remote" (e.g., pushed to 'upstream' but tracking 'origin').
|
||||
// Use for-each-ref to check cached remote refs (already fetched above if includeRemote was true)
|
||||
remotesWithBranch = await getRemotesWithBranch(worktreePath, currentBranch, hasAnyRemotes);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
@@ -182,6 +191,7 @@ export function createListBranchesHandler() {
|
||||
hasRemoteBranch,
|
||||
hasAnyRemotes,
|
||||
trackingRemote,
|
||||
remotesWithBranch,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user