Fix/worktree branch selection (#854)

* fix QA validation back to coding

* fix/worktree-branch-selection

* fix/workspace-merge

* fix/cleanup-worktree-after-done

* fix: address PR review feedback

- Fix workspace.py: move git add and resolved_files tracking inside content check blocks
- Fix workspace.py: add warning when merge-base fails (fall back to semantic analysis)
- Batch git add operations for efficiency
- Remove debug useEffect and console.log statements from KanbanBoard.tsx
- Consolidate duplicate handleStatusChange functions into single handler
- Remove debug console.log from WorktreeCleanupDialog.tsx
- Add i18n translations for WorktreeCleanupDialog (en/fr)
- Make _get_base_branch_from_metadata public (keep alias for compatibility)
- Add toast notification for worktree cleanup failures
- Add 30s timeout to git execFileSync calls for protection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: return failure when git add fails in direct copy path

When git add fails after writing files in the diverged_but_no_conflicts
direct copy path, now returns success: False with error details instead
of silently returning success: True with files listed as resolved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address LOW severity PR review findings

- Add debug logging when branch name fallback is used (NEW-004)
- Add retry button to worktree cleanup dialog on failure (NEW-003)
- Add error state propagation to WorktreeCleanupDialog
- Add French translation for retry button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address remaining PR review findings for better code quality

- NEW-002: Add warning when branch deletion uses fallback pattern
  - Track when fallback branch name is used
  - Log specific warning if fallback pattern fails to match actual branch
  - Helps identify potential orphaned branches needing manual cleanup

- NEW-003: Propagate actual error from forceCompleteTask
  - Change return type from boolean to PersistStatusResult
  - Show actual backend error in dialog instead of generic message
  - Improves debugging and user experience

- CMT-LINT: Change console.log to console.warn in worktree cleanup
  - Aligns with ESLint config (no-console rule)
  - Debug logging now uses appropriate log level

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: prevent requestAnimationFrame test flakiness in useXterm.test.ts

- Use fake timers (vi.useFakeTimers) to control async behavior
- Clear timers in afterEach before restoring mocks to prevent callbacks
  from firing after requestAnimationFrame mock is torn down
- Replace setTimeout promises with vi.advanceTimersByTimeAsync
- Add cancelAnimationFrame mock for completeness

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use subshells in pre-commit to prevent worktree corruption

Wrap both Python and frontend check sections in subshells to isolate
directory changes (cd commands) and prevent git worktree HEAD corruption
during pre-commit hook execution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use console.warn for limbo state log message

Change console.log to console.warn for the limbo state recovery message
to match project logging standards.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add worktree context preservation to pre-commit hook

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review findings for code quality

- NEW-005 MEDIUM: Track skipped files in workspace.py direct-copy path
  - Add skipped_files list to track files that fail to copy
  - Include skipped_count in stats and skipped_files in result
  - Return success: False when files are skipped
  - Print warning about skipped files for user visibility

- QUAL-001 LOW: Add .catch() to handleDragEnd async calls
  - Prevent unhandled promise rejections in drag-and-drop

- TEST-001 LOW: Isolate requestAnimationFrame mock in useXterm.test.ts
  - Move mock setup into beforeAll/afterAll hooks
  - Store and restore original functions for proper test isolation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add path traversal protection to worktree path functions

Add defense-in-depth validation to findTaskWorktree() and
findTerminalWorktree() to prevent directory traversal attacks.

- Add isPathWithinBase() helper to validate resolved paths
- Validate that specId/name doesn't escape project directory
- Return null and log error if path traversal is detected
- Both new and legacy path locations are validated

This addresses CodeRabbit security review finding about ensuring
worktree paths stay within the project root before git operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
This commit is contained in:
Andy
2026-01-11 00:17:49 +01:00
committed by GitHub
parent df540ec512
commit a6bd8842f8
22 changed files with 803 additions and 326 deletions
+59 -44
View File
@@ -1,5 +1,14 @@
#!/bin/sh
# Preserve git worktree context - prevent HEAD corruption in worktrees
if [ -f ".git" ]; then
WORKTREE_GIT_DIR=$(cat .git | sed 's/gitdir: //')
if [ -n "$WORKTREE_GIT_DIR" ]; then
export GIT_DIR="$WORKTREE_GIT_DIR"
export GIT_WORK_TREE="$(pwd)"
fi
fi
echo "Running pre-commit checks..."
# =============================================================================
@@ -126,28 +135,30 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
fi
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
# Use subshell to isolate directory changes and prevent worktree corruption
echo "Running Python tests..."
cd apps/backend
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py"
if [ -d ".venv" ]; then
# Use venv if it exists
if [ -f ".venv/bin/pytest" ]; then
PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
elif [ -f ".venv/Scripts/pytest.exe" ]; then
# Windows
PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
(
cd apps/backend
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py"
if [ -d ".venv" ]; then
# Use venv if it exists
if [ -f ".venv/bin/pytest" ]; then
PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
elif [ -f ".venv/Scripts/pytest.exe" ]; then
# Windows
PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
else
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
fi
else
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
fi
else
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
fi
)
if [ $? -ne 0 ]; then
echo "Python tests failed. Please fix failing tests before committing."
exit 1
fi
cd ../..
echo "Backend checks passed!"
fi
@@ -159,40 +170,44 @@ fi
# Check if there are staged files in apps/frontend
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
echo "Frontend changes detected, running frontend checks..."
cd apps/frontend
# Use subshell to isolate directory changes and prevent worktree corruption
(
cd apps/frontend
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
if [ $? -ne 0 ]; then
echo "lint-staged failed. Please fix linting errors before committing."
exit 1
fi
# Run TypeScript type check
echo "Running type check..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "Type check failed. Please fix TypeScript errors before committing."
exit 1
fi
# Run linting
echo "Running lint..."
npm run lint
if [ $? -ne 0 ]; then
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
exit 1
fi
# Check for vulnerabilities (only high severity)
echo "Checking for vulnerabilities..."
npm audit --audit-level=high
if [ $? -ne 0 ]; then
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
exit 1
fi
)
if [ $? -ne 0 ]; then
echo "lint-staged failed. Please fix linting errors before committing."
exit 1
fi
# Run TypeScript type check
echo "Running type check..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "Type check failed. Please fix TypeScript errors before committing."
exit 1
fi
# Run linting
echo "Running lint..."
npm run lint
if [ $? -ne 0 ]; then
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
exit 1
fi
# Check for vulnerabilities (only high severity)
echo "Checking for vulnerabilities..."
npm audit --audit-level=high
if [ $? -ne 0 ]; then
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
exit 1
fi
cd ../..
echo "Frontend checks passed!"
fi
+9
View File
@@ -87,6 +87,7 @@ def handle_build_command(
debug_success,
)
from phase_config import get_phase_model
from prompts_pkg.prompts import get_base_branch_from_metadata
from qa_loop import run_qa_validation_loop, should_run_qa
from .utils import print_banner, validate_environment
@@ -194,6 +195,14 @@ def handle_build_command(
auto_continue=auto_continue,
)
# If base_branch not provided via CLI, try to read from task_metadata.json
# This ensures the backend uses the branch configured in the frontend
if base_branch is None:
metadata_branch = get_base_branch_from_metadata(spec_dir)
if metadata_branch:
base_branch = metadata_branch
debug("run.py", f"Using base branch from task metadata: {base_branch}")
if workspace_mode == WorkspaceMode.ISOLATED:
# Keep reference to original spec directory for syncing progress back
source_spec_dir = spec_dir
+232 -57
View File
@@ -252,9 +252,10 @@ def merge_existing_build(
stats = smart_result.get("stats", {})
had_conflicts = stats.get("conflicts_resolved", 0) > 0
ai_assisted = stats.get("ai_assisted", 0) > 0
direct_copy = stats.get("direct_copy", False)
if had_conflicts or ai_assisted:
# AI actually resolved conflicts or assisted with merges
if had_conflicts or ai_assisted or direct_copy:
# AI resolved conflicts, assisted with merges, or direct copy was used
# Changes are already written and staged - no need for git merge
_print_merge_success(
no_commit, stats, spec_name=spec_name, keep_worktree=True
@@ -499,6 +500,155 @@ def _try_smart_merge_inner(
"error": resolution_result.get("error"),
}
# Check if branches diverged but no actual conflicts (can do direct copy)
if git_conflicts.get("diverged_but_no_conflicts"):
debug(MODULE, "Branches diverged but no conflicts - doing direct file copy")
print(muted(" Branches diverged but no conflicts detected"))
print(muted(" Copying changed files directly from worktree..."))
# Get changed files from spec branch
spec_branch = f"auto-claude/{spec_name}"
base_branch = git_conflicts.get("base_branch", "main")
# Get merge-base for diff
merge_base_result = subprocess.run(
["git", "merge-base", base_branch, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
)
merge_base = (
merge_base_result.stdout.strip()
if merge_base_result.returncode == 0
else None
)
if merge_base:
# Get list of changed files in spec branch
changed_files = _get_changed_files_from_branch(
project_dir, merge_base, spec_branch
)
resolved_files = []
skipped_files = [] # Track files that failed to copy
files_to_stage = []
for file_path, status in changed_files:
if _is_auto_claude_file(file_path):
continue
try:
target_path = project_dir / file_path
if status == "D":
# Deleted in worktree
if target_path.exists():
target_path.unlink()
files_to_stage.append(file_path)
resolved_files.append(file_path)
print(success(f"{file_path} (deleted)"))
else:
# New or modified - copy from spec branch
target_path.parent.mkdir(parents=True, exist_ok=True)
if _is_binary_file(file_path):
binary_content = _get_binary_file_content_from_ref(
project_dir, spec_branch, file_path
)
if binary_content is not None:
target_path.write_bytes(binary_content)
files_to_stage.append(file_path)
resolved_files.append(file_path)
status_label = (
"new file" if status == "A" else "updated"
)
print(
success(f"{file_path} ({status_label})")
)
else:
skipped_files.append(file_path)
debug_warning(
MODULE,
f"Could not retrieve binary content for {file_path}",
)
else:
content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None:
target_path.write_text(content, encoding="utf-8")
files_to_stage.append(file_path)
resolved_files.append(file_path)
status_label = (
"new file" if status == "A" else "updated"
)
print(
success(f"{file_path} ({status_label})")
)
else:
skipped_files.append(file_path)
debug_warning(
MODULE,
f"Could not retrieve content for {file_path}",
)
except Exception as e:
skipped_files.append(file_path)
debug_warning(MODULE, f"Could not copy {file_path}: {e}")
# Stage all files in a single git add call for efficiency
if files_to_stage:
try:
subprocess.run(
["git", "add"] + files_to_stage,
cwd=project_dir,
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
debug_warning(
MODULE, f"Failed to stage files for direct copy: {e.stderr}"
)
# Return failure - files were written but not staged
return {
"success": False,
"error": f"Failed to stage files: {e.stderr}",
"resolved_files": [],
}
# Build result - check for skipped files to detect partial merges
result = {
"success": len(skipped_files) == 0,
"resolved_files": resolved_files,
"stats": {
"files_merged": len(resolved_files),
"conflicts_resolved": 0,
"ai_assisted": 0,
"auto_merged": len(resolved_files),
"direct_copy": True, # Flag indicating direct copy was used
"skipped_count": len(skipped_files),
},
}
if skipped_files:
result["skipped_files"] = skipped_files
result["partial_success"] = len(resolved_files) > 0
print()
print(
warning(
f"{len(skipped_files)} file(s) could not be retrieved:"
)
)
for skipped_file in skipped_files:
print(muted(f" - {skipped_file}"))
print(muted(" These files may need manual review."))
return result
else:
# merge-base failed - branches may not share history
debug_warning(
MODULE,
"Could not find merge-base between branches - falling back to semantic analysis",
)
# No git conflicts - proceed with semantic analysis
debug(MODULE, "No git conflicts, proceeding with semantic analysis")
preview = orchestrator.preview_merge([spec_name])
@@ -623,11 +773,10 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
text=True,
)
# merge-tree returns exit code 1 if there are conflicts
# merge-tree returns exit code 1 if there are actual text conflicts
# Exit code 0 means clean merge possible
if merge_tree_result.returncode != 0:
result["has_conflicts"] = True
# Parse the output for conflicting files
# Parse the output for ACTUAL conflicting files (look for CONFLICT markers)
output = merge_tree_result.stdout + merge_tree_result.stderr
for line in output.split("\n"):
if "CONFLICT" in line:
@@ -645,38 +794,27 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
):
result["conflicting_files"].append(file_path)
# Fallback: if we didn't parse conflicts, use diff to find files changed in both branches
if not result["conflicting_files"]:
main_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, main_commit],
cwd=project_dir,
capture_output=True,
text=True,
# Only set has_conflicts if we found ACTUAL CONFLICT markers
# A non-zero exit code without CONFLICT markers just means branches diverged
# but git can auto-merge them - we handle this with direct file copy
if result["conflicting_files"]:
result["has_conflicts"] = True
debug(
MODULE,
f"Found {len(result['conflicting_files'])} actual git conflicts",
files=result["conflicting_files"],
)
main_files = (
set(main_files_result.stdout.strip().split("\n"))
if main_files_result.stdout.strip()
else set()
else:
# No CONFLICT markers = no actual conflicts
# Branches diverged but changes don't overlap - git can auto-merge
# We'll handle this by copying files directly from spec branch
debug(
MODULE,
"No CONFLICT markers - branches diverged but can be auto-merged",
merge_tree_returncode=merge_tree_result.returncode,
)
spec_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, spec_commit],
cwd=project_dir,
capture_output=True,
text=True,
)
spec_files = (
set(spec_files_result.stdout.strip().split("\n"))
if spec_files_result.stdout.strip()
else set()
)
# Files modified in both = potential conflicts
# Filter out .auto-claude files - they should never be merged
conflicting = main_files & spec_files
result["conflicting_files"] = [
f for f in conflicting if not _is_auto_claude_file(f)
]
result["has_conflicts"] = False
result["diverged_but_no_conflicts"] = True # Flag for direct copy
except Exception as e:
print(muted(f" Error checking git conflicts: {e}"))
@@ -829,6 +967,7 @@ def _resolve_git_conflicts_with_ai(
tuple[str, str | None]
] = [] # (file_path, merged_content or None for delete)
lock_files_excluded: list[str] = [] # Lock files excluded from merge
auto_merged_simple: set[str] = set() # Files that were auto-merged via simple 3-way
debug(MODULE, "Categorizing conflicting files for parallel processing")
@@ -887,27 +1026,49 @@ def _resolve_git_conflicts_with_ai(
f" {target_file_path}: lock file (excluded - will use main version)",
)
else:
# Regular file - needs AI merge
# Store the TARGET path for writing, but track original for content retrieval
files_needing_ai_merge.append(
ParallelMergeTask(
file_path=target_file_path, # Use target path for writing
main_content=main_content,
worktree_content=worktree_content,
base_content=base_content,
spec_name=spec_name,
project_dir=project_dir,
# File exists in both - try simple 3-way merge FIRST (no AI needed)
# This handles cases where:
# - Only one side changed from base (ours==base or theirs==base)
# - Both sides made identical changes (ours==theirs)
simple_success, simple_merged = _try_simple_3way_merge(
base_content, main_content, worktree_content
)
if simple_success and simple_merged is not None:
# Simple 3-way merge succeeded - no AI needed!
simple_merges.append((target_file_path, simple_merged))
auto_merged_simple.add(target_file_path) # Track for stats
debug(
MODULE,
f" {file_path}: auto-merged (simple 3-way, no AI needed)"
+ (
f" (will write to {target_file_path})"
if target_file_path != file_path
else ""
),
)
else:
# Simple merge failed - needs AI merge
# Store the TARGET path for writing, but track original for content retrieval
files_needing_ai_merge.append(
ParallelMergeTask(
file_path=target_file_path, # Use target path for writing
main_content=main_content,
worktree_content=worktree_content,
base_content=base_content,
spec_name=spec_name,
project_dir=project_dir,
)
)
debug(
MODULE,
f" {file_path}: needs AI merge (both sides changed differently)"
+ (
f" (will write to {target_file_path})"
if target_file_path != file_path
else ""
),
)
)
debug(
MODULE,
f" {file_path}: needs AI merge"
+ (
f" (will write to {target_file_path})"
if target_file_path != file_path
else ""
),
)
except Exception as e:
print(error(f" ✗ Failed to categorize {file_path}: {e}"))
@@ -932,7 +1093,18 @@ def _resolve_git_conflicts_with_ai(
["git", "add", file_path], cwd=project_dir, capture_output=True
)
resolved_files.append(file_path)
print(success(f"{file_path} (new file)"))
# Show appropriate message based on merge type
if file_path in auto_merged_simple:
print(success(f"{file_path} (auto-merged)"))
auto_merged_count += 1 # Count for stats
elif file_path in lock_files_excluded:
print(
success(
f"{file_path} (lock file - kept main version)"
)
)
else:
print(success(f"{file_path} (new file)"))
else:
# Delete the file
target_path = project_dir / file_path
@@ -1198,6 +1370,9 @@ def _resolve_git_conflicts_with_ai(
"conflicts_resolved": len(conflicting_files) - len(remaining_conflicts),
"ai_assisted": ai_merged_count,
"auto_merged": auto_merged_count,
"simple_3way_merged": len(
auto_merged_simple
), # Files auto-merged without AI
"parallel_ai_merges": len(files_needing_ai_merge),
"lock_files_excluded": len(lock_files_excluded),
},
+5 -1
View File
@@ -58,7 +58,7 @@ def _validate_branch_name(branch: str | None) -> str | None:
return branch
def _get_base_branch_from_metadata(spec_dir: Path) -> str | None:
def get_base_branch_from_metadata(spec_dir: Path) -> str | None:
"""
Read baseBranch from task_metadata.json if it exists.
@@ -81,6 +81,10 @@ def _get_base_branch_from_metadata(spec_dir: Path) -> str | None:
return None
# Alias for backwards compatibility (internal use)
_get_base_branch_from_metadata = get_base_branch_from_metadata
def _detect_base_branch(spec_dir: Path, project_dir: Path) -> str:
"""
Detect the base branch for a project/task.
+7 -6
View File
@@ -317,17 +317,18 @@ async def run_qa_validation_loop(
issues=current_issues[:3] if current_issues else [], # Show first 3
)
# Record rejected iteration
record_iteration(
spec_dir, qa_iteration, "rejected", current_issues, iteration_duration
)
# Check for recurring issues
# Check for recurring issues BEFORE recording current iteration
# This prevents the current issues from matching themselves in history
history = get_iteration_history(spec_dir)
has_recurring, recurring_issues = has_recurring_issues(
current_issues, history
)
# Record rejected iteration AFTER checking for recurring issues
record_iteration(
spec_dir, qa_iteration, "rejected", current_issues, iteration_duration
)
if has_recurring:
from .report import RECURRING_ISSUE_THRESHOLD
@@ -3,7 +3,8 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/con
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
import path from 'path';
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
import { spawnSync } from 'child_process';
import { spawnSync, execFileSync } from 'child_process';
import { getToolPath } from '../../cli-tool-manager';
import { AgentManager } from '../../agent';
import { fileWatcher } from '../../file-watcher';
import { findTaskAndProject } from './shared';
@@ -438,14 +439,17 @@ export function registerTaskExecutionHandlers(
/**
* Update task status manually
* Options:
* - forceCleanup: When setting to 'done' with a worktree present, delete the worktree first
*/
ipcMain.handle(
IPC_CHANNELS.TASK_UPDATE_STATUS,
async (
_,
taskId: string,
status: TaskStatus
): Promise<IPCResult> => {
status: TaskStatus,
options?: { forceCleanup?: boolean }
): Promise<IPCResult & { worktreeExists?: boolean; worktreePath?: string }> => {
// Find task and project first (needed for worktree check)
const { task, project } = findTaskAndProject(taskId);
@@ -455,21 +459,80 @@ export function registerTaskExecutionHandlers(
// Validate status transition - 'done' can only be set through merge handler
// UNLESS there's no worktree (limbo state - already merged/discarded or failed)
// OR forceCleanup is requested (user confirmed they want to delete the worktree)
if (status === 'done') {
// Check if worktree exists (task.specId matches worktree folder name)
const worktreePath = findTaskWorktree(project.path, task.specId);
const hasWorktree = worktreePath !== null;
if (hasWorktree) {
// Worktree exists - must use merge workflow
console.warn(`[TASK_UPDATE_STATUS] Blocked attempt to set status 'done' directly for task ${taskId}. Use merge workflow instead.`);
return {
success: false,
error: "Cannot set status to 'done' directly. Complete the human review and merge the worktree changes instead."
};
if (options?.forceCleanup) {
// User confirmed cleanup - delete worktree and branch
console.warn(`[TASK_UPDATE_STATUS] Cleaning up worktree for task ${taskId} (user confirmed)`);
try {
// Get the branch name before removing the worktree
let branch = '';
let usingFallbackBranch = false;
try {
branch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: worktreePath,
encoding: 'utf-8',
timeout: 30000
}).trim();
} catch (branchError) {
// If we can't get branch name, use the default pattern
branch = `auto-claude/${task.specId}`;
usingFallbackBranch = true;
console.warn(`[TASK_UPDATE_STATUS] Could not get branch name, using fallback pattern: ${branch}`, branchError);
}
// Remove the worktree
execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], {
cwd: project.path,
encoding: 'utf-8',
timeout: 30000
});
console.warn(`[TASK_UPDATE_STATUS] Worktree removed: ${worktreePath}`);
// Delete the branch (ignore errors if branch doesn't exist)
try {
execFileSync(getToolPath('git'), ['branch', '-D', branch], {
cwd: project.path,
encoding: 'utf-8',
timeout: 30000
});
console.warn(`[TASK_UPDATE_STATUS] Branch deleted: ${branch}`);
} catch (branchDeleteError) {
// Branch may not exist or may be the current branch
if (usingFallbackBranch) {
// More concerning - fallback pattern didn't match actual branch
console.warn(`[TASK_UPDATE_STATUS] Could not delete branch ${branch} using fallback pattern. Actual branch may still exist and need manual cleanup.`, branchDeleteError);
} else {
console.warn(`[TASK_UPDATE_STATUS] Could not delete branch ${branch} (may not exist or be checked out elsewhere)`);
}
}
console.warn(`[TASK_UPDATE_STATUS] Worktree cleanup completed successfully`);
} catch (cleanupError) {
console.error(`[TASK_UPDATE_STATUS] Failed to cleanup worktree:`, cleanupError);
return {
success: false,
error: `Failed to cleanup worktree: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`
};
}
} else {
// Worktree exists but no forceCleanup - return special response for UI to show confirmation
console.warn(`[TASK_UPDATE_STATUS] Worktree exists for task ${taskId}. Requesting user confirmation.`);
return {
success: false,
worktreeExists: true,
worktreePath: worktreePath,
error: "A worktree still exists for this task. Would you like to delete it and mark the task as complete?"
};
}
} else {
// No worktree - allow marking as done (limbo state recovery)
console.log(`[TASK_UPDATE_STATUS] Allowing status 'done' for task ${taskId} (no worktree found - limbo state)`);
console.warn(`[TASK_UPDATE_STATUS] Allowing status 'done' for task ${taskId} (no worktree found - limbo state)`);
}
}
@@ -1656,9 +1656,19 @@ export function registerWorktreeHandlers(
// Get base branch using proper fallback chain:
// 1. Task metadata baseBranch, 2. Project settings mainBranch, 3. main/master detection
// Note: We do NOT use current HEAD as that may be a feature branch
const baseBranch = getEffectiveBaseBranch(project.path, task.specId, project.settings?.mainBranch);
// Get user's current branch in main project (this is where changes will merge INTO)
let currentProjectBranch: string | undefined;
try {
currentProjectBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: project.path,
encoding: 'utf-8'
}).trim();
} catch {
// Ignore - might be in detached HEAD or git error
}
// Get commit count (cross-platform - no shell syntax)
let commitCount = 0;
try {
@@ -1703,6 +1713,7 @@ export function registerWorktreeHandlers(
worktreePath,
branch,
baseBranch,
currentProjectBranch,
commitCount,
filesChanged,
additions,
@@ -2133,39 +2144,16 @@ export function registerWorktreeHandlers(
if (isStageOnly && !hasActualStagedChanges && mergeAlreadyCommitted) {
// Stage-only was requested but merge was already committed previously
// Mark as done since changes are already in the branch
newStatus = 'done';
planStatus = 'completed';
message = 'Changes were already merged and committed. Task marked as done.';
// Keep in human_review and let user explicitly mark as done (which will trigger cleanup confirmation)
// This ensures user is in control of when the worktree is deleted
newStatus = 'human_review';
planStatus = 'review';
message = 'Changes were already merged and committed. You can mark this task as complete when ready.';
staged = false;
debug('Stage-only requested but merge already committed. Marking as done.');
// Clean up worktree since merge is complete (fixes #243)
// This is the same cleanup as the full merge path, needed because
// stageOnly defaults to true for human_review tasks
try {
if (worktreePath && existsSync(worktreePath)) {
execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], {
cwd: project.path,
encoding: 'utf-8'
});
debug('Worktree cleaned up (already merged):', worktreePath);
// Also delete the task branch
const taskBranch = `auto-claude/${task.specId}`;
try {
execFileSync(getToolPath('git'), ['branch', '-D', taskBranch], {
cwd: project.path,
encoding: 'utf-8'
});
debug('Task branch deleted:', taskBranch);
} catch {
// Branch might not exist or already deleted
}
}
} catch (cleanupErr) {
debug('Worktree cleanup failed (non-fatal):', cleanupErr);
}
debug('Stage-only requested but merge already committed. Keeping in human_review for user to confirm completion.');
// NOTE: We intentionally do NOT auto-clean the worktree here.
// User can drag the task to "Done" column which will show a confirmation dialog
// asking if they want to delete the worktree and mark complete.
} else if (isStageOnly && !hasActualStagedChanges) {
// Stage-only was requested but no changes to stage (and not committed)
// This could mean nothing to merge or an error - keep in human_review for investigation
+52 -4
View File
@@ -32,18 +32,47 @@ export function getTaskWorktreePath(projectPath: string, specId: string): string
return path.join(projectPath, TASK_WORKTREE_DIR, specId);
}
/**
* Validate that a resolved path is within the expected base directory
* Protects against path traversal attacks (e.g., specId containing "..")
*/
function isPathWithinBase(resolvedPath: string, basePath: string): boolean {
const normalizedPath = path.resolve(resolvedPath);
const normalizedBase = path.resolve(basePath);
return normalizedPath.startsWith(normalizedBase + path.sep) || normalizedPath === normalizedBase;
}
/**
* Find a task worktree path, checking new location first then legacy
* Returns the path if found, null otherwise
* Includes path traversal protection to ensure paths stay within project
*/
export function findTaskWorktree(projectPath: string, specId: string): string | null {
const normalizedProject = path.resolve(projectPath);
// Check new path first
const newPath = path.join(projectPath, TASK_WORKTREE_DIR, specId);
if (existsSync(newPath)) return newPath;
const resolvedNewPath = path.resolve(newPath);
// Validate path stays within project (defense against path traversal)
if (!isPathWithinBase(resolvedNewPath, normalizedProject)) {
console.error(`[worktree-paths] Path traversal detected: specId "${specId}" resolves outside project`);
return null;
}
if (existsSync(resolvedNewPath)) return resolvedNewPath;
// Legacy fallback
const legacyPath = path.join(projectPath, LEGACY_WORKTREE_DIR, specId);
if (existsSync(legacyPath)) return legacyPath;
const resolvedLegacyPath = path.resolve(legacyPath);
// Validate legacy path as well
if (!isPathWithinBase(resolvedLegacyPath, normalizedProject)) {
console.error(`[worktree-paths] Path traversal detected: specId "${specId}" resolves outside project (legacy)`);
return null;
}
if (existsSync(resolvedLegacyPath)) return resolvedLegacyPath;
return null;
}
@@ -65,15 +94,34 @@ export function getTerminalWorktreePath(projectPath: string, name: string): stri
/**
* Find a terminal worktree path, checking new location first then legacy
* Returns the path if found, null otherwise
* Includes path traversal protection to ensure paths stay within project
*/
export function findTerminalWorktree(projectPath: string, name: string): string | null {
const normalizedProject = path.resolve(projectPath);
// Check new path first
const newPath = path.join(projectPath, TERMINAL_WORKTREE_DIR, name);
if (existsSync(newPath)) return newPath;
const resolvedNewPath = path.resolve(newPath);
// Validate path stays within project (defense against path traversal)
if (!isPathWithinBase(resolvedNewPath, normalizedProject)) {
console.error(`[worktree-paths] Path traversal detected: name "${name}" resolves outside project`);
return null;
}
if (existsSync(resolvedNewPath)) return resolvedNewPath;
// Legacy fallback (terminal worktrees used terminal-{name} prefix)
const legacyPath = path.join(projectPath, LEGACY_WORKTREE_DIR, `terminal-${name}`);
if (existsSync(legacyPath)) return legacyPath;
const resolvedLegacyPath = path.resolve(legacyPath);
// Validate legacy path as well
if (!isPathWithinBase(resolvedLegacyPath, normalizedProject)) {
console.error(`[worktree-paths] Path traversal detected: name "${name}" resolves outside project (legacy)`);
return null;
}
if (existsSync(resolvedLegacyPath)) return resolvedLegacyPath;
return null;
}
+7 -5
View File
@@ -39,8 +39,9 @@ export interface TaskAPI {
) => Promise<IPCResult>;
updateTaskStatus: (
taskId: string,
status: TaskStatus
) => Promise<IPCResult>;
status: TaskStatus,
options?: { forceCleanup?: boolean }
) => Promise<IPCResult & { worktreeExists?: boolean; worktreePath?: string }>;
recoverStuckTask: (
taskId: string,
options?: import('../../shared/types').TaskRecoveryOptions
@@ -117,9 +118,10 @@ export const createTaskAPI = (): TaskAPI => ({
updateTaskStatus: (
taskId: string,
status: TaskStatus
): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE_STATUS, taskId, status),
status: TaskStatus,
options?: { forceCleanup?: boolean }
): Promise<IPCResult & { worktreeExists?: boolean; worktreePath?: string }> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE_STATUS, taskId, status, options),
recoverStuckTask: (
taskId: string,
@@ -19,23 +19,17 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, Trash2, FolderCheck } from 'lucide-react';
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/alert-dialog';
import { TaskCard } from './TaskCard';
import { SortableTaskCard } from './SortableTaskCard';
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
import { cn } from '../lib/utils';
import { persistTaskStatus, archiveTasks } from '../stores/task-store';
import { persistTaskStatus, forceCompleteTask, archiveTasks } from '../stores/task-store';
import { useToast } from '../hooks/use-toast';
import { WorktreeCleanupDialog } from './WorktreeCleanupDialog';
import type { Task, TaskStatus } from '../../shared/types';
// Type guard for valid drop column targets - preserves literal type from TASK_STATUS_COLUMNS
@@ -339,15 +333,28 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
}, droppableColumnPropsAreEqual);
export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isRefreshing }: KanbanBoardProps) {
const { t } = useTranslation('tasks');
const { t } = useTranslation(['tasks', 'dialogs', 'common']);
const { toast } = useToast();
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [overColumnId, setOverColumnId] = useState<string | null>(null);
const { showArchived, toggleShowArchived } = useViewState();
// Worktree cleanup dialog state
const [worktreeDialogOpen, setWorktreeDialogOpen] = useState(false);
const [pendingDoneTask, setPendingDoneTask] = useState<Task | null>(null);
const [isCleaningUp, setIsCleaningUp] = useState(false);
const [worktreeCleanupDialog, setWorktreeCleanupDialog] = useState<{
open: boolean;
taskId: string | null;
taskTitle: string;
worktreePath?: string;
isProcessing: boolean;
error?: string;
}>({
open: false,
taskId: null,
taskTitle: '',
worktreePath: undefined,
isProcessing: false,
error: undefined
});
// Calculate archived count for Done column button
const archivedCount = useMemo(() =>
@@ -452,39 +459,66 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
}
};
// Check if a task has a worktree (async check)
const checkTaskHasWorktree = async (taskId: string): Promise<boolean> => {
try {
const result = await window.electronAPI.getWorktreeStatus(taskId);
return result.success && result.data?.exists === true;
} catch {
return false;
}
};
/**
* Handle status change with worktree cleanup dialog support
* Consolidated handler that accepts an optional task object for the dialog title
*/
const handleStatusChange = async (taskId: string, newStatus: TaskStatus, providedTask?: Task) => {
const task = providedTask || tasks.find(t => t.id === taskId);
const result = await persistTaskStatus(taskId, newStatus);
// Handle moving task to done with worktree cleanup option
const handleMoveToDone = async (task: Task, deleteWorktree: boolean) => {
setIsCleaningUp(true);
try {
if (deleteWorktree) {
// Delete worktree first, skip automatic status change to backlog
// since we're about to set status to 'done'
const result = await window.electronAPI.discardWorktree(task.id, true);
if (!result.success) {
console.error('Failed to delete worktree:', result.error);
// Continue anyway - user can clean up manually
}
if (!result.success) {
if (result.worktreeExists) {
// Show the worktree cleanup dialog
setWorktreeCleanupDialog({
open: true,
taskId: taskId,
taskTitle: task?.title || t('tasks:untitled'),
worktreePath: result.worktreePath,
isProcessing: false,
error: undefined
});
} else {
// Show error toast for other failures
toast({
title: t('common:errors.operationFailed'),
description: result.error || t('common:errors.unknownError'),
variant: 'destructive'
});
}
// Mark as done
await persistTaskStatus(task.id, 'done');
} finally {
setIsCleaningUp(false);
setWorktreeDialogOpen(false);
setPendingDoneTask(null);
}
};
const handleDragEnd = async (event: DragEndEvent) => {
/**
* Handle worktree cleanup confirmation
*/
const handleWorktreeCleanupConfirm = async () => {
if (!worktreeCleanupDialog.taskId) return;
setWorktreeCleanupDialog(prev => ({ ...prev, isProcessing: true, error: undefined }));
const result = await forceCompleteTask(worktreeCleanupDialog.taskId);
if (result.success) {
setWorktreeCleanupDialog({
open: false,
taskId: null,
taskTitle: '',
worktreePath: undefined,
isProcessing: false,
error: undefined
});
} else {
// Keep dialog open with error state for retry - show actual error if available
setWorktreeCleanupDialog(prev => ({
...prev,
isProcessing: false,
error: result.error || t('dialogs:worktreeCleanup.errorDescription')
}));
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveTask(null);
setOverColumnId(null);
@@ -494,38 +528,31 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
const activeTaskId = active.id as string;
const overId = over.id as string;
// Determine target status
let targetStatus: TaskStatus | null = null;
// Check if dropped on a column
if (isValidDropColumn(overId)) {
targetStatus = overId;
} else {
// Dropped on another task - get its column
const overTask = tasks.find((t) => t.id === overId);
if (overTask) {
targetStatus = overTask.status;
const newStatus = overId;
const task = tasks.find((t) => t.id === activeTaskId);
if (task && task.status !== newStatus) {
// Persist status change to file and update local state
handleStatusChange(activeTaskId, newStatus, task).catch((err) =>
console.error('[KanbanBoard] Status change failed:', err)
);
}
return;
}
if (!targetStatus) return;
const task = tasks.find((t) => t.id === activeTaskId);
if (!task || task.status === targetStatus) return;
// Special handling for moving to "done" - check for worktree
if (targetStatus === 'done') {
const hasWorktree = await checkTaskHasWorktree(task.id);
if (hasWorktree) {
// Show dialog asking about worktree cleanup
setPendingDoneTask(task);
setWorktreeDialogOpen(true);
return;
// Check if dropped on another task - move to that task's column
const overTask = tasks.find((t) => t.id === overId);
if (overTask) {
const task = tasks.find((t) => t.id === activeTaskId);
if (task && task.status !== overTask.status) {
// Persist status change to file and update local state
handleStatusChange(activeTaskId, overTask.status, task).catch((err) =>
console.error('[KanbanBoard] Status change failed:', err)
);
}
}
// Normal status change
persistTaskStatus(activeTaskId, targetStatus);
};
return (
@@ -560,7 +587,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
status={status}
tasks={tasksByStatus[status]}
onTaskClick={onTaskClick}
onStatusChange={persistTaskStatus}
onStatusChange={handleStatusChange}
isOver={overColumnId === status}
onAddClick={status === 'backlog' ? onNewTaskClick : undefined}
onArchiveAll={status === 'done' ? handleArchiveAll : undefined}
@@ -582,71 +609,19 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
</DndContext>
{/* Worktree cleanup confirmation dialog */}
<AlertDialog open={worktreeDialogOpen} onOpenChange={setWorktreeDialogOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<FolderCheck className="h-5 w-5 text-primary" />
{t('kanban.worktreeCleanupTitle', 'Worktree Cleanup')}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-left space-y-2">
{pendingDoneTask?.stagedInMainProject ? (
<p>
{t('kanban.worktreeCleanupStaged', 'This task has been staged and has a worktree. Would you like to clean up the worktree?')}
</p>
) : (
<p>
{t('kanban.worktreeCleanupNotStaged', 'This task has a worktree with changes that have not been merged. Delete the worktree to mark as done, or cancel to review the changes first.')}
</p>
)}
{pendingDoneTask && (
<p className="text-sm font-medium text-foreground/80 bg-muted/50 rounded px-2 py-1.5">
{pendingDoneTask.title}
</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={() => {
setWorktreeDialogOpen(false);
setPendingDoneTask(null);
}}
disabled={isCleaningUp}
>
{t('common:cancel', 'Cancel')}
</Button>
{/* Only show "Keep Worktree" option if task is staged */}
{pendingDoneTask?.stagedInMainProject && (
<Button
variant="secondary"
onClick={() => pendingDoneTask && handleMoveToDone(pendingDoneTask, false)}
disabled={isCleaningUp}
className="gap-2"
>
<FolderCheck className="h-4 w-4" />
{t('kanban.keepWorktree', 'Keep Worktree')}
</Button>
)}
<Button
variant={pendingDoneTask?.stagedInMainProject ? 'default' : 'destructive'}
onClick={() => pendingDoneTask && handleMoveToDone(pendingDoneTask, true)}
disabled={isCleaningUp}
className="gap-2"
>
{isCleaningUp ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{t('kanban.deleteWorktree', 'Delete Worktree & Mark Done')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<WorktreeCleanupDialog
open={worktreeCleanupDialog.open}
taskTitle={worktreeCleanupDialog.taskTitle}
worktreePath={worktreeCleanupDialog.worktreePath}
isProcessing={worktreeCleanupDialog.isProcessing}
error={worktreeCleanupDialog.error}
onOpenChange={(open) => {
if (!open && !worktreeCleanupDialog.isProcessing) {
setWorktreeCleanupDialog(prev => ({ ...prev, open: false, error: undefined }));
}
}}
onConfirm={handleWorktreeCleanupConfirm}
/>
</div>
);
}
@@ -348,7 +348,14 @@ export function TaskCreationWizard({
if (images.length > 0) metadata.attachedImages = images;
if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles;
if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true;
if (baseBranch && baseBranch !== PROJECT_DEFAULT_BRANCH) metadata.baseBranch = baseBranch;
// Always include baseBranch - resolve PROJECT_DEFAULT_BRANCH to actual branch name
// This ensures the backend always knows which branch to use for worktree creation
if (baseBranch === PROJECT_DEFAULT_BRANCH) {
// Use the resolved project default branch
if (projectDefaultBranch) metadata.baseBranch = projectDefaultBranch;
} else if (baseBranch) {
metadata.baseBranch = baseBranch;
}
// Pass worktree preference - false means use --direct mode
if (!useWorktree) metadata.useWorktree = false;
@@ -0,0 +1,112 @@
import { useTranslation, Trans } from 'react-i18next';
import { AlertCircle, CheckCircle2, FolderX, Loader2, RefreshCw } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/alert-dialog';
interface WorktreeCleanupDialogProps {
open: boolean;
taskTitle: string;
worktreePath?: string;
isProcessing: boolean;
error?: string;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
/**
* Confirmation dialog for cleaning up worktree when marking task as done
*/
export function WorktreeCleanupDialog({
open,
taskTitle,
worktreePath,
isProcessing,
error,
onOpenChange,
onConfirm
}: WorktreeCleanupDialogProps) {
const { t } = useTranslation(['dialogs', 'common']);
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{error ? (
<AlertCircle className="h-5 w-5 text-destructive" />
) : (
<CheckCircle2 className="h-5 w-5 text-success" />
)}
{error ? t('dialogs:worktreeCleanup.errorTitle') : t('dialogs:worktreeCleanup.title')}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-sm text-muted-foreground space-y-3">
{error ? (
<p className="text-destructive">{error}</p>
) : (
<>
<p>
<Trans
i18nKey="dialogs:worktreeCleanup.hasWorktree"
values={{ taskTitle }}
components={{ strong: <strong className="text-foreground" /> }}
/>
</p>
<p>
{t('dialogs:worktreeCleanup.willDelete')}
</p>
</>
)}
{worktreePath && (
<div className="bg-muted/50 rounded-lg p-3 font-mono text-xs break-all">
{worktreePath}
</div>
)}
{!error && (
<p className="text-amber-600 dark:text-amber-500">
{t('dialogs:worktreeCleanup.warning')}
</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isProcessing}>{t('common:buttons.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={isProcessing}
className={error ? "bg-primary text-primary-foreground hover:bg-primary/90" : "bg-success text-success-foreground hover:bg-success/90"}
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('dialogs:worktreeCleanup.completing')}
</>
) : error ? (
<>
<RefreshCw className="mr-2 h-4 w-4" />
{t('dialogs:worktreeCleanup.retry')}
</>
) : (
<>
<FolderX className="mr-2 h-4 w-4" />
{t('dialogs:worktreeCleanup.confirm')}
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -192,12 +192,12 @@ export function WorkspaceStatus({
</span>
</div>
{/* Branch info */}
{/* Branch info: spec branch → user's current branch (merge target) */}
{worktreeStatus.branch && (
<div className="mt-2 text-xs text-muted-foreground">
<code className="bg-background/80 px-1.5 py-0.5 rounded text-[11px]">{worktreeStatus.branch}</code>
<span className="mx-1.5"></span>
<code className="bg-background/80 px-1.5 py-0.5 rounded text-[11px]">{worktreeStatus.baseBranch || 'main'}</code>
<code className="bg-background/80 px-1.5 py-0.5 rounded text-[11px]">{worktreeStatus.currentProjectBranch || worktreeStatus.baseBranch || 'main'}</code>
</div>
)}
@@ -6,7 +6,7 @@
* Unit tests for useXterm keyboard handlers
* Tests terminal copy/paste keyboard shortcuts and platform detection
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import type { Mock } from 'vitest';
import { renderHook, act, render } from '@testing-library/react';
import React from 'react';
@@ -61,8 +61,9 @@ vi.mock('../../../../lib/terminal-buffer-manager', () => ({
// Mock navigator.platform for platform detection
const originalNavigatorPlatform = navigator.platform;
// Mock requestAnimationFrame for jsdom environment (not provided by default)
global.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => setTimeout(cb, 0) as unknown as number);
// Store original requestAnimationFrame for restoration after tests
const originalRequestAnimationFrame = global.requestAnimationFrame;
const originalCancelAnimationFrame = global.cancelAnimationFrame;
/**
* Helper function to set up XTerm mocks and render the hook
@@ -152,7 +153,22 @@ describe('useXterm keyboard handlers', () => {
readText: ReturnType<typeof vi.fn>;
};
// Mock requestAnimationFrame for jsdom environment (not provided by default)
// Isolated to this test file to prevent affecting other tests
beforeAll(() => {
global.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => setTimeout(cb, 0) as unknown as number);
global.cancelAnimationFrame = vi.fn((id: number) => clearTimeout(id));
});
afterAll(() => {
global.requestAnimationFrame = originalRequestAnimationFrame;
global.cancelAnimationFrame = originalCancelAnimationFrame;
});
beforeEach(() => {
// Use fake timers to control async behavior and prevent timer leaks
vi.useFakeTimers();
// Clear all mocks before each test
vi.clearAllMocks();
@@ -183,6 +199,11 @@ describe('useXterm keyboard handlers', () => {
});
afterEach(() => {
// Clear all pending timers before restoring mocks to prevent
// "requestAnimationFrame is not defined" errors from delayed callbacks
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
// Reset navigator.platform to original value
Object.defineProperty(navigator, 'platform', {
@@ -211,7 +232,7 @@ describe('useXterm keyboard handlers', () => {
});
keyEventHandler(event);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
// Windows should enable CTRL+V paste
@@ -238,7 +259,7 @@ describe('useXterm keyboard handlers', () => {
});
keyEventHandler(event);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockPaste).toHaveBeenCalledTimes(1);
@@ -252,7 +273,7 @@ describe('useXterm keyboard handlers', () => {
});
keyEventHandler(event);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockPaste).toHaveBeenCalledTimes(2);
@@ -282,7 +303,7 @@ describe('useXterm keyboard handlers', () => {
});
keyEventHandler(event);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockClipboard.writeText).toHaveBeenCalledTimes(1);
@@ -296,7 +317,7 @@ describe('useXterm keyboard handlers', () => {
});
keyEventHandler(event);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockClipboard.writeText).toHaveBeenCalledTimes(2);
@@ -321,7 +342,7 @@ describe('useXterm keyboard handlers', () => {
});
keyEventHandler(event);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
// macOS should NOT use custom CTRL+V handler (uses system Cmd+V instead)
@@ -352,7 +373,7 @@ describe('useXterm keyboard handlers', () => {
expect(handled).toBe(false); // Should prevent xterm handling
// Wait for clipboard write
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
// Verify the xterm instance methods were called
@@ -408,7 +429,7 @@ describe('useXterm keyboard handlers', () => {
if (keyEventHandler) {
keyEventHandler!(event);
// Wait for clipboard write
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
}
});
@@ -423,7 +444,7 @@ describe('useXterm keyboard handlers', () => {
if (keyEventHandler) {
keyEventHandler!(event);
// Wait for clipboard write
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
}
});
@@ -455,7 +476,7 @@ describe('useXterm keyboard handlers', () => {
expect(handled).toBe(false); // Should prevent literal ^V
// Wait for clipboard read and paste
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
}
});
@@ -484,7 +505,7 @@ describe('useXterm keyboard handlers', () => {
const handled = keyEventHandler(event);
expect(handled).toBe(false);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockClipboard.readText).toHaveBeenCalled();
@@ -546,7 +567,7 @@ describe('useXterm keyboard handlers', () => {
const handled = keyEventHandler(event);
expect(handled).toBe(false);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockClipboard.writeText).toHaveBeenCalledWith('selected text');
@@ -603,7 +624,7 @@ describe('useXterm keyboard handlers', () => {
const handled = keyEventHandler(event);
expect(handled).toBe(false);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockClipboard.readText).toHaveBeenCalled();
@@ -632,7 +653,7 @@ describe('useXterm keyboard handlers', () => {
});
keyEventHandler(event);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
// Should log error but not throw
@@ -666,7 +687,7 @@ describe('useXterm keyboard handlers', () => {
});
keyEventHandler(event);
await new Promise(resolve => setTimeout(resolve, 0));
await vi.advanceTimersByTimeAsync(0);
});
// Should log error but not throw
@@ -60,7 +60,7 @@ export const taskMock = {
unarchiveTasks: async () => ({ success: true, data: true }),
// Task status operations
updateTaskStatus: async () => ({ success: true }),
updateTaskStatus: async (_taskId: string, _status: string, _options?: { forceCleanup?: boolean }) => ({ success: true }),
recoverStuckTask: async (taskId: string, options?: TaskRecoveryOptions) => ({
success: true,
@@ -451,32 +451,64 @@ export async function submitReview(
}
}
/**
* Result type for persistTaskStatus with worktree info
*/
export interface PersistStatusResult {
success: boolean;
worktreeExists?: boolean;
worktreePath?: string;
error?: string;
}
/**
* Update task status and persist to file
* Returns additional info if a worktree exists and needs cleanup confirmation
*/
export async function persistTaskStatus(
taskId: string,
status: TaskStatus
): Promise<boolean> {
status: TaskStatus,
options?: { forceCleanup?: boolean }
): Promise<PersistStatusResult> {
const store = useTaskStore.getState();
try {
// Update local state first for immediate feedback
store.updateTaskStatus(taskId, status);
// Persist to file first (don't optimistically update for 'done' status)
const result = await window.electronAPI.updateTaskStatus(taskId, status, options);
// Persist to file
const result = await window.electronAPI.updateTaskStatus(taskId, status);
if (!result.success) {
// Check if this is a worktree exists case
if (result.worktreeExists) {
console.log('[persistTaskStatus] Worktree exists, confirmation needed');
return {
success: false,
worktreeExists: true,
worktreePath: result.worktreePath,
error: result.error
};
}
console.error('Failed to persist task status:', result.error);
return false;
return { success: false, error: result.error };
}
return true;
// Only update local state after backend confirms success
store.updateTaskStatus(taskId, status);
return { success: true };
} catch (error) {
console.error('Error persisting task status:', error);
return false;
return { success: false, error: String(error) };
}
}
/**
* Force complete a task by cleaning up its worktree
* Used when user confirms they want to delete the worktree and mark as done
* Returns full result including error details for better UX
*/
export async function forceCompleteTask(taskId: string): Promise<PersistStatusResult> {
return persistTaskStatus(taskId, 'done', { forceCleanup: true });
}
/**
* Update task title/description/metadata and persist to file
*/
@@ -101,6 +101,7 @@
"errors": {
"generic": "An error occurred",
"unknownError": "An unknown error occurred",
"operationFailed": "Operation failed",
"networkError": "Network error",
"notFound": "Not found",
"unauthorized": "Unauthorized"
@@ -56,6 +56,17 @@
"delete": "Delete Worktree?",
"deleteDescription": "This will permanently delete the worktree and all uncommitted changes. This action cannot be undone."
},
"worktreeCleanup": {
"title": "Complete Task",
"hasWorktree": "The task <strong>\"{{taskTitle}}\"</strong> still has an isolated workspace (worktree).",
"willDelete": "To mark this task as complete, the worktree and its associated branch will be deleted.",
"warning": "Make sure you have merged or saved any changes you want to keep before proceeding.",
"confirm": "Delete Worktree & Complete",
"completing": "Completing...",
"retry": "Try Again",
"errorTitle": "Cleanup Failed",
"errorDescription": "Failed to cleanup worktree. Please try again."
},
"update": {
"title": "Auto Claude",
"projectInitialized": "Project is initialized."
@@ -101,6 +101,7 @@
"errors": {
"generic": "Une erreur s'est produite",
"unknownError": "Une erreur inconnue s'est produite",
"operationFailed": "Opération échouée",
"networkError": "Erreur réseau",
"notFound": "Non trouvé",
"unauthorized": "Non autorisé"
@@ -56,6 +56,17 @@
"delete": "Supprimer le worktree ?",
"deleteDescription": "Ceci supprimera définitivement le worktree et toutes les modifications non committées. Cette action est irréversible."
},
"worktreeCleanup": {
"title": "Terminer la tâche",
"hasWorktree": "La tâche <strong>\"{{taskTitle}}\"</strong> a encore un espace de travail isolé (worktree).",
"willDelete": "Pour marquer cette tâche comme terminée, le worktree et sa branche associée seront supprimés.",
"warning": "Assurez-vous d'avoir fusionné ou sauvegardé les modifications que vous souhaitez conserver avant de continuer.",
"confirm": "Supprimer le worktree et terminer",
"completing": "Finalisation...",
"retry": "Réessayer",
"errorTitle": "Échec du nettoyage",
"errorDescription": "Échec du nettoyage du worktree. Veuillez réessayer."
},
"update": {
"title": "Auto Claude",
"projectInitialized": "Le projet est initialisé."
+1 -1
View File
@@ -159,7 +159,7 @@ export interface ElectronAPI {
startTask: (taskId: string, options?: TaskStartOptions) => void;
stopTask: (taskId: string) => void;
submitReview: (taskId: string, approved: boolean, feedback?: string) => Promise<IPCResult>;
updateTaskStatus: (taskId: string, status: TaskStatus) => Promise<IPCResult>;
updateTaskStatus: (taskId: string, status: TaskStatus, options?: { forceCleanup?: boolean }) => Promise<IPCResult & { worktreeExists?: boolean; worktreePath?: string }>;
recoverStuckTask: (taskId: string, options?: TaskRecoveryOptions) => Promise<IPCResult<TaskRecoveryResult>>;
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
+1
View File
@@ -301,6 +301,7 @@ export interface WorktreeStatus {
worktreePath?: string;
branch?: string;
baseBranch?: string;
currentProjectBranch?: string; // User's current checked-out branch in main project (merge target)
commitCount?: number;
filesChanged?: number;
additions?: number;