5 Commits

Author SHA1 Message Date
gsxdsm
2f071a1ba3 Fix deleting worktree crash and improve UX (#798)
* Changes from fix/deleting-worktree

* fix: Improve worktree deletion safety and branch cleanup logic

* fix: Improve error handling and async operations across auto-mode and worktree services

* Update apps/server/src/routes/auto-mode/routes/analyze-project.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-22 00:58:00 -08:00
gsxdsm
1d732916f1 Fix event hooks not persisting across server syncs (#799)
* Changes from fix/event-hook-persistence

* feat: Add explicit permission escape hatch for clearing eventHooks and improve error handling in UI
2026-02-22 00:36:08 -08:00
gsxdsm
629fd24d9f Improve pull request prompt and generation handling (#800)
* Changes from fix/improve-pull-request-prompt

* Update apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-22 00:27:39 -08:00
gsxdsm
72cb942788 Fix Codex CLI timeout handling and improve CI workflows (#797)
* Changes from fix/codex-cli-timeout

* test: Clarify timeout values and multipliers in codex-provider tests

* refactor: Rename useWorktreesEnabled to worktreesEnabled for clarity
2026-02-21 23:58:09 -08:00
gsxdsm
91bff21d58 Feature: Git sync, set-tracking, and push divergence handling (#796) 2026-02-21 18:54:16 -08:00
43 changed files with 1524 additions and 210 deletions

View File

@@ -33,7 +33,6 @@ import {
supportsReasoningEffort,
validateBareModelId,
calculateReasoningTimeout,
DEFAULT_TIMEOUT_MS,
type CodexApprovalPolicy,
type CodexSandboxMode,
type CodexAuthStatus,
@@ -98,7 +97,7 @@ const TEXT_ENCODING = 'utf-8';
*
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
const CODEX_INSTRUCTIONS_DIR = '.codex';

View File

@@ -19,10 +19,11 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceComp
return;
}
// Start analysis in background
autoModeService.analyzeProject(projectPath).catch((error) => {
logger.error(`[AutoMode] Project analysis error:`, error);
});
// Kick off analysis in the background; attach a rejection handler so
// unhandled-promise warnings don't surface and errors are at least logged.
// Synchronous throws (e.g. "not implemented") still propagate here.
const analysisPromise = autoModeService.analyzeProject(projectPath);
analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed'));
res.json({ success: true, message: 'Project analysis started' });
} catch (error) {

View File

@@ -67,6 +67,8 @@ import { createAbortOperationHandler } from './routes/abort-operation.js';
import { createContinueOperationHandler } from './routes/continue-operation.js';
import { createStageFilesHandler } from './routes/stage-files.js';
import { createCheckChangesHandler } from './routes/check-changes.js';
import { createSetTrackingHandler } from './routes/set-tracking.js';
import { createSyncHandler } from './routes/sync.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -118,6 +120,18 @@ export function createWorktreeRoutes(
requireValidWorktree,
createPullHandler()
);
router.post(
'/sync',
validatePathParams('worktreePath'),
requireValidWorktree,
createSyncHandler()
);
router.post(
'/set-tracking',
validatePathParams('worktreePath'),
requireValidWorktree,
createSetTrackingHandler()
);
router.post(
'/checkout-branch',
validatePathParams('worktreePath'),

View File

@@ -5,6 +5,7 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
@@ -46,20 +47,79 @@ export function createDeleteHandler() {
});
branchName = stdout.trim();
} catch {
// Could not get branch name
// Could not get branch name - worktree directory may already be gone
logger.debug('Could not determine branch for worktree, directory may be missing');
}
// Remove the worktree (using array arguments to prevent injection)
let removeSucceeded = false;
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
} catch {
// Try with prune if remove fails
await execGitCommand(['worktree', 'prune'], projectPath);
removeSucceeded = true;
} catch (removeError) {
// `git worktree remove` can fail if the directory is already missing
// or in a bad state. Try pruning stale worktree entries as a fallback.
logger.debug('git worktree remove failed, trying prune', {
error: getErrorMessage(removeError),
});
try {
await execGitCommand(['worktree', 'prune'], projectPath);
// Verify the specific worktree is no longer registered after prune.
// `git worktree prune` exits 0 even if worktreePath was never registered,
// so we must explicitly check the worktree list to avoid false positives.
const { stdout: listOut } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
// Parse porcelain output and check for an exact path match.
// Using substring .includes() can produce false positives when one
// worktree path is a prefix of another (e.g. /foo vs /foobar).
const stillRegistered = listOut
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => line.slice('worktree '.length).trim())
.some((registeredPath) => registeredPath === worktreePath);
if (stillRegistered) {
// Prune didn't clean up our entry - treat as failure
throw removeError;
}
removeSucceeded = true;
} catch (pruneError) {
// If pruneError is the original removeError re-thrown, propagate it
if (pruneError === removeError) {
throw removeError;
}
logger.warn('git worktree prune also failed', {
error: getErrorMessage(pruneError),
});
// If both remove and prune fail, still try to return success
// if the worktree directory no longer exists (it may have been
// manually deleted already).
let dirExists = false;
try {
await fs.access(worktreePath);
dirExists = true;
} catch {
// Directory doesn't exist
}
if (dirExists) {
// Directory still exists - this is a real failure
throw removeError;
}
// Directory is gone, treat as success
removeSucceeded = true;
}
}
// Optionally delete the branch
// Optionally delete the branch (only if worktree was successfully removed)
let branchDeleted = false;
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
if (
removeSucceeded &&
deleteBranch &&
branchName &&
branchName !== 'main' &&
branchName !== 'master'
) {
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);

View File

@@ -53,7 +53,9 @@ Rules:
- Focus on the user-facing impact when possible
- If there are breaking changes, mention them prominently
- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes`;
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes
- EXCLUDE any files that are gitignored (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). These should not be mentioned in the description even if they appear in the diff
- Focus only on meaningful source code changes that are tracked by git and relevant to reviewers`;
/**
* Wraps an async generator with a timeout.

View File

@@ -1,24 +1,24 @@
/**
* POST /push endpoint - Push a worktree branch to remote
*
* Git business logic is delegated to push-service.ts.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
import { performPush } from '../../../services/push-service.js';
export function createPushHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, force, remote } = req.body as {
const { worktreePath, force, remote, autoResolve } = req.body as {
worktreePath: string;
force?: boolean;
remote?: string;
autoResolve?: boolean;
};
if (!worktreePath) {
@@ -29,34 +29,28 @@ export function createPushHandler() {
return;
}
// Get branch name
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();
const result = await performPush(worktreePath, { remote, force, autoResolve });
// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
// Push the branch
const forceFlag = force ? '--force' : '';
try {
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
} catch {
// Try setting upstream
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
if (!result.success) {
const statusCode = isClientError(result.error ?? '') ? 400 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
diverged: result.diverged,
hasConflicts: result.hasConflicts,
conflictFiles: result.conflictFiles,
});
return;
}
res.json({
success: true,
result: {
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to ${targetRemote}`,
branch: result.branch,
pushed: result.pushed,
diverged: result.diverged,
autoResolved: result.autoResolved,
message: result.message,
},
});
} catch (error) {
@@ -65,3 +59,15 @@ export function createPushHandler() {
}
};
}
/**
* Determine whether an error message represents a client error (400)
* vs a server error (500).
*/
function isClientError(errorMessage: string): boolean {
return (
errorMessage.includes('detached HEAD') ||
errorMessage.includes('rejected') ||
errorMessage.includes('diverged')
);
}

View File

@@ -0,0 +1,76 @@
/**
* POST /set-tracking endpoint - Set the upstream tracking branch for a worktree
*
* Sets `git branch --set-upstream-to=<remote>/<branch>` for the current branch.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execGitCommand } from '@automaker/git-utils';
import { getErrorMessage, logError } from '../common.js';
import { getCurrentBranch } from '../../../lib/git.js';
export function createSetTrackingHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote, branch } = req.body as {
worktreePath: string;
remote: string;
branch?: string;
};
if (!worktreePath) {
res.status(400).json({ success: false, error: 'worktreePath required' });
return;
}
if (!remote) {
res.status(400).json({ success: false, error: 'remote required' });
return;
}
// Get current branch if not provided
let targetBranch = branch;
if (!targetBranch) {
try {
targetBranch = await getCurrentBranch(worktreePath);
} catch (err) {
res.status(400).json({
success: false,
error: `Failed to get current branch: ${getErrorMessage(err)}`,
});
return;
}
if (targetBranch === 'HEAD') {
res.status(400).json({
success: false,
error: 'Cannot set tracking in detached HEAD state.',
});
return;
}
}
// Set upstream tracking (pass local branch name as final arg to be explicit)
await execGitCommand(
['branch', '--set-upstream-to', `${remote}/${targetBranch}`, targetBranch],
worktreePath
);
res.json({
success: true,
result: {
branch: targetBranch,
remote,
upstream: `${remote}/${targetBranch}`,
message: `Set tracking branch to ${remote}/${targetBranch}`,
},
});
} catch (error) {
logError(error, 'Set tracking branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,66 @@
/**
* POST /sync endpoint - Pull then push a worktree branch
*
* Performs a full sync operation: pull latest from remote, then push
* local commits. Handles divergence automatically.
*
* Git business logic is delegated to sync-service.ts.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { performSync } from '../../../services/sync-service.js';
export function createSyncHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote } = req.body as {
worktreePath: string;
remote?: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
const result = await performSync(worktreePath, { remote });
if (!result.success) {
const statusCode = result.hasConflicts ? 409 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
hasConflicts: result.hasConflicts,
conflictFiles: result.conflictFiles,
conflictSource: result.conflictSource,
pulled: result.pulled,
pushed: result.pushed,
});
return;
}
res.json({
success: true,
result: {
branch: result.branch,
pulled: result.pulled,
pushed: result.pushed,
isFastForward: result.isFastForward,
isMerge: result.isMerge,
autoResolved: result.autoResolved,
message: result.message,
},
});
} catch (error) {
logError(error, 'Sync worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -910,7 +910,7 @@ export class AutoModeServiceFacade {
if (feature) {
title = feature.title;
description = feature.description;
branchName = feature.branchName;
branchName = feature.branchName ?? undefined;
}
} catch {
// Silently ignore
@@ -1140,10 +1140,31 @@ export class AutoModeServiceFacade {
// ===========================================================================
/**
* Save execution state for recovery
* Save execution state for recovery.
*
* Uses the active auto-loop config for each worktree so that the persisted
* state reflects the real branch and maxConcurrency values rather than the
* hard-coded fallbacks (null / DEFAULT_MAX_CONCURRENCY).
*/
private async saveExecutionState(): Promise<void> {
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
const projectWorktrees = this.autoLoopCoordinator
.getActiveWorktrees()
.filter((w) => w.projectPath === this.projectPath);
if (projectWorktrees.length === 0) {
// No active auto loops — save with defaults as a best-effort fallback.
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
}
// Save state for every active worktree using its real config values.
for (const { branchName } of projectWorktrees) {
const config = this.autoLoopCoordinator.getAutoLoopConfigForProject(
this.projectPath,
branchName
);
const maxConcurrency = config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
await this.saveExecutionStateForProject(branchName, maxConcurrency);
}
}
/**

View File

@@ -159,7 +159,7 @@ export class GlobalAutoModeService {
if (feature) {
title = feature.title;
description = feature.description;
branchName = feature.branchName;
branchName = feature.branchName ?? undefined;
}
} catch {
// Silently ignore

View File

@@ -0,0 +1,258 @@
/**
* PushService - Push git operations without HTTP
*
* Encapsulates the full git push workflow including:
* - Branch name and detached HEAD detection
* - Safe array-based command execution (no shell interpolation)
* - Divergent branch detection and auto-resolution via pull-then-retry
* - Structured result reporting
*
* Mirrors the pull-service.ts pattern for consistency.
*/
import { createLogger, getErrorMessage } from '@automaker/utils';
import { execGitCommand } from '@automaker/git-utils';
import { getCurrentBranch } from '../lib/git.js';
import { performPull } from './pull-service.js';
const logger = createLogger('PushService');
// ============================================================================
// Types
// ============================================================================
export interface PushOptions {
/** Remote name to push to (defaults to 'origin') */
remote?: string;
/** Force push */
force?: boolean;
/** When true and push is rejected due to divergence, pull then retry push */
autoResolve?: boolean;
}
export interface PushResult {
success: boolean;
error?: string;
branch?: string;
pushed?: boolean;
/** Whether the push was initially rejected because the branches diverged */
diverged?: boolean;
/** Whether divergence was automatically resolved via pull-then-retry */
autoResolved?: boolean;
/** Whether the auto-resolve pull resulted in merge conflicts */
hasConflicts?: boolean;
/** Files with merge conflicts (only when hasConflicts is true) */
conflictFiles?: string[];
message?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Detect whether push error output indicates a diverged/non-fast-forward rejection.
*/
function isDivergenceError(errorOutput: string): boolean {
const lower = errorOutput.toLowerCase();
// Require specific divergence indicators rather than just 'rejected' alone,
// which could match pre-receive hook rejections or protected branch errors.
const hasNonFastForward = lower.includes('non-fast-forward');
const hasFetchFirst = lower.includes('fetch first');
const hasFailedToPush = lower.includes('failed to push some refs');
const hasRejected = lower.includes('rejected');
return hasNonFastForward || hasFetchFirst || (hasRejected && hasFailedToPush);
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Perform a git push on the given worktree.
*
* The workflow:
* 1. Get current branch name (detect detached HEAD)
* 2. Attempt `git push <remote> <branch>` with safe array args
* 3. If push fails with divergence and autoResolve is true:
* a. Pull from the same remote (with stash support)
* b. If pull succeeds without conflicts, retry push
* 4. If push fails with "no upstream" error, retry with --set-upstream
* 5. Return structured result
*
* @param worktreePath - Path to the git worktree
* @param options - Push options (remote, force, autoResolve)
* @returns PushResult with detailed status information
*/
export async function performPush(
worktreePath: string,
options?: PushOptions
): Promise<PushResult> {
const targetRemote = options?.remote || 'origin';
const force = options?.force ?? false;
const autoResolve = options?.autoResolve ?? false;
// 1. Get current branch name
let branchName: string;
try {
branchName = await getCurrentBranch(worktreePath);
} catch (err) {
return {
success: false,
error: `Failed to get current branch: ${getErrorMessage(err)}`,
};
}
// 2. Check for detached HEAD state
if (branchName === 'HEAD') {
return {
success: false,
error: 'Cannot push in detached HEAD state. Please checkout a branch first.',
};
}
// 3. Build push args (no -u flag; upstream is set in the fallback path only when needed)
const pushArgs = ['push', targetRemote, branchName];
if (force) {
pushArgs.push('--force');
}
// 4. Attempt push
try {
await execGitCommand(pushArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to ${targetRemote}`,
};
} catch (pushError: unknown) {
const err = pushError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// 5. Check if the error is a divergence rejection
if (isDivergenceError(errorOutput)) {
if (!autoResolve) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
error: `Push rejected: remote has changes not present locally. Use sync or pull first, or enable auto-resolve.`,
message: `Push to ${targetRemote} was rejected because the remote branch has diverged.`,
};
}
// 6. Auto-resolve: pull then retry push
logger.info('Push rejected due to divergence, attempting auto-resolve via pull', {
worktreePath,
remote: targetRemote,
branch: branchName,
});
try {
const pullResult = await performPull(worktreePath, {
remote: targetRemote,
stashIfNeeded: true,
});
if (!pullResult.success) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Auto-resolve failed during pull: ${pullResult.error}`,
};
}
if (pullResult.hasConflicts) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
hasConflicts: true,
conflictFiles: pullResult.conflictFiles,
error:
'Auto-resolve pull resulted in merge conflicts. Resolve conflicts and push again.',
};
}
// 7. Retry push after successful pull
try {
await execGitCommand(pushArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
diverged: true,
autoResolved: true,
message: `Push succeeded after auto-resolving divergence (pulled from ${targetRemote} first).`,
};
} catch (retryError: unknown) {
const retryErr = retryError as { stderr?: string; message?: string };
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Push failed after auto-resolve pull: ${retryErr.stderr || retryErr.message || 'Unknown error'}`,
};
}
} catch (pullError) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Auto-resolve pull failed: ${getErrorMessage(pullError)}`,
};
}
}
// 6b. Non-divergence error (e.g. no upstream configured) - retry with --set-upstream
const isNoUpstreamError =
errorOutput.toLowerCase().includes('no upstream') ||
errorOutput.toLowerCase().includes('has no upstream branch') ||
errorOutput.toLowerCase().includes('set-upstream');
if (isNoUpstreamError) {
try {
const setUpstreamArgs = ['push', '--set-upstream', targetRemote, branchName];
if (force) {
setUpstreamArgs.push('--force');
}
await execGitCommand(setUpstreamArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to ${targetRemote} (set upstream)`,
};
} catch (upstreamError: unknown) {
const upstreamErr = upstreamError as { stderr?: string; message?: string };
return {
success: false,
branch: branchName,
pushed: false,
error: upstreamErr.stderr || upstreamErr.message || getErrorMessage(pushError),
};
}
}
// 6c. Other push error - return as-is
return {
success: false,
branch: branchName,
pushed: false,
error: err.stderr || err.message || getErrorMessage(pushError),
};
}
}

View File

@@ -573,6 +573,17 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('claudeApiProfiles');
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
// Check for explicit permission to clear eventHooks (escape hatch for intentional clearing)
const allowEmptyEventHooks =
(sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks === true;
// Remove the flag so it doesn't get persisted
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks;
// Only guard eventHooks if explicit permission wasn't granted
if (!allowEmptyEventHooks) {
ignoreEmptyArrayOverwrite('eventHooks');
}
// Empty object overwrite guard
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;

View File

@@ -0,0 +1,209 @@
/**
* SyncService - Pull then push in a single operation
*
* Composes performPull() and performPush() to synchronize a branch
* with its remote. Always uses stashIfNeeded for the pull step.
* If push fails with divergence after pull, retries once.
*
* Follows the same pattern as pull-service.ts and push-service.ts.
*/
import { createLogger, getErrorMessage } from '@automaker/utils';
import { performPull } from './pull-service.js';
import { performPush } from './push-service.js';
import type { PullResult } from './pull-service.js';
import type { PushResult } from './push-service.js';
const logger = createLogger('SyncService');
// ============================================================================
// Types
// ============================================================================
export interface SyncOptions {
/** Remote name (defaults to 'origin') */
remote?: string;
}
export interface SyncResult {
success: boolean;
error?: string;
branch?: string;
/** Whether the pull step was performed */
pulled?: boolean;
/** Whether the push step was performed */
pushed?: boolean;
/** Pull resulted in conflicts */
hasConflicts?: boolean;
/** Files with merge conflicts */
conflictFiles?: string[];
/** Source of conflicts ('pull' | 'stash') */
conflictSource?: 'pull' | 'stash';
/** Whether the pull was a fast-forward */
isFastForward?: boolean;
/** Whether the pull resulted in a merge commit */
isMerge?: boolean;
/** Whether push divergence was auto-resolved */
autoResolved?: boolean;
message?: string;
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Perform a sync operation (pull then push) on the given worktree.
*
* The workflow:
* 1. Pull from remote with stashIfNeeded: true
* 2. If pull has conflicts, stop and return conflict info
* 3. Push to remote
* 4. If push fails with divergence after pull, retry once
*
* @param worktreePath - Path to the git worktree
* @param options - Sync options (remote)
* @returns SyncResult with detailed status information
*/
export async function performSync(
worktreePath: string,
options?: SyncOptions
): Promise<SyncResult> {
const targetRemote = options?.remote || 'origin';
// 1. Pull from remote
logger.info('Sync: starting pull', { worktreePath, remote: targetRemote });
let pullResult: PullResult;
try {
pullResult = await performPull(worktreePath, {
remote: targetRemote,
stashIfNeeded: true,
});
} catch (pullError) {
return {
success: false,
error: `Sync pull failed: ${getErrorMessage(pullError)}`,
};
}
if (!pullResult.success) {
return {
success: false,
branch: pullResult.branch,
pulled: false,
pushed: false,
error: `Sync pull failed: ${pullResult.error}`,
hasConflicts: pullResult.hasConflicts,
conflictFiles: pullResult.conflictFiles,
conflictSource: pullResult.conflictSource,
};
}
// 2. If pull had conflicts, stop and return conflict info
if (pullResult.hasConflicts) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
hasConflicts: true,
conflictFiles: pullResult.conflictFiles,
conflictSource: pullResult.conflictSource,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: 'Sync stopped: pull resulted in merge conflicts. Resolve conflicts and try again.',
message: pullResult.message,
};
}
// 3. Push to remote
logger.info('Sync: pull succeeded, starting push', { worktreePath, remote: targetRemote });
let pushResult: PushResult;
try {
pushResult = await performPush(worktreePath, {
remote: targetRemote,
});
} catch (pushError) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: `Sync push failed: ${getErrorMessage(pushError)}`,
};
}
if (!pushResult.success) {
// 4. If push diverged after pull, retry once with autoResolve
if (pushResult.diverged) {
logger.info('Sync: push diverged after pull, retrying with autoResolve', {
worktreePath,
remote: targetRemote,
});
try {
const retryResult = await performPush(worktreePath, {
remote: targetRemote,
autoResolve: true,
});
if (retryResult.success) {
return {
success: true,
branch: retryResult.branch,
pulled: true,
pushed: true,
autoResolved: true,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
message: 'Sync completed (push required auto-resolve).',
};
}
return {
success: false,
branch: retryResult.branch,
pulled: true,
pushed: false,
hasConflicts: retryResult.hasConflicts,
conflictFiles: retryResult.conflictFiles,
error: retryResult.error,
};
} catch (retryError) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
error: `Sync push retry failed: ${getErrorMessage(retryError)}`,
};
}
}
return {
success: false,
branch: pushResult.branch,
pulled: true,
pushed: false,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: `Sync push failed: ${pushResult.error}`,
};
}
return {
success: true,
branch: pushResult.branch,
pulled: pullResult.pulled ?? true,
pushed: true,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
message: pullResult.pulled
? 'Sync completed: pulled latest changes and pushed.'
: 'Sync completed: already up to date, pushed local commits.',
};
}

View File

@@ -320,8 +320,10 @@ describe('codex-provider.ts', () => {
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// High reasoning effort should have 3x the default timeout (90000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
// High reasoning effort should have 3x the CLI base timeout (120000ms)
// CODEX_CLI_TIMEOUT_MS = 120000, multiplier for 'high' = 3.0 → 360000ms
const CODEX_CLI_TIMEOUT_MS = 120000;
expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
});
it('passes extended timeout for xhigh reasoning effort', async () => {
@@ -357,8 +359,10 @@ describe('codex-provider.ts', () => {
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// No reasoning effort should use the default timeout
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS);
// No reasoning effort should use the CLI base timeout (2 minutes)
// CODEX_CLI_TIMEOUT_MS = 120000ms, no multiplier applied
const CODEX_CLI_TIMEOUT_MS = 120000;
expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS);
});
});

View File

@@ -99,6 +99,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
import { forceSyncSettingsToServer } from '@/hooks/use-settings-sync';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -114,6 +115,7 @@ export function BoardView() {
pendingPlanApproval,
setPendingPlanApproval,
updateFeature,
batchUpdateFeatures,
getCurrentWorktree,
setCurrentWorktree,
getWorktrees,
@@ -132,6 +134,7 @@ export function BoardView() {
pendingPlanApproval: state.pendingPlanApproval,
setPendingPlanApproval: state.setPendingPlanApproval,
updateFeature: state.updateFeature,
batchUpdateFeatures: state.batchUpdateFeatures,
getCurrentWorktree: state.getCurrentWorktree,
setCurrentWorktree: state.setCurrentWorktree,
getWorktrees: state.getWorktrees,
@@ -411,25 +414,34 @@ export function BoardView() {
currentProject,
});
// Shared helper: batch-reset branch assignment and persist for each affected feature.
// Used when worktrees are deleted or branches are removed during merge.
const batchResetBranchFeatures = useCallback(
(branchName: string) => {
const affectedIds = hookFeatures.filter((f) => f.branchName === branchName).map((f) => f.id);
if (affectedIds.length === 0) return;
const updates: Partial<Feature> = { branchName: null };
batchUpdateFeatures(affectedIds, updates);
for (const id of affectedIds) {
persistFeatureUpdate(id, updates).catch((err: unknown) => {
console.error(
`[batchResetBranchFeatures] Failed to persist update for feature ${id}:`,
err
);
});
}
},
[hookFeatures, batchUpdateFeatures, persistFeatureUpdate]
);
// Memoize the removed worktrees handler to prevent infinite loops
const handleRemovedWorktrees = useCallback(
(removedWorktrees: Array<{ path: string; branch: string }>) => {
// Reset features that were assigned to the removed worktrees (by branch)
hookFeatures.forEach((feature) => {
const matchesRemovedWorktree = removedWorktrees.some((removed) => {
// Match by branch name since worktreePath is no longer stored
return feature.branchName === removed.branch;
});
if (matchesRemovedWorktree) {
// Reset the feature's branch assignment - update both local state and persist
const updates = { branchName: null as unknown as string | undefined };
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
});
for (const { branch } of removedWorktrees) {
batchResetBranchFeatures(branch);
}
},
[hookFeatures, updateFeature, persistFeatureUpdate]
[batchResetBranchFeatures]
);
// Get current worktree info (path) for filtering features
@@ -437,28 +449,6 @@ export function BoardView() {
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Track the previous worktree path to detect worktree switches
const prevWorktreePathRef = useRef<string | null | undefined>(undefined);
// When the active worktree changes, invalidate feature queries to ensure
// feature cards (especially their todo lists / planSpec tasks) render fresh data.
// Without this, cards that unmount when filtered out and remount when the user
// switches back may show stale or missing todo list data until the next polling cycle.
useEffect(() => {
// Skip the initial mount (prevWorktreePathRef starts as undefined)
if (prevWorktreePathRef.current === undefined) {
prevWorktreePathRef.current = currentWorktreePath;
return;
}
// Only invalidate when the worktree actually changed
if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
}
prevWorktreePathRef.current = currentWorktreePath;
}, [currentWorktreePath, currentProject?.path, queryClient]);
// Select worktrees for the current project directly from the store.
// Using a project-scoped selector prevents re-renders when OTHER projects'
// worktrees change (the old selector subscribed to the entire worktreesByProject
@@ -1603,17 +1593,7 @@ export function BoardView() {
onStashPopConflict={handleStashPopConflict}
onStashApplyConflict={handleStashApplyConflict}
onBranchDeletedDuringMerge={(branchName) => {
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
hookFeatures.forEach((feature) => {
if (feature.branchName === branchName) {
// Reset the feature's branch assignment - update both local state and persist
const updates = {
branchName: null as unknown as string | undefined,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
});
batchResetBranchFeatures(branchName);
setWorktreeRefreshKey((k) => k + 1);
}}
onRemovedWorktrees={handleRemovedWorktrees}
@@ -1990,31 +1970,76 @@ export function BoardView() {
}
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
onDeleted={(deletedWorktree, _deletedBranch) => {
// If the deleted worktree was currently selected, immediately reset to main
// to prevent the UI from trying to render a non-existent worktree view
if (
currentWorktreePath !== null &&
pathsEqual(currentWorktreePath, deletedWorktree.path)
) {
const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
setCurrentWorktree(currentProject.path, null, mainBranch);
}
// 1. Reset current worktree to main FIRST. This must happen
// BEFORE removing from the list to ensure downstream hooks
// (useAutoMode, useBoardFeatures) see a valid worktree and
// never try to render the deleted worktree.
const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
setCurrentWorktree(currentProject.path, null, mainBranch);
// Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => {
// Match by branch name since worktreePath is no longer stored
if (feature.branchName === deletedWorktree.branch) {
// Reset the feature's branch assignment - update both local state and persist
const updates = {
branchName: null as unknown as string | undefined,
// 2. Immediately remove the deleted worktree from the store's
// worktree list so the UI never renders a stale tab/dropdown
// item that can be clicked and cause a crash.
const remainingWorktrees = worktrees.filter(
(w) => !pathsEqual(w.path, deletedWorktree.path)
);
setWorktrees(currentProject.path, remainingWorktrees);
// 3. Cancel any in-flight worktree queries, then optimistically
// update the React Query cache so the worktree disappears
// from the dropdown immediately. Cancelling first prevents a
// pending refetch from overwriting our optimistic update with
// stale server data.
const worktreeQueryKey = queryKeys.worktrees.all(currentProject.path);
void queryClient.cancelQueries({ queryKey: worktreeQueryKey });
queryClient.setQueryData(
worktreeQueryKey,
(
old:
| {
worktrees: WorktreeInfo[];
removedWorktrees: Array<{ path: string; branch: string }>;
}
| undefined
) => {
if (!old) return old;
return {
...old,
worktrees: old.worktrees.filter(
(w: WorktreeInfo) => !pathsEqual(w.path, deletedWorktree.path)
),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
);
// 4. Batch-reset features assigned to the deleted worktree in one
// store mutation to avoid N individual updateFeature calls that
// cascade into React error #185.
batchResetBranchFeatures(deletedWorktree.branch);
// 5. Do NOT trigger setWorktreeRefreshKey here. The optimistic
// cache update (step 3) already removed the worktree from
// both the Zustand store and React Query cache. Incrementing
// the refresh key would cause invalidateQueries → server
// refetch, and if the server's .worktrees/ directory scan
// finds remnants of the deleted worktree, it would re-add
// it to the dropdown. The 30-second polling interval in
// WorktreePanel will eventually reconcile with the server.
setSelectedWorktreeForAction(null);
// 6. Force-sync settings immediately so the reset worktree
// selection is persisted before any potential page reload.
// Without this, the debounced sync (1s) may not complete
// in time and the stale worktree path survives in
// server settings, causing the deleted worktree to
// reappear on next load.
forceSyncSettingsToServer().then((ok) => {
if (!ok) {
logger.warn(
'forceSyncSettingsToServer failed after worktree deletion; stale path may reappear on reload'
);
}
});
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>

View File

@@ -491,7 +491,7 @@ export function CreatePRDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px]">
<DialogContent className="sm:max-w-[550px] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />
@@ -565,7 +565,7 @@ export function CreatePRDialog({
</div>
) : (
<>
<div className="grid gap-4 py-4">
<div className="grid gap-4 py-4 overflow-y-auto min-h-0 flex-1">
{worktree.hasChanges && (
<div className="grid gap-2">
<Label htmlFor="commit-message">
@@ -739,7 +739,7 @@ export function CreatePRDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<DialogFooter className="shrink-0 pt-2 border-t">
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
Cancel
</Button>

View File

@@ -72,9 +72,19 @@ export function DeleteWorktreeDialog({
? `Branch "${worktree.branch}" was also deleted`
: `Branch "${worktree.branch}" was kept`,
});
onDeleted(worktree, deleteBranch);
// Close the dialog first, then notify the parent.
// This ensures the dialog unmounts before the parent
// triggers potentially heavy state updates (feature branch
// resets, worktree refresh), reducing concurrent re-renders
// that can cascade into React error #185.
onOpenChange(false);
setDeleteBranch(false);
try {
onDeleted(worktree, deleteBranch);
} catch (error) {
// Prevent errors in onDeleted from propagating to the error boundary
console.error('onDeleted callback failed:', error);
}
} else {
toast.error('Failed to delete worktree', {
description: result.error,

View File

@@ -84,17 +84,19 @@ export function useBoardActions({
onWorktreeAutoSelect,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const {
addFeature,
updateFeature,
removeFeature,
moveFeature,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore();
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
// and triggers React error #185 (maximum update depth exceeded).
const addFeature = useAppStore((s) => s.addFeature);
const updateFeature = useAppStore((s) => s.updateFeature);
const removeFeature = useAppStore((s) => s.removeFeature);
const moveFeature = useAppStore((s) => s.moveFeature);
const worktreesEnabled = useAppStore((s) => s.useWorktrees);
const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking);
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
const isPrimaryWorktreeBranch = useAppStore((s) => s.isPrimaryWorktreeBranch);
const getPrimaryWorktreeBranch = useAppStore((s) => s.getPrimaryWorktreeBranch);
const autoMode = useAutoMode();
// React Query mutations for feature operations
@@ -549,7 +551,7 @@ export function useBoardActions({
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
useWorktrees
worktreesEnabled
// No worktreePath - server derives from feature.branchName
);
@@ -560,7 +562,7 @@ export function useBoardActions({
throw new Error(result.error || 'Failed to start feature');
}
},
[currentProject, useWorktrees]
[currentProject, worktreesEnabled]
);
const handleStartImplementation = useCallback(
@@ -693,9 +695,9 @@ export function useBoardActions({
logger.error('No current project');
return;
}
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees: worktreesEnabled });
},
[currentProject, resumeFeatureMutation, useWorktrees]
[currentProject, resumeFeatureMutation, worktreesEnabled]
);
const handleManualVerify = useCallback(
@@ -780,7 +782,7 @@ export function useBoardActions({
followUpFeature.id,
followUpPrompt,
imagePaths,
useWorktrees
worktreesEnabled
);
if (!result.success) {
@@ -818,7 +820,7 @@ export function useBoardActions({
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
useWorktrees,
worktreesEnabled,
]);
const handleCommitFeature = useCallback(

View File

@@ -33,7 +33,12 @@ export function useBoardDragDrop({
const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
null
);
const { moveFeature, updateFeature } = useAppStore();
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
// and triggers React error #185 (maximum update depth exceeded).
const moveFeature = useAppStore((s) => s.moveFeature);
const updateFeature = useAppStore((s) => s.updateFeature);
const autoMode = useAutoMode();
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side

View File

@@ -14,7 +14,11 @@ interface UseBoardPersistenceProps {
}
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
const { updateFeature } = useAppStore();
// IMPORTANT: Use individual selector instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
// and triggers React error #185 (maximum update depth exceeded).
const updateFeature = useAppStore((s) => s.updateFeature);
const queryClient = useQueryClient();
// Persist feature update to API (replaces saveFeatures)

View File

@@ -14,6 +14,7 @@ import {
import {
Trash2,
MoreHorizontal,
GitBranch,
GitCommit,
GitPullRequest,
Download,
@@ -138,6 +139,85 @@ interface WorktreeActionsDropdownProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
/** Whether sync is in progress */
isSyncing?: boolean;
/** Sync (pull + push) callback */
onSync?: (worktree: WorktreeInfo) => void;
/** Sync with a specific remote */
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
}
/**
* A remote item that either renders as a split-button with "Set as Tracking Branch"
* sub-action, or a plain menu item if onSetTracking is not provided.
*/
function RemoteActionMenuItem({
remote,
icon: Icon,
trackingRemote,
isDisabled,
isGitOpsAvailable,
onAction,
onSetTracking,
}: {
remote: { name: string; url: string };
icon: typeof Download;
trackingRemote?: string;
isDisabled: boolean;
isGitOpsAvailable: boolean;
onAction: () => void;
onSetTracking?: () => void;
}) {
if (onSetTracking) {
return (
<DropdownMenuSub key={remote.name}>
<div className="flex items-center">
<DropdownMenuItem
onClick={onAction}
disabled={isDisabled || !isGitOpsAvailable}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Icon className="w-3.5 h-3.5 mr-2" />
{remote.name}
{trackingRemote === remote.name && (
<span className="ml-auto text-[10px] text-muted-foreground mr-1">tracking</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className="text-xs px-1 rounded-l-none border-l border-border/30 h-8"
disabled={!isGitOpsAvailable}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={onSetTracking}
disabled={!isGitOpsAvailable}
className="text-xs"
>
<GitBranch className="w-3.5 h-3.5 mr-2" />
Set as Tracking Branch
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
return (
<DropdownMenuItem
key={remote.name}
onClick={onAction}
disabled={isDisabled || !isGitOpsAvailable}
className="text-xs"
>
<Icon className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
);
}
export function WorktreeActionsDropdown({
@@ -198,6 +278,10 @@ export function WorktreeActionsDropdown({
terminalScripts,
onRunTerminalScript,
onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -719,18 +803,20 @@ export function WorktreeActionsDropdown({
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
<RemoteActionMenuItem
key={remote.name}
onClick={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
disabled={isPulling || !isGitOpsAvailable}
className="text-xs"
>
<Download className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
remote={remote}
icon={Download}
trackingRemote={trackingRemote}
isDisabled={isPulling}
isGitOpsAvailable={isGitOpsAvailable}
onAction={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
onSetTracking={
onSetTracking
? () => isGitOpsAvailable && onSetTracking(worktree, remote.name)
: undefined
}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -818,18 +904,20 @@ export function WorktreeActionsDropdown({
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
<RemoteActionMenuItem
key={remote.name}
onClick={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
disabled={isPushing || !isGitOpsAvailable}
className="text-xs"
>
<Upload className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
remote={remote}
icon={Upload}
trackingRemote={trackingRemote}
isDisabled={isPushing}
isGitOpsAvailable={isGitOpsAvailable}
onAction={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
onSetTracking={
onSetTracking
? () => isGitOpsAvailable && onSetTracking(worktree, remote.name)
: undefined
}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -876,6 +964,72 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
</TooltipWrapper>
{onSync && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
{remotes && remotes.length > 1 && onSyncWithRemote ? (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onSync(worktree)}
disabled={isSyncing || !isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<RefreshCw className={cn('w-3.5 h-3.5 mr-2', isSyncing && 'animate-spin')} />
{isSyncing ? 'Syncing...' : 'Sync'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
(!isGitOpsAvailable || isSyncing) && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable || isSyncing}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Sync with remote
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
key={`sync-${remote.name}`}
onClick={() => isGitOpsAvailable && onSyncWithRemote(worktree, remote.name)}
disabled={isSyncing || !isGitOpsAvailable}
className="text-xs"
>
<RefreshCw className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onSync(worktree)}
disabled={isSyncing || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<RefreshCw className={cn('w-3.5 h-3.5 mr-2', isSyncing && 'animate-spin')} />
{isSyncing ? 'Syncing...' : 'Sync'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}

View File

@@ -138,6 +138,14 @@ export interface WorktreeDropdownProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
/** Whether sync is in progress */
isSyncing?: boolean;
/** Sync (pull + push) callback */
onSync?: (worktree: WorktreeInfo) => void;
/** Sync with a specific remote */
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
}
/**
@@ -230,6 +238,10 @@ export function WorktreeDropdown({
terminalScripts,
onRunTerminalScript,
onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -549,6 +561,10 @@ export function WorktreeDropdown({
terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts}
isSyncing={isSyncing}
onSync={onSync}
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
/>
)}
</div>

View File

@@ -108,6 +108,14 @@ interface WorktreeTabProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
/** Whether sync is in progress */
isSyncing?: boolean;
/** Sync (pull + push) callback */
onSync?: (worktree: WorktreeInfo) => void;
/** Sync with a specific remote */
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
}
export function WorktreeTab({
@@ -181,6 +189,10 @@ export function WorktreeTab({
terminalScripts,
onRunTerminalScript,
onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -550,6 +562,10 @@ export function WorktreeTab({
terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts}
isSyncing={isSyncing}
onSync={onSync}
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
/>
</div>
);

View File

@@ -8,6 +8,8 @@ import {
useSwitchBranch,
usePullWorktree,
usePushWorktree,
useSyncWorktree,
useSetTracking,
useOpenInEditor,
} from '@/hooks/mutations';
import type { WorktreeInfo } from '../types';
@@ -51,6 +53,8 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
});
const pullMutation = usePullWorktree();
const pushMutation = usePushWorktree();
const syncMutation = useSyncWorktree();
const setTrackingMutation = useSetTracking();
const openInEditorMutation = useOpenInEditor();
/**
@@ -150,6 +154,28 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
[pushMutation]
);
const handleSync = useCallback(
async (worktree: WorktreeInfo, remote?: string) => {
if (syncMutation.isPending) return;
syncMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[syncMutation]
);
const handleSetTracking = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
if (setTrackingMutation.isPending) return;
setTrackingMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[setTrackingMutation]
);
const handleOpenInIntegratedTerminal = useCallback(
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
// Navigate to the terminal view with the worktree path and branch name
@@ -215,12 +241,15 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
return {
isPulling: pullMutation.isPending,
isPushing: pushMutation.isPending,
isSyncing: syncMutation.isPending,
isSwitching: switchBranchMutation.isPending,
isActivating,
setIsActivating,
handleSwitchBranch,
handlePull,
handlePush,
handleSync,
handleSetTracking,
handleOpenInIntegratedTerminal,
handleRunTerminalScript,
handleOpenInEditor,

View File

@@ -111,13 +111,17 @@ export function useWorktrees({
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
// Invalidate feature queries when switching worktrees to ensure fresh data.
// Without this, feature cards that remount after the worktree switch may have stale
// or missing planSpec/task data, causing todo lists to appear empty until the next
// polling cycle or user interaction triggers a re-render.
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(projectPath),
});
// Defer feature query invalidation so the store update and client-side
// re-filtering happen in the current render cycle first. The features
// list is the same regardless of worktree (filtering is client-side),
// so the board updates instantly. The deferred invalidation ensures
// feature card details (planSpec, todo lists) are refreshed in the
// background without blocking the worktree switch.
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(projectPath),
});
}, 0);
},
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath]
);

View File

@@ -113,11 +113,14 @@ export function WorktreePanel({
const {
isPulling,
isPushing,
isSyncing,
isSwitching,
isActivating,
handleSwitchBranch,
handlePull: _handlePull,
handlePush,
handleSync,
handleSetTracking,
handleOpenInIntegratedTerminal,
handleRunTerminalScript,
handleOpenInEditor,
@@ -828,6 +831,30 @@ export function WorktreePanel({
[handlePush, fetchBranches, fetchWorktrees]
);
// Handle sync (pull + push) with optional remote selection
const handleSyncWithRemoteSelection = useCallback(
(worktree: WorktreeInfo) => {
handleSync(worktree);
},
[handleSync]
);
// Handle sync with a specific remote selected from the submenu
const handleSyncWithSpecificRemote = useCallback(
(worktree: WorktreeInfo, remote: string) => {
handleSync(worktree, remote);
},
[handleSync]
);
// Handle set tracking branch for a specific remote
const handleSetTrackingForRemote = useCallback(
(worktree: WorktreeInfo, remote: string) => {
handleSetTracking(worktree, remote);
},
[handleSetTracking]
);
// Handle confirming the push to remote dialog
const handleConfirmPushToRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
@@ -936,6 +963,10 @@ export function WorktreePanel({
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1179,6 +1210,10 @@ export function WorktreePanel({
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
@@ -1286,6 +1321,10 @@ export function WorktreePanel({
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
@@ -1373,6 +1412,10 @@ export function WorktreePanel({
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}

View File

@@ -9,6 +9,10 @@ import type { EventHook, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { EventHookDialog } from './event-hook-dialog';
import { EventHistoryView } from './event-history-view';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('EventHooks');
export function EventHooksSection() {
const { eventHooks, setEventHooks } = useAppStore();
@@ -26,24 +30,39 @@ export function EventHooksSection() {
setDialogOpen(true);
};
const handleDeleteHook = (hookId: string) => {
setEventHooks(eventHooks.filter((h) => h.id !== hookId));
};
const handleToggleHook = (hookId: string, enabled: boolean) => {
setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h)));
};
const handleSaveHook = (hook: EventHook) => {
if (editingHook) {
// Update existing
setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h)));
} else {
// Add new
setEventHooks([...eventHooks, hook]);
const handleDeleteHook = async (hookId: string) => {
try {
await setEventHooks(eventHooks.filter((h) => h.id !== hookId));
} catch (error) {
logger.error('Failed to delete event hook:', error);
toast.error('Failed to delete event hook');
}
};
const handleToggleHook = async (hookId: string, enabled: boolean) => {
try {
await setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h)));
} catch (error) {
logger.error('Failed to toggle event hook:', error);
toast.error('Failed to update event hook');
}
};
const handleSaveHook = async (hook: EventHook) => {
try {
if (editingHook) {
// Update existing
await setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h)));
} else {
// Add new
await setEventHooks([...eventHooks, hook]);
}
setDialogOpen(false);
setEditingHook(null);
} catch (error) {
logger.error('Failed to save event hook:', error);
toast.error('Failed to save event hook');
}
setDialogOpen(false);
setEditingHook(null);
};
// Group hooks by trigger type for better organization

View File

@@ -46,6 +46,8 @@ export {
useCommitWorktree,
usePushWorktree,
usePullWorktree,
useSyncWorktree,
useSetTracking,
useCreatePullRequest,
useMergeWorktree,
useSwitchBranch,

View File

@@ -197,6 +197,76 @@ export function usePullWorktree() {
});
}
/**
* Sync worktree branch (pull then push)
*
* @returns Mutation for syncing changes
*/
export function useSyncWorktree() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.sync(worktreePath, remote);
if (!result.success) {
throw new Error(result.error || 'Failed to sync');
}
return result.result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
toast.success('Branch synced with remote');
},
onError: (error: Error) => {
toast.error('Failed to sync', {
description: error.message,
});
},
});
}
/**
* Set upstream tracking branch
*
* @returns Mutation for setting tracking branch
*/
export function useSetTracking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
worktreePath,
remote,
branch,
}: {
worktreePath: string;
remote: string;
branch?: string;
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.setTracking(worktreePath, remote, branch);
if (!result.success) {
throw new Error(result.error || 'Failed to set tracking branch');
}
return result.result;
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
toast.success('Tracking branch set', {
description: result?.message,
});
},
onError: (error: Error) => {
toast.error('Failed to set tracking branch', {
description: error.message,
});
},
});
}
/**
* Create a pull request from a worktree
*

View File

@@ -8,12 +8,21 @@ import { getHttpApiClient } from '@/lib/http-api-client';
* before the user opens feature dialogs.
*/
export function useCursorStatusInit() {
const { setCursorCliStatus, cursorCliStatus } = useSetupStore();
// Use individual selectors instead of bare useSetupStore() to prevent
// re-rendering on every setup store mutation during initialization.
const setCursorCliStatus = useSetupStore((s) => s.setCursorCliStatus);
const initialized = useRef(false);
useEffect(() => {
// Only initialize once per session
if (initialized.current || cursorCliStatus !== null) {
if (initialized.current) {
return;
}
// Check current status at call time rather than via dependency to avoid
// re-renders when other setup store fields change during initialization.
const currentStatus = useSetupStore.getState().cursorCliStatus;
if (currentStatus !== null) {
initialized.current = true;
return;
}
initialized.current = true;
@@ -42,5 +51,5 @@ export function useCursorStatusInit() {
};
initCursorStatus();
}, [setCursorCliStatus, cursorCliStatus]);
}, [setCursorCliStatus]);
}

View File

@@ -17,17 +17,16 @@ const logger = createLogger('ProviderAuthInit');
* without needing to visit the settings page first.
*/
export function useProviderAuthInit() {
const {
setClaudeAuthStatus,
setCodexAuthStatus,
setZaiAuthStatus,
setGeminiCliStatus,
setGeminiAuthStatus,
claudeAuthStatus,
codexAuthStatus,
zaiAuthStatus,
geminiAuthStatus,
} = useSetupStore();
// IMPORTANT: Use individual selectors instead of bare useSetupStore() to prevent
// re-rendering on every setup store mutation. The bare call subscribes to the ENTIRE
// store, which during initialization causes cascading re-renders as multiple status
// setters fire in rapid succession. With enough rapid mutations, React hits the
// maximum update depth limit (error #185).
const setClaudeAuthStatus = useSetupStore((s) => s.setClaudeAuthStatus);
const setCodexAuthStatus = useSetupStore((s) => s.setCodexAuthStatus);
const setZaiAuthStatus = useSetupStore((s) => s.setZaiAuthStatus);
const setGeminiCliStatus = useSetupStore((s) => s.setGeminiCliStatus);
const setGeminiAuthStatus = useSetupStore((s) => s.setGeminiAuthStatus);
const initialized = useRef(false);
const refreshStatuses = useCallback(async () => {
@@ -219,5 +218,9 @@ export function useProviderAuthInit() {
// Always call refreshStatuses() to background re-validate on app restart,
// even when statuses are pre-populated from persisted storage (cache case).
void refreshStatuses();
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]);
// Only depend on the callback ref. The status values were previously included
// but they are outputs of refreshStatuses(), not inputs — including them caused
// cascading re-renders during initialization that triggered React error #185
// (maximum update depth exceeded) on first run.
}, [refreshStatuses]);
}

View File

@@ -26,6 +26,7 @@ import { useEffect, useState, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
import { getItem, setItem } from '@/lib/storage';
import { sanitizeWorktreeByProject } from '@/lib/settings-utils';
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import {
@@ -363,6 +364,15 @@ export function mergeSettings(
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
}
// Event hooks - preserve from localStorage if server is empty
if (
(!serverSettings.eventHooks || serverSettings.eventHooks.length === 0) &&
localSettings.eventHooks &&
localSettings.eventHooks.length > 0
) {
merged.eventHooks = localSettings.eventHooks;
}
// Preserve new settings fields from localStorage if server has defaults
// Use nullish coalescing to accept stored falsy values (e.g. false)
if (localSettings.enableAiCommitMessages != null && merged.enableAiCommitMessages == null) {
@@ -785,7 +795,14 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
projectHistory: settings.projectHistory ?? [],
projectHistoryIndex: settings.projectHistoryIndex ?? -1,
lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {},
currentWorktreeByProject: settings.currentWorktreeByProject ?? {},
// Sanitize currentWorktreeByProject: only restore entries where path is null
// (main branch). Non-null paths point to worktree directories that may have
// been deleted while the app was closed. Restoring a stale path causes the
// board to render an invalid worktree selection, triggering a crash loop
// (error boundary reloads → restores same bad path → crash again).
// The use-worktrees validation effect will re-discover valid worktrees
// from the server once they load.
currentWorktreeByProject: sanitizeWorktreeByProject(settings.currentWorktreeByProject),
// UI State
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
lastProjectDir: settings.lastProjectDir ?? '',

View File

@@ -19,6 +19,7 @@ import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-stor
import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import { sanitizeWorktreeByProject } from '@/lib/settings-utils';
import {
DEFAULT_OPENCODE_MODEL,
DEFAULT_GEMINI_MODEL,
@@ -584,6 +585,15 @@ export async function forceSyncSettingsToServer(): Promise<boolean> {
updates[field] = setupState[field as keyof typeof setupState];
}
// Update localStorage cache immediately so a page reload before the
// server response arrives still sees the latest state (e.g. after
// deleting a worktree, the stale worktree path won't survive in cache).
try {
setItem('automaker-settings-cache', JSON.stringify(updates));
} catch (storageError) {
logger.warn('Failed to update localStorage cache during force sync:', storageError);
}
const result = await api.settings.updateGlobal(updates);
return result.success;
} catch (error) {
@@ -796,8 +806,11 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
projectHistory: serverSettings.projectHistory,
projectHistoryIndex: serverSettings.projectHistoryIndex,
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
currentWorktreeByProject:
serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject,
// Sanitize: only restore entries with path === null (main branch).
// Non-null paths may reference deleted worktrees, causing crash loops.
currentWorktreeByProject: sanitizeWorktreeByProject(
serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject
),
// UI State (previously in localStorage)
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
lastProjectDir: serverSettings.lastProjectDir ?? '',

View File

@@ -2268,7 +2268,12 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
push: async (worktreePath: string, force?: boolean, remote?: string) => {
push: async (
worktreePath: string,
force?: boolean,
remote?: string,
_autoResolve?: boolean
) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
return {
@@ -2281,6 +2286,38 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
sync: async (worktreePath: string, remote?: string) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Syncing worktree:', { worktreePath, remote: targetRemote });
return {
success: true,
result: {
branch: 'feature-branch',
pulled: true,
pushed: true,
message: `Synced with ${targetRemote}`,
},
};
},
setTracking: async (worktreePath: string, remote: string, branch?: string) => {
const targetBranch = branch || 'feature-branch';
console.log('[Mock] Setting tracking branch:', {
worktreePath,
remote,
branch: targetBranch,
});
return {
success: true,
result: {
branch: targetBranch,
remote,
upstream: `${remote}/${targetBranch}`,
message: `Set tracking branch to ${remote}/${targetBranch}`,
},
};
},
createPR: async (worktreePath: string, options?: CreatePROptions) => {
console.log('[Mock] Creating PR:', { worktreePath, options });
return {

View File

@@ -2208,8 +2208,12 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/generate-commit-message', { worktreePath }),
generatePRDescription: (worktreePath: string, baseBranch?: string) =>
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }),
push: (worktreePath: string, force?: boolean, remote?: string) =>
this.post('/api/worktree/push', { worktreePath, force, remote }),
push: (worktreePath: string, force?: boolean, remote?: string, autoResolve?: boolean) =>
this.post('/api/worktree/push', { worktreePath, force, remote, autoResolve }),
sync: (worktreePath: string, remote?: string) =>
this.post('/api/worktree/sync', { worktreePath, remote }),
setTracking: (worktreePath: string, remote: string, branch?: string) =>
this.post('/api/worktree/set-tracking', { worktreePath, remote, branch }),
createPR: (worktreePath: string, options?: CreatePROptions) =>
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) =>

View File

@@ -0,0 +1,27 @@
/**
* Shared settings utility functions
*/
/**
* Drop currentWorktreeByProject entries with non-null paths.
* Non-null paths reference worktree directories that may have been deleted,
* and restoring them causes crash loops (board renders invalid worktree
* -> error boundary reloads -> restores same stale path).
*/
export function sanitizeWorktreeByProject(
raw: Record<string, { path: string | null; branch: string }> | undefined
): Record<string, { path: string | null; branch: string }> {
if (!raw) return {};
const sanitized: Record<string, { path: string | null; branch: string }> = {};
for (const [projectPath, worktree] of Object.entries(raw)) {
if (
typeof worktree === 'object' &&
worktree !== null &&
'path' in worktree &&
worktree.path === null
) {
sanitized[projectPath] = worktree;
}
}
return sanitized;
}

View File

@@ -594,6 +594,21 @@ function RootLayoutContent() {
logger.info(
'[FAST_HYDRATE] Background reconcile: cache updated (store untouched)'
);
// Selectively reconcile event hooks from server.
// Unlike projects/theme, eventHooks aren't rendered on the main view,
// so updating them won't cause a visible re-render flash.
const serverHooks = (finalSettings as GlobalSettings).eventHooks ?? [];
const currentHooks = useAppStore.getState().eventHooks;
if (
JSON.stringify(serverHooks) !== JSON.stringify(currentHooks) &&
serverHooks.length > 0
) {
logger.info(
`[FAST_HYDRATE] Reconciling eventHooks from server (server=${serverHooks.length}, store=${currentHooks.length})`
);
useAppStore.setState({ eventHooks: serverHooks });
}
} catch (e) {
logger.debug('[FAST_HYDRATE] Failed to update cache:', e);
}

View File

@@ -878,6 +878,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set((state) => ({
features: state.features.map((f) => (f.id === id ? { ...f, ...updates } : f)),
})),
batchUpdateFeatures: (ids, updates) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
set((state) => ({
features: state.features.map((f) => (idSet.has(f.id) ? { ...f, ...updates } : f)),
}));
},
addFeature: (feature) => {
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newFeature = { ...feature, id } as Feature;
@@ -1415,7 +1422,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
},
// Event Hook actions
setEventHooks: (hooks) => set({ eventHooks: hooks }),
setEventHooks: async (hooks) => {
set({ eventHooks: hooks });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ eventHooks: hooks });
} catch (error) {
logger.error('Failed to sync event hooks:', error);
}
},
// Claude-Compatible Provider actions (new system)
addClaudeCompatibleProvider: async (provider) => {

View File

@@ -440,6 +440,8 @@ export interface AppActions {
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void;
/** Apply the same updates to multiple features in a single store mutation. */
batchUpdateFeatures: (ids: string[], updates: Partial<Feature>) => void;
addFeature: (feature: Omit<Feature, 'id'> & Partial<Pick<Feature, 'id'>>) => Feature;
removeFeature: (id: string) => void;
moveFeature: (id: string, newStatus: Feature['status']) => void;
@@ -634,7 +636,7 @@ export interface AppActions {
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
// Event Hook actions
setEventHooks: (hooks: EventHook[]) => void;
setEventHooks: (hooks: EventHook[]) => Promise<void>;
// Claude-Compatible Provider actions (new system)
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;

View File

@@ -980,18 +980,61 @@ export interface WorktreeAPI {
push: (
worktreePath: string,
force?: boolean,
remote?: string
remote?: string,
autoResolve?: boolean
) => Promise<{
success: boolean;
result?: {
branch: string;
pushed: boolean;
diverged?: boolean;
autoResolved?: boolean;
message: string;
};
error?: string;
diverged?: boolean;
hasConflicts?: boolean;
conflictFiles?: string[];
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
}>;
// Sync a worktree branch (pull then push)
sync: (
worktreePath: string,
remote?: string
) => Promise<{
success: boolean;
result?: {
branch: string;
pulled: boolean;
pushed: boolean;
isFastForward?: boolean;
isMerge?: boolean;
autoResolved?: boolean;
message: string;
};
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
conflictSource?: 'pull' | 'stash';
}>;
// Set the upstream tracking branch
setTracking: (
worktreePath: string,
remote: string,
branch?: string
) => Promise<{
success: boolean;
result?: {
branch: string;
remote: string;
upstream: string;
message: string;
};
error?: string;
}>;
// Create a pull request from a worktree
createPR: (
worktreePath: string,

View File

@@ -490,6 +490,7 @@ Rules:
- Focus on WHAT changed and WHY (if clear from the diff), not HOW
- No quotes, backticks, or extra formatting
- If there are multiple changes, provide a brief summary on the first line
- Ignore changes to gitignored files (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). Focus only on meaningful source code changes that are tracked by git
Examples:
- feat: Add dark mode toggle to settings

View File

@@ -91,7 +91,7 @@ export interface Feature {
imagePaths?: Array<string | FeatureImagePath | { path: string; [key: string]: unknown }>;
textFilePaths?: FeatureTextFilePath[];
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
branchName?: string | null; // Name of the feature branch (undefined/null = use current worktree)
skipTests?: boolean;
excludedPipelineSteps?: string[]; // Array of pipeline step IDs to skip for this feature
thinkingLevel?: ThinkingLevel;

View File

@@ -1672,6 +1672,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
skillsSources: ['user', 'project'],
enableSubagents: true,
subagentsSources: ['user', 'project'],
// Event hooks
eventHooks: [],
// New provider system
claudeCompatibleProviders: [],
// Deprecated - kept for migration