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:
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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's worktree will be created from. Leave empty to use the project'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é)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user