Compare commits

...

1 Commits

Author SHA1 Message Date
Alex Madera a17d201041 fix windows issue 2026-01-05 17:30:36 +01:00
19 changed files with 683 additions and 78 deletions
+6 -2
View File
@@ -11,14 +11,17 @@ import shutil
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
def get_latest_commit(project_dir: Path) -> str | None:
"""Get the hash of the latest git commit."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
[git_path, "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -32,8 +35,9 @@ def get_latest_commit(project_dir: Path) -> str | None:
def get_commit_count(project_dir: Path) -> int:
"""Get the total number of commits."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-list", "--count", "HEAD"],
[git_path, "rev-list", "--count", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
+8 -3
View File
@@ -18,6 +18,8 @@ import subprocess
from pathlib import Path
from typing import Any
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
# Check for Claude SDK availability
@@ -86,8 +88,9 @@ def get_session_diff(
return "(No changes - same commit)"
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", commit_before, commit_after],
[git_path, "diff", commit_before, commit_after],
cwd=project_dir,
capture_output=True,
text=True,
@@ -131,8 +134,9 @@ def get_changed_files(
return []
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--name-only", commit_before, commit_after],
[git_path, "diff", "--name-only", commit_before, commit_after],
cwd=project_dir,
capture_output=True,
text=True,
@@ -156,8 +160,9 @@ def get_commit_messages(
return "(No commits)"
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "log", "--oneline", f"{commit_before}..{commit_after}"],
[git_path, "log", "--oneline", f"{commit_before}..{commit_after}"],
cwd=project_dir,
capture_output=True,
text=True,
+19 -12
View File
@@ -9,6 +9,8 @@ import subprocess
import sys
from pathlib import Path
from core.git_bash import get_git_executable_path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
@@ -58,12 +60,14 @@ def _detect_default_branch(project_dir: Path) -> str:
"""
import os
git_path = get_git_executable_path()
# 1. Check for DEFAULT_BRANCH env var
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = subprocess.run(
["git", "rev-parse", "--verify", env_branch],
[git_path, "rev-parse", "--verify", env_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -74,7 +78,7 @@ def _detect_default_branch(project_dir: Path) -> str:
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = subprocess.run(
["git", "rev-parse", "--verify", branch],
[git_path, "rev-parse", "--verify", branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -99,9 +103,10 @@ def _get_changed_files_from_git(
Returns:
List of changed file paths
"""
git_path = get_git_executable_path()
try:
result = subprocess.run(
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
[git_path, "diff", "--name-only", f"{base_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -119,7 +124,7 @@ def _get_changed_files_from_git(
# Fallback: try without the three-dot notation
try:
result = subprocess.run(
["git", "diff", "--name-only", base_branch, "HEAD"],
[git_path, "diff", "--name-only", base_branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -226,8 +231,9 @@ def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None
diff_summary = ""
files_changed = []
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--staged", "--stat"],
[git_path, "diff", "--staged", "--stat"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -237,7 +243,7 @@ def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None
# Get list of changed files
result = subprocess.run(
["git", "diff", "--staged", "--name-only"],
[git_path, "diff", "--staged", "--name-only"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -375,6 +381,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
debug(MODULE, "Checking for git-level merge conflicts (non-destructive)...")
git_path = get_git_executable_path()
spec_branch = f"auto-claude/{spec_name}"
result = {
"has_conflicts": False,
@@ -388,7 +395,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
try:
# Get the current branch (base branch)
base_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -398,7 +405,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get the merge base commit
merge_base_result = subprocess.run(
["git", "merge-base", result["base_branch"], spec_branch],
[git_path, "merge-base", result["base_branch"], spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -411,7 +418,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Count commits main is ahead
ahead_result = subprocess.run(
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
[git_path, "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -430,7 +437,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Note: --write-tree mode only accepts 2 branches (it auto-finds the merge base)
merge_tree_result = subprocess.run(
[
"git",
git_path,
"merge-tree",
"--write-tree",
"--no-messages",
@@ -474,7 +481,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
if not result["conflicting_files"]:
# Files changed in main since merge-base
main_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, result["base_branch"]],
[git_path, "diff", "--name-only", merge_base, result["base_branch"]],
cwd=project_dir,
capture_output=True,
text=True,
@@ -487,7 +494,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Files changed in spec branch since merge-base
spec_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, spec_branch],
[git_path, "diff", "--name-only", merge_base, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
+13
View File
@@ -7,10 +7,15 @@ for custom API endpoints.
"""
import json
import logging
import os
import platform
import subprocess
from core.git_bash import get_git_bash_env
logger = logging.getLogger(__name__)
# Priority order for auth token resolution
# NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY.
# Auto Claude is designed to use Claude Code OAuth tokens only.
@@ -222,6 +227,8 @@ def get_sdk_env_vars() -> dict[str, str]:
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
be passed through to the claude-agent-sdk subprocess.
On Windows, also includes CLAUDE_CODE_GIT_BASH_PATH if git-bash is found.
Returns:
Dict of env var name -> value for non-empty vars
"""
@@ -230,6 +237,12 @@ def get_sdk_env_vars() -> dict[str, str]:
value = os.environ.get(var)
if value:
env[var] = value
# On Windows, add git-bash path for Claude SDK sandbox
git_bash_env = get_git_bash_env()
if git_bash_env:
env.update(git_bash_env)
return env
+434
View File
@@ -0,0 +1,434 @@
"""
Windows Git Bash Detection
Detects git-bash (bash.exe from Git for Windows) for Claude Agent SDK.
The SDK requires bash.exe on Windows for its sandbox functionality.
Detection Priority:
1. CLAUDE_CODE_GIT_BASH_PATH environment variable (user override)
2. Standard Git installation paths (Program Files)
3. User-specific installations (LocalAppData, Scoop, Chocolatey)
Note: This detects bash.exe, NOT git.exe. They are different executables:
- git.exe: The Git CLI
- bash.exe: Unix shell bundled with Git for Windows (required by Claude SDK)
"""
import logging
import os
import platform
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# Environment variable for user override
ENV_VAR_NAME = "CLAUDE_CODE_GIT_BASH_PATH"
@dataclass
class GitBashDetectionResult:
"""Result of git-bash detection attempt."""
found: bool
path: Optional[str] = None
source: str = "not-found"
message: str = ""
# Module-level cache (persists for process lifetime)
_cache: Optional[GitBashDetectionResult] = None
def _find_git_executable() -> Optional[str]:
"""
Find git.exe using multiple methods.
GUI apps on Windows often have different PATH than terminal sessions.
We try multiple approaches to find git.
Returns:
Path to git.exe if found, None otherwise
"""
import shutil
import subprocess
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
# Method 1: shutil.which (standard PATH lookup)
git_path = shutil.which("git")
if git_path:
if debug:
print(f"[GitBash] Found git via shutil.which: {git_path}")
return git_path
# Method 2: Windows 'where' command (searches PATH differently)
try:
result = subprocess.run(
["where", "git"],
capture_output=True,
text=True,
timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
)
if result.returncode == 0 and result.stdout.strip():
git_path = result.stdout.strip().split('\n')[0].strip()
if os.path.isfile(git_path):
if debug:
print(f"[GitBash] Found git via 'where' command: {git_path}")
return git_path
except Exception as e:
if debug:
print(f"[GitBash] 'where git' failed: {e}")
# Method 3: Check Windows Registry for Git install location
try:
import winreg
for hive in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
for key_path in [
r"SOFTWARE\GitForWindows",
r"SOFTWARE\WOW6432Node\GitForWindows",
]:
try:
with winreg.OpenKey(hive, key_path) as key:
install_path, _ = winreg.QueryValueEx(key, "InstallPath")
if install_path:
# Try multiple possible git.exe locations
for subpath in ["cmd\\git.exe", "bin\\git.exe", "mingw64\\bin\\git.exe"]:
git_exe = os.path.join(install_path, subpath)
if os.path.isfile(git_exe):
if debug:
print(f"[GitBash] Found git via registry: {git_exe}")
return git_exe
except (FileNotFoundError, OSError):
continue
except ImportError:
pass # winreg not available (non-Windows)
return None
def _find_bash_from_registry() -> Optional[str]:
"""
Find bash.exe directly from Windows Registry Git InstallPath.
This is more reliable than PATH-based detection for GUI apps.
Returns:
Path to bash.exe if found via registry, None otherwise
"""
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
try:
import winreg
for hive in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
for key_path in [
r"SOFTWARE\GitForWindows",
r"SOFTWARE\WOW6432Node\GitForWindows",
]:
try:
with winreg.OpenKey(hive, key_path) as key:
install_path, _ = winreg.QueryValueEx(key, "InstallPath")
if install_path:
# Check for bash.exe in standard locations
bash_exe = os.path.join(install_path, "bin", "bash.exe")
if os.path.isfile(bash_exe):
if debug:
print(f"[GitBash] Found bash.exe via registry: {bash_exe}")
return bash_exe
# Try usr/bin as fallback
bash_exe = os.path.join(install_path, "usr", "bin", "bash.exe")
if os.path.isfile(bash_exe):
if debug:
print(f"[GitBash] Found bash.exe via registry: {bash_exe}")
return bash_exe
except (FileNotFoundError, OSError):
continue
except ImportError:
pass # winreg not available (non-Windows)
return None
def _derive_bash_from_git(git_path: str) -> Optional[str]:
"""
Derive bash.exe location from git.exe path.
Git for Windows structure:
<install>/cmd/git.exe
<install>/bin/bash.exe
<install>/mingw64/bin/git.exe
Args:
git_path: Path to git.exe
Returns:
Path to bash.exe if found, None otherwise
"""
git_dir = os.path.dirname(git_path)
git_parent = os.path.dirname(git_dir)
# Build list of possible bash locations
possible_paths = []
# If git is in cmd/ folder
if git_dir.endswith("cmd"):
possible_paths.extend([
os.path.join(git_parent, "bin", "bash.exe"),
os.path.join(git_parent, "usr", "bin", "bash.exe"),
])
# If git is in mingw64/bin/ folder
if "mingw64" in git_dir:
git_root = git_parent
while git_root and not git_root.endswith("mingw64"):
git_root = os.path.dirname(git_root)
if git_root:
install_root = os.path.dirname(git_root)
possible_paths.extend([
os.path.join(install_root, "bin", "bash.exe"),
os.path.join(install_root, "usr", "bin", "bash.exe"),
])
# Generic fallbacks
possible_paths.extend([
os.path.join(git_parent, "bin", "bash.exe"),
os.path.join(git_parent, "usr", "bin", "bash.exe"),
])
for bash_path in possible_paths:
if os.path.isfile(bash_path):
return bash_path
return None
def _find_git_from_path() -> Optional[str]:
"""
Find bash.exe by first locating git.exe.
Returns:
Path to bash.exe if found via git location, None otherwise
"""
git_path = _find_git_executable()
if not git_path:
return None
return _derive_bash_from_git(git_path)
def _get_candidate_paths() -> list[str]:
"""
Get list of candidate paths for bash.exe on Windows.
Returns paths in priority order (most common first).
"""
candidates = [
# Standard 64-bit Git installation
r"C:\Program Files\Git\bin\bash.exe",
# Standard 32-bit Git installation
r"C:\Program Files (x86)\Git\bin\bash.exe",
# User-specific installation (Git installer option)
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\bin\bash.exe"),
# Chocolatey installation
r"C:\ProgramData\chocolatey\lib\git\tools\bin\bash.exe",
# Scoop installation
os.path.expandvars(r"%USERPROFILE%\scoop\apps\git\current\bin\bash.exe"),
# Git for Windows SDK
r"C:\Git\bin\bash.exe",
# Alternative usr/bin location (some Git versions)
r"C:\Program Files\Git\usr\bin\bash.exe",
]
return candidates
def detect_git_bash(force_refresh: bool = False) -> GitBashDetectionResult:
"""
Detect git-bash installation on Windows.
Args:
force_refresh: If True, bypass cache and re-detect
Returns:
GitBashDetectionResult with detection outcome
"""
global _cache
# Non-Windows: not applicable
if platform.system() != "Windows":
return GitBashDetectionResult(
found=False,
source="not-applicable",
message="Git Bash detection only applies to Windows"
)
# Return cached result if available
if _cache is not None and not force_refresh:
logger.debug(f"[GitBash] Using cached result: {_cache.path} ({_cache.source})")
return _cache
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
if debug:
print("[GitBash] Starting detection...")
# Priority 1: Environment variable override
env_path = os.environ.get(ENV_VAR_NAME)
if env_path:
if os.path.isfile(env_path):
result = GitBashDetectionResult(
found=True,
path=env_path,
source="env-var",
message=f"Using {ENV_VAR_NAME}: {env_path}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
else:
print(f"[GitBash] WARNING: {ENV_VAR_NAME} set but path does not exist: {env_path}")
# Priority 2: Windows Registry (most reliable for GUI apps)
if debug:
print("[GitBash] Checking Windows Registry...")
registry_bash = _find_bash_from_registry()
if registry_bash:
result = GitBashDetectionResult(
found=True,
path=registry_bash,
source="windows-registry",
message=f"Found git-bash via registry: {registry_bash}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
# Priority 3: Check standard candidate paths
candidates = _get_candidate_paths()
if debug:
print(f"[GitBash] Checking {len(candidates)} standard paths...")
for candidate_path in candidates:
resolved = os.path.expandvars(candidate_path)
if os.path.isfile(resolved):
result = GitBashDetectionResult(
found=True,
path=resolved,
source="standard-path",
message=f"Found git-bash at standard path: {resolved}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
elif debug:
print(f"[GitBash] Not found: {resolved}")
# Priority 4: Derive from git.exe in PATH (handles custom PATH setups)
if debug:
print("[GitBash] Checking PATH-based git installation...")
path_based_bash = _find_git_from_path()
if path_based_bash:
result = GitBashDetectionResult(
found=True,
path=path_based_bash,
source="derived-from-path",
message=f"Found git-bash via PATH: {path_based_bash}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
elif debug:
print("[GitBash] Could not derive bash.exe from git PATH")
# Not found
result = GitBashDetectionResult(
found=False,
source="not-found",
message=(
"Git Bash not found. Install Git for Windows from https://git-scm.com/downloads/win "
f"or set {ENV_VAR_NAME} environment variable."
)
)
print(f"[GitBash] WARNING: {result.message}")
_cache = result
return result
def get_git_bash_path() -> Optional[str]:
"""
Get the path to bash.exe if available.
Convenience function that returns just the path or None.
Returns:
Path to bash.exe or None if not found
"""
result = detect_git_bash()
return result.path if result.found else None
def get_git_bash_env() -> dict[str, str]:
"""
Get environment variables for Claude SDK with git-bash path.
Returns a dict suitable for merging into SDK environment variables.
Only returns the variable if git-bash was found.
Returns:
Dict with CLAUDE_CODE_GIT_BASH_PATH if found, empty dict otherwise
"""
# Skip on non-Windows
if platform.system() != "Windows":
return {}
result = detect_git_bash()
if result.found and result.path:
return {ENV_VAR_NAME: result.path}
return {}
def clear_cache() -> None:
"""Clear the detection cache. Useful for testing."""
global _cache
_cache = None
logger.debug("[GitBash] Cache cleared")
# Module-level cache for git executable path
_git_cache: Optional[str] = None
def get_git_executable_path() -> str:
"""
Get the path to git executable.
On Windows, uses multi-method detection (shutil.which, registry, standard paths).
On other platforms, returns "git" and relies on PATH.
Returns:
Path to git executable, or "git" as fallback
"""
global _git_cache
# Return cached result if available
if _git_cache is not None:
return _git_cache
# On non-Windows, just use "git" from PATH
if platform.system() != "Windows":
_git_cache = "git"
return _git_cache
# On Windows, try to find the full path
git_path = _find_git_executable()
if git_path:
_git_cache = git_path
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
if debug:
print(f"[Git] Using detected git: {git_path}")
return _git_cache
# Fallback to "git" and hope it's in PATH
_git_cache = "git"
return _git_cache
+20 -16
View File
@@ -20,6 +20,7 @@ Public API is exported via workspace/__init__.py for backward compatibility.
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
from ui import (
Icons,
bold,
@@ -180,8 +181,9 @@ def merge_existing_build(
# Detect current branch - this is where user wants changes merged
# Normal workflow: user is on their feature branch (e.g., version/2.5.5)
# and wants to merge the spec changes into it, then PR to main
git_path = get_git_executable_path()
current_branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -545,6 +547,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
"""
import re
git_path = get_git_executable_path()
spec_branch = f"auto-claude/{spec_name}"
result = {
"has_conflicts": False,
@@ -556,7 +559,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
try:
# Get current branch
base_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -566,7 +569,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get merge base
merge_base_result = subprocess.run(
["git", "merge-base", result["base_branch"], spec_branch],
[git_path, "merge-base", result["base_branch"], spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -579,13 +582,13 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get commit hashes
main_commit_result = subprocess.run(
["git", "rev-parse", result["base_branch"]],
[git_path, "rev-parse", result["base_branch"]],
cwd=project_dir,
capture_output=True,
text=True,
)
spec_commit_result = subprocess.run(
["git", "rev-parse", spec_branch],
[git_path, "rev-parse", spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -602,7 +605,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Note: --write-tree mode only accepts 2 branches (it auto-finds the merge base)
merge_tree_result = subprocess.run(
[
"git",
git_path,
"merge-tree",
"--write-tree",
"--no-messages",
@@ -639,7 +642,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# 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],
[git_path, "diff", "--name-only", merge_base, main_commit],
cwd=project_dir,
capture_output=True,
text=True,
@@ -651,7 +654,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
)
spec_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, spec_commit],
[git_path, "diff", "--name-only", merge_base, spec_commit],
cwd=project_dir,
capture_output=True,
text=True,
@@ -728,8 +731,9 @@ def _resolve_git_conflicts_with_ai(
)
# Get merge-base commit
git_path = get_git_executable_path()
merge_base_result = subprocess.run(
["git", "merge-base", base_branch, spec_branch],
[git_path, "merge-base", base_branch, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -783,7 +787,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
[git_path, "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
@@ -904,7 +908,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(merged_content, encoding="utf-8")
subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
[git_path, "add", file_path], cwd=project_dir, capture_output=True
)
resolved_files.append(file_path)
print(success(f"{file_path} (new file)"))
@@ -914,7 +918,7 @@ def _resolve_git_conflicts_with_ai(
if target_path.exists():
target_path.unlink()
subprocess.run(
["git", "add", file_path],
[git_path, "add", file_path],
cwd=project_dir,
capture_output=True,
)
@@ -960,7 +964,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
["git", "add", result.file_path],
[git_path, "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1079,7 +1083,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
["git", "add", result.file_path],
[git_path, "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1112,7 +1116,7 @@ def _resolve_git_conflicts_with_ai(
if target_path.exists():
target_path.unlink()
subprocess.run(
["git", "add", target_file_path],
[git_path, "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1126,7 +1130,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
[git_path, "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
+16 -7
View File
@@ -10,6 +10,8 @@ import json
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
# 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
@@ -113,9 +115,10 @@ def detect_file_renames(
# -M flag enables rename detection
# --diff-filter=R shows only renames
# --name-status shows status and file names
git_path = get_git_executable_path()
result = subprocess.run(
[
"git",
git_path,
"log",
"--name-status",
"-M",
@@ -176,8 +179,9 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
Merge-base commit hash, or None if not found
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "merge-base", ref1, ref2],
[git_path, "merge-base", ref1, ref2],
cwd=project_dir,
capture_output=True,
text=True,
@@ -191,8 +195,9 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
def has_uncommitted_changes(project_dir: Path) -> bool:
"""Check if user has unsaved work."""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "status", "--porcelain"],
[git_path, "status", "--porcelain"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -202,8 +207,9 @@ def has_uncommitted_changes(project_dir: Path) -> bool:
def get_current_branch(project_dir: Path) -> str:
"""Get the current branch name."""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -239,8 +245,9 @@ 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.)."""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "show", f"{ref}:{file_path}"],
[git_path, "show", f"{ref}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -268,8 +275,9 @@ def get_changed_files_from_branch(
Returns:
List of (file_path, status) tuples
"""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
[git_path, "diff", "--name-status", f"{base_branch}...{spec_branch}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -491,8 +499,9 @@ def create_conflict_file_with_git(
try:
# git merge-file <current> <base> <other>
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
git_path = get_git_executable_path()
result = subprocess.run(
["git", "merge-file", "-p", main_path, base_path, wt_path],
[git_path, "merge-file", "-p", main_path, base_path, wt_path],
cwd=project_dir,
capture_output=True,
text=True,
+3 -1
View File
@@ -12,6 +12,7 @@ import subprocess
import sys
from pathlib import Path
from core.git_bash import get_git_executable_path
from merge import FileTimelineTracker
from ui import (
Icons,
@@ -368,8 +369,9 @@ def initialize_timeline_tracking(
files_to_modify.extend(subtask.get("files", []))
# Get the current branch point commit
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
[git_path, "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
+12 -5
View File
@@ -22,6 +22,8 @@ import subprocess
from dataclasses import dataclass
from pathlib import Path
from core.git_bash import get_git_executable_path
class WorktreeError(Exception):
"""Error during worktree operations."""
@@ -70,12 +72,14 @@ class WorktreeManager:
Returns:
The detected base branch name
"""
git_path = get_git_executable_path()
# 1. Check for DEFAULT_BRANCH env var
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = subprocess.run(
["git", "rev-parse", "--verify", env_branch],
[git_path, "rev-parse", "--verify", env_branch],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -92,7 +96,7 @@ class WorktreeManager:
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = subprocess.run(
["git", "rev-parse", "--verify", branch],
[git_path, "rev-parse", "--verify", branch],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -111,8 +115,9 @@ class WorktreeManager:
def _get_current_branch(self) -> str:
"""Get the current git branch."""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -127,8 +132,9 @@ class WorktreeManager:
self, args: list[str], cwd: Path | None = None
) -> subprocess.CompletedProcess:
"""Run a git command and return the result."""
git_path = get_git_executable_path()
return subprocess.run(
["git"] + args,
[git_path] + args,
cwd=cwd or self.project_dir,
capture_output=True,
text=True,
@@ -157,8 +163,9 @@ class WorktreeManager:
# 1. Check which staged files are gitignored
# git check-ignore returns the files that ARE ignored
git_path = get_git_executable_path()
result = subprocess.run(
["git", "check-ignore", "--stdin"],
[git_path, "check-ignore", "--stdin"],
cwd=self.project_dir,
input="\n".join(staged_files),
capture_output=True,
@@ -15,6 +15,8 @@ import subprocess
from datetime import datetime
from pathlib import Path
from core.git_bash import get_git_executable_path
from ..types import FileEvolution, TaskSnapshot, compute_content_hash
from .storage import EvolutionStorage
@@ -93,8 +95,9 @@ class BaselineCapture:
List of absolute paths to trackable files
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "ls-files"],
[git_path, "ls-files"],
cwd=self.storage.project_dir,
capture_output=True,
text=True,
@@ -124,8 +127,9 @@ class BaselineCapture:
Git commit SHA, or "unknown" if not available
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
[git_path, "rev-parse", "HEAD"],
cwd=self.storage.project_dir,
capture_output=True,
text=True,
@@ -15,6 +15,8 @@ import subprocess
from datetime import datetime
from pathlib import Path
from core.git_bash import get_git_executable_path
from ..semantic_analyzer import SemanticAnalyzer
from ..types import FileEvolution, TaskSnapshot, compute_content_hash
from .storage import EvolutionStorage
@@ -157,9 +159,10 @@ class ModificationTracker:
)
try:
git_path = get_git_executable_path()
# Get list of files changed in the worktree vs target branch
result = subprocess.run(
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
[git_path, "diff", "--name-only", f"{target_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -178,7 +181,7 @@ class ModificationTracker:
for file_path in changed_files:
# Get the diff for this file
diff_result = subprocess.run(
["git", "diff", f"{target_branch}...HEAD", "--", file_path],
[git_path, "diff", f"{target_branch}...HEAD", "--", file_path],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -188,7 +191,7 @@ class ModificationTracker:
# Get content before (from target branch) and after (current)
try:
show_result = subprocess.run(
["git", "show", f"{target_branch}:{file_path}"],
[git_path, "show", f"{target_branch}:{file_path}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -259,10 +262,12 @@ class ModificationTracker:
Returns:
The detected target branch name, defaults to 'main' if detection fails
"""
git_path = get_git_executable_path()
# Try to get the upstream tracking branch
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
[git_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -280,7 +285,7 @@ class ModificationTracker:
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
["git", "merge-base", branch, "HEAD"],
[git_path, "merge-base", branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
+4 -1
View File
@@ -15,6 +15,8 @@ from __future__ import annotations
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
def find_worktree(project_dir: Path, task_id: str) -> Path | None:
"""
@@ -57,8 +59,9 @@ def get_file_from_branch(project_dir: Path, file_path: str, branch: str) -> str
File content as string, or None if file doesn't exist on branch
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "show", f"{branch}:{file_path}"],
[git_path, "show", f"{branch}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
+23 -11
View File
@@ -17,6 +17,8 @@ import logging
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
# Import debug utilities
@@ -56,8 +58,9 @@ class TimelineGitHelper:
def get_current_main_commit(self) -> str:
"""Get the current HEAD commit on main branch."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
[git_path, "rev-parse", "HEAD"],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -81,8 +84,9 @@ class TimelineGitHelper:
File content as string, or None if file doesn't exist at that commit
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "show", f"{commit_hash}:{file_path}"],
[git_path, "show", f"{commit_hash}:{file_path}"],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -104,9 +108,10 @@ class TimelineGitHelper:
List of file paths changed in the commit
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[
"git",
git_path,
"diff-tree",
"--no-commit-id",
"--name-only",
@@ -134,9 +139,11 @@ class TimelineGitHelper:
"""
info = {}
try:
git_path = get_git_executable_path()
# Get commit message
result = subprocess.run(
["git", "log", "-1", "--format=%s", commit_hash],
[git_path, "log", "-1", "--format=%s", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -146,7 +153,7 @@ class TimelineGitHelper:
# Get author
result = subprocess.run(
["git", "log", "-1", "--format=%an", commit_hash],
[git_path, "log", "-1", "--format=%an", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -156,7 +163,7 @@ class TimelineGitHelper:
# Get diff stat
result = subprocess.run(
["git", "diff-tree", "--stat", "--no-commit-id", commit_hash],
[git_path, "diff-tree", "--stat", "--no-commit-id", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -221,8 +228,9 @@ class TimelineGitHelper:
target_branch = self._detect_target_branch(worktree_path)
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
[git_path, "diff", "--name-only", f"{target_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -254,8 +262,9 @@ class TimelineGitHelper:
target_branch = self._detect_target_branch(worktree_path)
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "merge-base", target_branch, "HEAD"],
[git_path, "merge-base", target_branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -284,10 +293,12 @@ class TimelineGitHelper:
Returns:
The detected target branch name, defaults to 'main' if detection fails
"""
git_path = get_git_executable_path()
# Try to get the upstream tracking branch
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
[git_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -305,7 +316,7 @@ class TimelineGitHelper:
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
["git", "merge-base", branch, "HEAD"],
[git_path, "merge-base", branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -330,8 +341,9 @@ class TimelineGitHelper:
Number of commits between the two points
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-list", "--count", f"{from_commit}..{to_commit}"],
[git_path, "rev-list", "--count", f"{from_commit}..{to_commit}"],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -27,6 +27,7 @@ from pathlib import Path
from typing import Any
from claude_agent_sdk import AgentDefinition
from core.git_bash import get_git_executable_path
try:
from ...core.client import create_client
@@ -164,8 +165,9 @@ class ParallelOrchestratorReviewer:
)
# Fetch the commit if not available locally (handles fork PRs)
git_path = get_git_executable_path()
fetch_result = subprocess.run(
["git", "fetch", "origin", head_sha],
[git_path, "fetch", "origin", head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -184,7 +186,7 @@ class ParallelOrchestratorReviewer:
# Create detached worktree at the PR commit
result = subprocess.run(
["git", "worktree", "add", "--detach", str(worktree_path), head_sha],
[git_path, "worktree", "add", "--detach", str(worktree_path), head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -245,8 +247,9 @@ class ParallelOrchestratorReviewer:
)
# Try 1: git worktree remove
git_path = get_git_executable_path()
result = subprocess.run(
["git", "worktree", "remove", "--force", str(worktree_path)],
[git_path, "worktree", "remove", "--force", str(worktree_path)],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -267,7 +270,7 @@ class ParallelOrchestratorReviewer:
try:
shutil.rmtree(worktree_path, ignore_errors=True)
subprocess.run(
["git", "worktree", "prune"],
[git_path, "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
@@ -283,8 +286,9 @@ class ParallelOrchestratorReviewer:
return
# Get registered worktrees from git
git_path = get_git_executable_path()
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
[git_path, "worktree", "list", "--porcelain"],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -308,7 +312,7 @@ class ParallelOrchestratorReviewer:
if stale_count > 0:
subprocess.run(
["git", "worktree", "prune"],
[git_path, "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
+11 -2
View File
@@ -47,6 +47,8 @@ if sys.version_info < (3, 10): # noqa: UP036
import asyncio
import io
import os
import platform
import subprocess
from pathlib import Path
# Configure safe encoding on Windows BEFORE any imports that might print
@@ -340,8 +342,15 @@ Examples:
print(f" {muted('Running:')} {' '.join(run_cmd)}")
print()
# Execute run.py - replace current process
os.execv(sys.executable, run_cmd)
# Execute run.py
# On Windows, use subprocess.run() to properly handle paths with spaces
# os.execv() on Windows has issues with argument quoting for paths containing spaces
if platform.system() == "Windows":
result = subprocess.run(run_cmd)
sys.exit(result.returncode)
else:
# On Unix, os.execv() replaces the current process (more efficient)
os.execv(sys.executable, run_cmd)
sys.exit(0)
+6 -2
View File
@@ -22,6 +22,8 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from core.git_bash import get_git_executable_path
# =============================================================================
# SECRET PATTERNS
# =============================================================================
@@ -364,8 +366,9 @@ def scan_content(content: str, file_path: str) -> list[SecretMatch]:
def get_staged_files() -> list[str]:
"""Get list of staged files from git (excluding deleted files)."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
[git_path, "diff", "--cached", "--name-only", "--diff-filter=ACM"],
capture_output=True,
text=True,
check=True,
@@ -379,8 +382,9 @@ def get_staged_files() -> list[str]:
def get_all_tracked_files() -> list[str]:
"""Get all tracked files in the repository."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "ls-files"],
[git_path, "ls-files"],
capture_output=True,
text=True,
check=True,
+4 -1
View File
@@ -20,6 +20,8 @@ from datetime import datetime
from enum import Enum
from pathlib import Path
from core.git_bash import get_git_executable_path
class FailureType(Enum):
"""Types of failures that can occur during autonomous builds."""
@@ -425,8 +427,9 @@ class RecoveryManager:
"""
try:
# Use git reset --hard to rollback
git_path = get_git_executable_path()
result = subprocess.run(
["git", "reset", "--hard", commit_hash],
[git_path, "reset", "--hard", commit_hash],
cwd=self.project_dir,
capture_output=True,
text=True,
+77 -2
View File
@@ -377,7 +377,24 @@ class CLIToolManager {
}
}
// 3. System PATH (augmented)
// 3. Windows Registry (most reliable for GUI apps on Windows)
if (process.platform === 'win32') {
const registryGit = this.findGitFromWindowsRegistry();
if (registryGit) {
const validation = this.validateGit(registryGit);
if (validation.valid) {
return {
found: true,
path: registryGit,
version: validation.version,
source: 'windows-registry',
message: `Using Git from registry: ${registryGit}`,
};
}
}
}
// 4. System PATH (augmented)
const gitPath = findExecutable('git');
if (gitPath) {
const validation = this.validateGit(gitPath);
@@ -392,7 +409,7 @@ class CLIToolManager {
}
}
// 4. Not found - fallback to 'git'
// 5. Not found - fallback to 'git'
return {
found: false,
source: 'fallback',
@@ -678,6 +695,64 @@ class CLIToolManager {
}
}
/**
* Find Git installation from Windows Registry
*
* Git for Windows registers its install path in the registry.
* This is more reliable than PATH for GUI apps.
*
* @returns Path to git.exe if found via registry, null otherwise
*/
private findGitFromWindowsRegistry(): string | null {
if (process.platform !== 'win32') {
return null;
}
try {
// Use reg.exe to query the registry (works without native modules)
const registryPaths = [
'HKLM\\SOFTWARE\\GitForWindows',
'HKCU\\SOFTWARE\\GitForWindows',
'HKLM\\SOFTWARE\\WOW6432Node\\GitForWindows',
];
for (const regPath of registryPaths) {
try {
const result = execFileSync('reg', ['query', regPath, '/v', 'InstallPath'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
});
// Parse the registry output to get InstallPath
const match = result.match(/InstallPath\s+REG_SZ\s+(.+)/);
if (match && match[1]) {
const installPath = match[1].trim();
// Check for git.exe in cmd folder
const gitExe = path.join(installPath, 'cmd', 'git.exe');
if (existsSync(gitExe)) {
console.warn(`[Git] Found via registry: ${gitExe}`);
return gitExe;
}
// Fallback to bin folder
const gitExeBin = path.join(installPath, 'bin', 'git.exe');
if (existsSync(gitExeBin)) {
console.warn(`[Git] Found via registry: ${gitExeBin}`);
return gitExeBin;
}
}
} catch {
// Registry key not found, try next
continue;
}
}
} catch (error) {
console.warn('[Git] Registry lookup failed:', error);
}
return null;
}
/**
* Validate Git availability and version
*
+1
View File
@@ -17,6 +17,7 @@ export interface ToolDetectionResult {
| 'user-config'
| 'venv'
| 'homebrew'
| 'windows-registry'
| 'system-path'
| 'bundled'
| 'fallback';