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:
+59
-44
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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é."
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user