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>
This commit is contained in:
gsxdsm
2026-02-22 00:58:00 -08:00
committed by GitHub
parent 1d732916f1
commit 2f071a1ba3
15 changed files with 300 additions and 110 deletions

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

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

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