ACS-103 Windows can finish a task (#739)

* ACS-103 Windows can finish a task

* show toast running in bg

* fix comments

* fix lint

* fix lint

* fix comment

* fix(windows): complete run_git migration and address code review findings

- Migrate all subprocess.run git calls in git_utils.py to run_git() helper
  for consistent Windows compatibility (8 functions updated)
- Add __all__ export list to git_utils.py for explicit re-exports
- Fix Windows path detection regex to avoid false positives on escape
  sequences (\n, \t, etc.) by requiring 2+ character path components
- Add i18n translations for workspace isolation UI strings in
  TaskCreationWizard (en/fr)

The run_git helper properly finds the git executable on Windows using
multiple fallback strategies, ensuring consistent behavior across platforms.

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

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

* style: fix ruff formatting in parser.py

Use double quotes for regex string per project style conventions.

🤖 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>
This commit is contained in:
Alex
2026-01-07 10:38:32 +01:00
committed by GitHub
parent 5e783908e3
commit 96b7eb4a3e
16 changed files with 527 additions and 128 deletions
+20 -24
View File
@@ -8,42 +8,38 @@ Helper functions for git operations, plan management, and file syncing.
import json
import logging
import shutil
import subprocess
from pathlib import Path
from core.git_executable import run_git
logger = logging.getLogger(__name__)
def get_latest_commit(project_dir: Path) -> str | None:
"""Get the hash of the latest git commit."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
timeout=10,
)
result = run_git(
["rev-parse", "HEAD"],
cwd=project_dir,
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
return None
return None
def get_commit_count(project_dir: Path) -> int:
"""Get the total number of commits."""
try:
result = subprocess.run(
["git", "rev-list", "--count", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
timeout=10,
)
return int(result.stdout.strip())
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
return 0
result = run_git(
["rev-list", "--count", "HEAD"],
cwd=project_dir,
timeout=10,
)
if result.returncode == 0:
try:
return int(result.stdout.strip())
except ValueError:
return 0
return 0
def load_implementation_plan(spec_dir: Path) -> dict | None:
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
Git Executable Finder
======================
Utility to find the git executable, with Windows-specific fallbacks.
Separated into its own module to avoid circular imports.
"""
import os
import shutil
import subprocess
from pathlib import Path
_cached_git_path: str | None = None
def get_git_executable() -> str:
"""Find the git executable, with Windows-specific fallbacks.
Returns the path to git executable. On Windows, checks multiple sources:
1. CLAUDE_CODE_GIT_BASH_PATH env var (set by Electron frontend)
2. shutil.which (if git is in PATH)
3. Common installation locations
4. Windows 'where' command
Caches the result after first successful find.
"""
global _cached_git_path
# Return cached result if available
if _cached_git_path is not None:
return _cached_git_path
git_path = _find_git_executable()
_cached_git_path = git_path
return git_path
def _find_git_executable() -> str:
"""Internal function to find git executable."""
# 1. Check CLAUDE_CODE_GIT_BASH_PATH (set by Electron frontend)
# This env var points to bash.exe, we can derive git.exe from it
bash_path = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
if bash_path:
try:
bash_path_obj = Path(bash_path)
if bash_path_obj.exists():
git_dir = bash_path_obj.parent.parent
# Try cmd/git.exe first (preferred), then bin/git.exe
for git_subpath in ["cmd/git.exe", "bin/git.exe"]:
git_path = git_dir / git_subpath
if git_path.is_file():
return str(git_path)
except (OSError, ValueError):
pass
# 2. Try shutil.which (works if git is in PATH)
git_path = shutil.which("git")
if git_path:
return git_path
# 3. Windows-specific: check common installation locations
if os.name == "nt":
common_paths = [
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
r"C:\Program Files\Git\cmd\git.exe",
r"C:\Program Files (x86)\Git\cmd\git.exe",
]
for path in common_paths:
try:
if os.path.isfile(path):
return path
except OSError:
continue
# 4. Try 'where' command with shell=True (more reliable on Windows)
try:
result = subprocess.run(
"where git",
capture_output=True,
text=True,
timeout=5,
shell=True,
)
if result.returncode == 0 and result.stdout.strip():
found_path = result.stdout.strip().split("\n")[0].strip()
if found_path and os.path.isfile(found_path):
return found_path
except (subprocess.TimeoutExpired, OSError):
pass
# Default fallback - let subprocess handle it (may fail)
return "git"
def run_git(
args: list[str],
cwd: Path | str | None = None,
timeout: int = 60,
input_data: str | None = None,
) -> subprocess.CompletedProcess:
"""Run a git command with proper executable finding.
Args:
args: Git command arguments (without 'git' prefix)
cwd: Working directory for the command
timeout: Command timeout in seconds (default: 60)
input_data: Optional string data to pass to stdin
Returns:
CompletedProcess with command results.
"""
git = get_git_executable()
try:
return subprocess.run(
[git] + args,
cwd=cwd,
input=input_data,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
args=[git] + args,
returncode=-1,
stdout="",
stderr=f"Command timed out after {timeout} seconds",
)
except FileNotFoundError:
return subprocess.CompletedProcess(
args=[git] + args,
returncode=-1,
stdout="",
stderr="Git executable not found. Please ensure git is installed and in PATH.",
)
+66 -45
View File
@@ -10,6 +10,45 @@ import json
import subprocess
from pathlib import Path
from core.git_executable import get_git_executable, run_git
__all__ = [
# Exported helpers
"get_git_executable",
"run_git",
# Constants
"MAX_FILE_LINES_FOR_AI",
"MAX_PARALLEL_AI_MERGES",
"LOCK_FILES",
"BINARY_EXTENSIONS",
"MERGE_LOCK_TIMEOUT",
"MAX_SYNTAX_FIX_RETRIES",
# Functions
"detect_file_renames",
"apply_path_mapping",
"get_merge_base",
"has_uncommitted_changes",
"get_current_branch",
"get_existing_build_worktree",
"get_file_content_from_ref",
"get_binary_file_content_from_ref",
"get_changed_files_from_branch",
"is_process_running",
"is_binary_file",
"is_lock_file",
"validate_merged_syntax",
"create_conflict_file_with_git",
# Backward compat aliases
"_is_process_running",
"_is_binary_file",
"_is_lock_file",
"_validate_merged_syntax",
"_get_file_content_from_ref",
"_get_binary_file_content_from_ref",
"_get_changed_files_from_branch",
"_create_conflict_file_with_git",
]
# Constants for merge limits
MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this
MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations
@@ -150,9 +189,8 @@ def detect_file_renames(
# -M flag enables rename detection
# --diff-filter=R shows only renames
# --name-status shows status and file names
result = subprocess.run(
result = run_git(
[
"git",
"log",
"--name-status",
"-M",
@@ -161,8 +199,6 @@ def detect_file_renames(
f"{from_ref}..{to_ref}",
],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
@@ -212,39 +248,21 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
Returns:
Merge-base commit hash, or None if not found
"""
try:
result = subprocess.run(
["git", "merge-base", ref1, ref2],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
result = run_git(["merge-base", ref1, ref2], cwd=project_dir)
if result.returncode == 0:
return result.stdout.strip()
return None
def has_uncommitted_changes(project_dir: Path) -> bool:
"""Check if user has unsaved work."""
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=project_dir,
capture_output=True,
text=True,
)
result = run_git(["status", "--porcelain"], cwd=project_dir)
return bool(result.stdout.strip())
def get_current_branch(project_dir: Path) -> str:
"""Get the current branch name."""
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir)
return result.stdout.strip()
@@ -276,12 +294,7 @@ def get_file_content_from_ref(
project_dir: Path, ref: str, file_path: str
) -> str | None:
"""Get file content from a git ref (branch, commit, etc.)."""
result = subprocess.run(
["git", "show", f"{ref}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
)
result = run_git(["show", f"{ref}:{file_path}"], cwd=project_dir)
if result.returncode == 0:
return result.stdout
return None
@@ -294,9 +307,13 @@ def get_binary_file_content_from_ref(
Unlike get_file_content_from_ref, this returns raw bytes without
text decoding, suitable for binary files like images, audio, etc.
Note: Uses subprocess directly with get_git_executable() since
run_git() always returns text output.
"""
git = get_git_executable()
result = subprocess.run(
["git", "show", f"{ref}:{file_path}"],
[git, "show", f"{ref}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=False, # Return bytes, not text
@@ -324,11 +341,9 @@ def get_changed_files_from_branch(
Returns:
List of (file_path, status) tuples
"""
result = subprocess.run(
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
result = run_git(
["diff", "--name-status", f"{base_branch}...{spec_branch}"],
cwd=project_dir,
capture_output=True,
text=True,
)
files = []
@@ -345,15 +360,23 @@ def get_changed_files_from_branch(
return files
def _normalize_path(path: str) -> str:
"""Normalize path separators to forward slashes for cross-platform comparison."""
return path.replace("\\", "/")
def _is_auto_claude_file(file_path: str) -> bool:
"""Check if a file is in the .auto-claude or auto-claude/specs directory."""
# These patterns cover the internal spec/build files that shouldn't be merged
"""Check if a file is in the .auto-claude or auto-claude/specs directory.
Handles both forward slashes (Unix/Git output) and backslashes (Windows).
"""
normalized = _normalize_path(file_path)
excluded_patterns = [
".auto-claude/",
"auto-claude/specs/",
]
for pattern in excluded_patterns:
if file_path.startswith(pattern):
if normalized.startswith(pattern):
return True
return False
@@ -547,11 +570,9 @@ def create_conflict_file_with_git(
try:
# git merge-file <current> <base> <other>
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
result = subprocess.run(
["git", "merge-file", "-p", main_path, base_path, wt_path],
result = run_git(
["merge-file", "-p", main_path, base_path, wt_path],
cwd=project_dir,
capture_output=True,
text=True,
)
# Read the merged content
+3 -5
View File
@@ -8,10 +8,10 @@ Functions for setting up and initializing workspaces.
import json
import shutil
import subprocess
import sys
from pathlib import Path
from core.git_executable import run_git
from merge import FileTimelineTracker
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
from ui import (
@@ -406,11 +406,9 @@ def initialize_timeline_tracking(
files_to_modify.extend(subtask.get("files", []))
# Get the current branch point commit
result = subprocess.run(
["git", "rev-parse", "HEAD"],
result = run_git(
["rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
branch_point = result.stdout.strip() if result.returncode == 0 else None
+15 -44
View File
@@ -23,6 +23,8 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from core.git_executable import run_git
class WorktreeError(Exception):
"""Error during worktree operations."""
@@ -77,13 +79,9 @@ class WorktreeManager:
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = subprocess.run(
["git", "rev-parse", "--verify", env_branch],
result = run_git(
["rev-parse", "--verify", env_branch],
cwd=self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode == 0:
return env_branch
@@ -94,13 +92,9 @@ class WorktreeManager:
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = subprocess.run(
["git", "rev-parse", "--verify", branch],
result = run_git(
["rev-parse", "--verify", branch],
cwd=self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode == 0:
return branch
@@ -114,13 +108,9 @@ class WorktreeManager:
def _get_current_branch(self) -> str:
"""Get the current git branch."""
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
result = run_git(
["rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
raise WorktreeError(f"Failed to get current branch: {result.stderr}")
@@ -140,24 +130,7 @@ class WorktreeManager:
CompletedProcess with command results. On timeout, returns a
CompletedProcess with returncode=-1 and timeout error in stderr.
"""
try:
return subprocess.run(
["git"] + args,
cwd=cwd or self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
# Return a failed result on timeout instead of raising
return subprocess.CompletedProcess(
args=["git"] + args,
returncode=-1,
stdout="",
stderr=f"Command timed out after {timeout} seconds",
)
return run_git(args, cwd=cwd or self.project_dir, timeout=timeout)
def _unstage_gitignored_files(self) -> None:
"""
@@ -180,14 +153,10 @@ class WorktreeManager:
# 1. Check which staged files are gitignored
# git check-ignore returns the files that ARE ignored
result = subprocess.run(
["git", "check-ignore", "--stdin"],
result = run_git(
["check-ignore", "--stdin"],
cwd=self.project_dir,
input="\n".join(staged_files),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
input_data="\n".join(staged_files),
)
if result.stdout.strip():
@@ -202,8 +171,10 @@ class WorktreeManager:
file = file.strip()
if not file:
continue
# Normalize path separators for cross-platform (Windows backslash support)
normalized = file.replace("\\", "/")
for pattern in auto_claude_patterns:
if file.startswith(pattern) or f"/{pattern}" in file:
if normalized.startswith(pattern) or f"/{pattern}" in normalized:
files_to_unstage.add(file)
break
+16
View File
@@ -200,9 +200,21 @@ Examples:
default=None,
help="Base branch for creating worktrees (default: auto-detect or current branch)",
)
parser.add_argument(
"--direct",
action="store_true",
help="Build directly in project without worktree isolation (default: use isolated worktree)",
)
args = parser.parse_args()
# Warn user about direct mode risks
if args.direct:
print_status(
"Direct mode: Building in project directory without worktree isolation",
"warning",
)
# Handle task from file if provided
task_description = args.task
if args.task_file:
@@ -330,6 +342,10 @@ Examples:
if args.base_branch:
run_cmd.extend(["--base-branch", args.base_branch])
# Pass --direct flag if specified (skip worktree isolation)
if args.direct:
run_cmd.append("--direct")
# Note: Model configuration for subsequent phases (planning, coding, qa)
# is read from task_metadata.json by run.py, so we don't pass it here.
# This allows per-phase configuration when using Auto profile.
+168 -3
View File
@@ -4,11 +4,137 @@ Command Parsing Utilities
Functions for parsing and extracting commands from shell command strings.
Handles compound commands, pipes, subshells, and various shell constructs.
Windows Compatibility Note:
--------------------------
On Windows, commands containing paths with backslashes can cause shlex.split()
to fail (e.g., incomplete commands with unclosed quotes). This module includes
a fallback parser that extracts command names even from malformed commands,
ensuring security validation can still proceed.
"""
import os
import re
import shlex
from pathlib import PurePosixPath, PureWindowsPath
def _cross_platform_basename(path: str) -> str:
"""
Extract the basename from a path in a cross-platform way.
Handles both Windows paths (C:\\dir\\cmd.exe) and POSIX paths (/dir/cmd)
regardless of the current platform. This is critical for running tests
on Linux CI while handling Windows-style paths.
Args:
path: A file path string (Windows or POSIX format)
Returns:
The basename of the path (e.g., "python.exe" from "C:\\Python312\\python.exe")
"""
# Strip surrounding quotes if present
path = path.strip("'\"")
# Check if this looks like a Windows path (contains backslash or drive letter)
if "\\" in path or (len(path) >= 2 and path[1] == ":"):
# Use PureWindowsPath to handle Windows paths on any platform
return PureWindowsPath(path).name
# For POSIX paths or simple command names, use PurePosixPath
# (os.path.basename works but PurePosixPath is more explicit)
return PurePosixPath(path).name
def _fallback_extract_commands(command_string: str) -> list[str]:
"""
Fallback command extraction when shlex.split() fails.
Uses regex to extract command names from potentially malformed commands.
This is more permissive than shlex but ensures we can at least identify
the commands being executed for security validation.
Args:
command_string: The command string to parse
Returns:
List of command names extracted from the string
"""
commands = []
# Shell keywords to skip
shell_keywords = {
"if",
"then",
"else",
"elif",
"fi",
"for",
"while",
"until",
"do",
"done",
"case",
"esac",
"in",
"function",
}
# First, split by common shell operators
# This regex splits on &&, ||, |, ; while being careful about quotes
# We're being permissive here since shlex already failed
parts = re.split(r"\s*(?:&&|\|\||\|)\s*|;\s*", command_string)
for part in parts:
part = part.strip()
if not part:
continue
# Skip variable assignments at the start (VAR=value cmd)
while re.match(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+", part):
part = re.sub(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+", "", part)
if not part:
continue
# Strategy: Extract command from the BEGINNING of the part
# Handle various formats:
# - Simple: python3, npm, git
# - Unix path: /usr/bin/python
# - Windows path: C:\Python312\python.exe
# - Quoted with spaces: "C:\Program Files\python.exe"
# Extract first token, handling quoted strings with spaces
first_token_match = re.match(r'^(?:"([^"]+)"|\'([^\']+)\'|([^\s]+))', part)
if not first_token_match:
continue
# Pick whichever capture group matched (double-quoted, single-quoted, or unquoted)
first_token = (
first_token_match.group(1)
or first_token_match.group(2)
or first_token_match.group(3)
)
# Now extract just the command name from this token
# Handle Windows paths (C:\dir\cmd.exe) and Unix paths (/dir/cmd)
# Use cross-platform basename for reliable path handling on any OS
cmd = _cross_platform_basename(first_token)
# Remove Windows extensions
cmd = re.sub(r"\.(exe|cmd|bat|ps1|sh)$", "", cmd, flags=re.IGNORECASE)
# Clean up any remaining quotes or special chars at the start
cmd = re.sub(r'^["\'\\/]+', "", cmd)
# Skip tokens that look like function calls or code fragments (not shell commands)
# These appear when splitting on semicolons inside malformed quoted strings
if "(" in cmd or ")" in cmd or "." in cmd:
continue
if cmd and cmd.lower() not in shell_keywords:
commands.append(cmd)
return commands
def split_command_segments(command_string: str) -> list[str]:
@@ -32,13 +158,46 @@ def split_command_segments(command_string: str) -> list[str]:
return result
def _contains_windows_path(command_string: str) -> bool:
"""
Check if a command string contains Windows-style paths.
Windows paths with backslashes cause issues with shlex.split() because
backslashes are interpreted as escape characters in POSIX mode.
Args:
command_string: The command string to check
Returns:
True if Windows paths are detected
"""
# Pattern matches:
# - Drive letter paths: C:\, D:\, etc.
# - Backslash followed by a path component (2+ chars to avoid escape sequences like \n, \t)
# The second char must be alphanumeric, underscore, or another path separator
# This avoids false positives on escape sequences which are single-char after backslash
return bool(re.search(r"[A-Za-z]:\\|\\[A-Za-z][A-Za-z0-9_\\/]", command_string))
def extract_commands(command_string: str) -> list[str]:
"""
Extract command names from a shell command string.
Handles pipes, command chaining (&&, ||, ;), and subshells.
Returns the base command names (without paths).
On Windows or when commands contain malformed quoting (common with
Windows paths in bash-style commands), falls back to regex-based
extraction to ensure security validation can proceed.
"""
# If command contains Windows paths, use fallback parser directly
# because shlex.split() interprets backslashes as escape characters
if _contains_windows_path(command_string):
fallback_commands = _fallback_extract_commands(command_string)
if fallback_commands:
return fallback_commands
# Continue with shlex if fallback found nothing
commands = []
# Split on semicolons that aren't inside quotes
@@ -53,7 +212,12 @@ def extract_commands(command_string: str) -> list[str]:
tokens = shlex.split(segment)
except ValueError:
# Malformed command (unclosed quotes, etc.)
# Return empty to trigger block (fail-safe)
# This is common on Windows with backslash paths in quoted strings
# Use fallback parser instead of blocking
fallback_commands = _fallback_extract_commands(command_string)
if fallback_commands:
return fallback_commands
# If fallback also found nothing, return empty to trigger block
return []
if not tokens:
@@ -106,7 +270,8 @@ def extract_commands(command_string: str) -> list[str]:
if expect_command:
# Extract the base command name (handle paths like /usr/bin/python)
cmd = os.path.basename(token)
# Use cross-platform basename for Windows paths on Linux CI
cmd = _cross_platform_basename(token)
commands.append(cmd)
expect_command = False
@@ -152,6 +152,11 @@ export class AgentManager extends EventEmitter {
}
}
// Workspace mode: --direct skips worktree isolation (default is isolated for safety)
if (metadata?.useWorktree === false) {
args.push('--direct');
}
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
@@ -200,6 +205,11 @@ export class AgentManager extends EventEmitter {
// Force: When user starts a task from the UI, that IS their approval
args.push('--force');
// Workspace mode: --direct skips worktree isolation (default is isolated for safety)
if (options.useWorktree === false) {
args.push('--direct');
}
// Pass base branch if specified (ensures worktrees are created from the correct branch)
if (options.baseBranch) {
args.push('--base-branch', options.baseBranch);
+3
View File
@@ -44,6 +44,7 @@ export interface TaskExecutionOptions {
parallel?: boolean;
workers?: number;
baseBranch?: string;
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
}
export interface SpecCreationMetadata {
@@ -65,6 +66,8 @@ export interface SpecCreationMetadata {
// Non-auto profile - single model and thinking level
model?: 'haiku' | 'sonnet' | 'opus';
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
// Workspace mode - whether to use worktree isolation
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
}
export interface IdeationProgressData {
@@ -193,7 +193,8 @@ export function registerTaskExecutionHandlers(
{
parallel: false, // Sequential for planning phase
workers: 1,
baseBranch
baseBranch,
useWorktree: task.metadata?.useWorktree
}
);
} else {
@@ -208,7 +209,8 @@ export function registerTaskExecutionHandlers(
{
parallel: false,
workers: 1,
baseBranch
baseBranch,
useWorktree: task.metadata?.useWorktree
}
);
}
@@ -588,7 +590,8 @@ export function registerTaskExecutionHandlers(
{
parallel: false,
workers: 1,
baseBranch: baseBranchForUpdate
baseBranch: baseBranchForUpdate,
useWorktree: task.metadata?.useWorktree
}
);
} else {
@@ -602,7 +605,8 @@ export function registerTaskExecutionHandlers(
{
parallel: false,
workers: 1,
baseBranch: baseBranchForUpdate
baseBranch: baseBranchForUpdate,
useWorktree: task.metadata?.useWorktree
}
);
}
@@ -939,7 +943,8 @@ export function registerTaskExecutionHandlers(
{
parallel: false,
workers: 1,
baseBranch: baseBranchForRecovery
baseBranch: baseBranchForRecovery,
useWorktree: task.metadata?.useWorktree
}
);
}
@@ -82,6 +82,8 @@ export function TaskCreationWizard({
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
// Worktree isolation - default to true for safety
const [useWorktree, setUseWorktree] = useState(true);
// Get project path from project store
const projects = useProjectStore((state) => state.projects);
@@ -636,6 +638,8 @@ export function TaskCreationWizard({
if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true;
// Only include baseBranch if it's not the project default placeholder
if (baseBranch && baseBranch !== PROJECT_DEFAULT_BRANCH) metadata.baseBranch = baseBranch;
// Pass worktree preference - false means use --direct mode
if (!useWorktree) metadata.useWorktree = false;
// Title is optional - if empty, it will be auto-generated by the backend
const task = await createTask(projectId, title.trim(), description.trim(), metadata);
@@ -672,6 +676,7 @@ export function TaskCreationWizard({
setReferencedFiles([]);
setRequireReviewBeforeCoding(false);
setBaseBranch(PROJECT_DEFAULT_BRANCH);
setUseWorktree(true);
setError(null);
setShowAdvanced(false);
setShowFileExplorer(false);
@@ -1114,6 +1119,27 @@ export function TaskCreationWizard({
Override the branch this task&apos;s worktree will be created from. Leave empty to use the project&apos;s configured default branch.
</p>
</div>
{/* Workspace Isolation Toggle */}
<div className="flex items-start space-x-3 pt-2 border-t border-border/50">
<Checkbox
id="use-worktree"
checked={useWorktree}
onCheckedChange={(checked) => setUseWorktree(checked === true)}
disabled={isCreating}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="use-worktree"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('wizard.gitOptions.useWorktreeLabel')}
</Label>
<p className="text-xs text-muted-foreground">
{t('wizard.gitOptions.useWorktreeDescription')}
</p>
</div>
</div>
</div>
)}
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { useToast } from '../../hooks/use-toast';
import { Separator } from '../ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ScrollArea } from '../ui/scroll-area';
@@ -76,6 +77,7 @@ const isFilesTabEnabled = () => {
// Separate component to use hooks only when task exists
function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, onOpenInbuiltTerminal }: { open: boolean; task: Task; onOpenChange: (open: boolean) => void; onSwitchToTerminals?: () => void; onOpenInbuiltTerminal?: (id: string, cwd: string) => void }) {
const { t } = useTranslation(['tasks']);
const { toast } = useToast();
const state = useTaskDetail({ task });
const showFilesTab = isFilesTabEnabled();
const progressPercent = calculateProgress(task.subtasks);
@@ -162,6 +164,14 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
};
const handleClose = () => {
// Show toast notification if task is running
if (state.isRunning && !state.isStuck) {
toast({
title: t('tasks:notifications.backgroundTaskTitle'),
description: t('tasks:notifications.backgroundTaskDescription'),
duration: 4000,
});
}
onOpenChange(false);
};
@@ -115,5 +115,15 @@
"images": {
"removeImageAriaLabel": "Remove image {{filename}}",
"pasteHint": "Tip: Paste screenshots directly with {{shortcut}} to add reference images."
},
"notifications": {
"backgroundTaskTitle": "Task continues in background",
"backgroundTaskDescription": "The task is still running. You can reopen this dialog to monitor progress."
},
"wizard": {
"gitOptions": {
"useWorktreeLabel": "Use isolated workspace (recommended)",
"useWorktreeDescription": "Creates changes in a separate git worktree for safe review before merging. Disable to build directly in your project (faster but riskier)."
}
}
}
@@ -115,5 +115,15 @@
"images": {
"removeImageAriaLabel": "Supprimer l'image {{filename}}",
"pasteHint": "Astuce : Collez des captures d'écran directement avec {{shortcut}} pour ajouter des images de référence."
},
"notifications": {
"backgroundTaskTitle": "La tâche continue en arrière-plan",
"backgroundTaskDescription": "La tâche est toujours en cours. Vous pouvez rouvrir cette boîte de dialogue pour suivre la progression."
},
"wizard": {
"gitOptions": {
"useWorktreeLabel": "Utiliser un espace de travail isolé (recommandé)",
"useWorktreeDescription": "Crée les changements dans un worktree git séparé pour une révision sécurisée avant la fusion. Désactivez pour travailler directement dans votre projet (plus rapide mais risqué)."
}
}
}
+1
View File
@@ -228,6 +228,7 @@ export interface TaskMetadata {
// Git/Worktree configuration
baseBranch?: string; // Override base branch for this task's worktree
useWorktree?: boolean; // If false, use direct mode (no worktree isolation) - default is true for safety
// Archive status
archivedAt?: string; // ISO date when task was archived
+17 -2
View File
@@ -94,9 +94,24 @@ class TestCommandExtraction:
assert commands == []
def test_malformed_command(self):
"""Returns empty list for malformed command (fail-safe)."""
"""Uses fallback parser for malformed commands (Windows path support).
The fallback parser extracts command names even from commands with
unclosed quotes, which is common when Windows paths are used.
"""
commands = extract_commands("echo 'unclosed quote")
assert commands == []
assert commands == ["echo"]
def test_windows_path_command(self):
"""Handles Windows paths with backslashes."""
commands = extract_commands(r'C:\Python312\python.exe -c "print(1)"')
assert "python" in commands
def test_incomplete_windows_path_command(self):
"""Handles incomplete commands with Windows paths (common AI generation issue)."""
cmd = r'python3 -c "import json; json.load(open(\'D:\path\file.json'
commands = extract_commands(cmd)
assert commands == ["python3"]
class TestSplitCommandSegments: