fix(worktree): prevent cross-worktree file leakage via environment variables (#1267)
* fix(worktree): prevent cross-worktree file leakage via environment variables
When pre-commit hook runs in a worktree, it sets GIT_DIR and GIT_WORK_TREE
environment variables. These variables were persisting and leaking into
subsequent git operations in other worktrees or the main repository, causing
files to appear as untracked in the wrong location.
Root cause confirmed by 5 independent investigation agents:
- GIT_DIR/GIT_WORK_TREE exports persist across shell sessions
- Version sync section runs git add without env isolation
- Tests already clear these vars but production code didn't
Fix:
- Clear GIT_DIR/GIT_WORK_TREE when NOT in a worktree context
- Add auto-detection and repair of corrupted core.worktree config
- Add comprehensive documentation explaining the bug and fix
* fix(worktree): add git env isolation to frontend subprocess calls
Extend worktree corruption fix to TypeScript frontend:
- Create git-isolation.ts utility with getIsolatedGitEnv()
- Fix task/worktree-handlers.ts: merge, preview, PR creation spawns
- Fix terminal/worktree-handlers.ts: all 10 execFileSync git calls
- Use getToolPath('git') consistently for cross-platform support
Clears GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, and author/committer
env vars to prevent cross-contamination between worktrees.
Part of fix for mysterious file leakage between worktrees.
* fix(pre-commit): improve robustness of git worktree handling
Address PR review findings:
1. Improve .git file parsing for worktree detection:
- Use sed -n with /p to only print matching lines
- Add head -1 to handle malformed files with multiple lines
- Add directory existence check before setting GIT_DIR
2. Add error handling for git config --unset:
- Wrap in conditional to detect failures
- Print warning if unset fails (permissions, locked config, etc.)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(worktree): align git env isolation between TypeScript and Python
Address PR review findings for consistency:
1. Add GIT_AUTHOR_DATE and GIT_COMMITTER_DATE to TypeScript
GIT_ENV_VARS_TO_CLEAR array to match Python implementation
2. Add HUSKY=0 to Python get_isolated_git_env() to match
TypeScript implementation and prevent double-hook execution
3. Add getIsolatedGitEnv() to listOtherWorktrees() for consistency
with all other git operations in the file
Also fix ruff linting (UP045): Replace Optional[dict] with dict | None
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(workspace): migrate remaining git calls to use run_git for env isolation
Replace all direct subprocess.run git calls in workspace.py with run_git()
to ensure consistent environment isolation across all backend git operations.
This prevents potential cross-worktree contamination from environment
variables (GIT_DIR, GIT_WORK_TREE, etc.) that could be set by pre-commit
hooks or other git configurations.
Converted 13 subprocess.run calls:
- git rev-parse --abbrev-ref HEAD
- git merge-base (2 locations)
- git add (10 locations)
Also:
- Remove now-unused subprocess import
- Fix cross-platform issue: use getToolPath('git') in listOtherWorktrees
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(worktree): add unit tests and fix pythonEnv git isolation bypass
- Add comprehensive Python tests for git_executable module (20 tests)
- Add TypeScript tests for getIsolatedGitEnv utility (19 tests)
- Migrate GitLab handlers to use getIsolatedGitEnv for git commands
- Fix critical bug: getPythonEnv() now uses getIsolatedGitEnv() as base
to prevent git env vars from being re-added when pythonEnv is spread
The pythonEnv bypass bug caused git isolation to be defeated when:
env: { ...getIsolatedGitEnv(), ...pythonEnv, ... }
because pythonEnv contained a copy of process.env with git vars intact.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(worktree): add git env isolation to execution-handlers
Add getIsolatedGitEnv() to 6 git commands in execution-handlers.ts:
- QA review rejection: reset, checkout, clean (lines 407-430)
- Task discard cleanup: rev-parse, worktree remove, branch -D (lines 573-599)
Without env isolation, these commands could operate on the wrong
repository if GIT_DIR/GIT_WORK_TREE vars were set from a previous
worktree operation.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(pre-commit): clear git env vars when worktree detection fails
When .git is a file but WORKTREE_GIT_DIR parsing fails (empty or invalid
directory), any inherited GIT_DIR/GIT_WORK_TREE environment variables
were left in place, potentially causing cross-worktree contamination.
Now explicitly unsets these variables in the failure case, matching the
behavior of the main repo branch.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore(tests): remove unused pytest import
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:
+44
-3
@@ -1,11 +1,52 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Preserve git worktree context - prevent HEAD corruption in worktrees
|
||||
# =============================================================================
|
||||
# GIT WORKTREE CONTEXT HANDLING
|
||||
# =============================================================================
|
||||
# When running in a worktree, we need to preserve git context to prevent HEAD
|
||||
# corruption. However, we must also CLEAR these variables when NOT in a worktree
|
||||
# to prevent cross-worktree contamination (files leaking between worktrees).
|
||||
#
|
||||
# The bug: If GIT_DIR/GIT_WORK_TREE are set from a previous worktree session
|
||||
# and this hook runs in the main repo (where .git is a directory, not a file),
|
||||
# git commands will target the wrong repository, causing files to appear as
|
||||
# untracked in the wrong location.
|
||||
#
|
||||
# Fix: Explicitly unset these variables when NOT in a worktree context.
|
||||
# =============================================================================
|
||||
|
||||
if [ -f ".git" ]; then
|
||||
WORKTREE_GIT_DIR=$(sed 's/^gitdir: //' .git)
|
||||
if [ -n "$WORKTREE_GIT_DIR" ]; then
|
||||
# We're in a worktree (.git is a file pointing to the actual git dir)
|
||||
# Use -n with /p to only print lines that match the gitdir: prefix, head -1 for safety
|
||||
WORKTREE_GIT_DIR=$(sed -n 's/^gitdir: //p' .git | head -1)
|
||||
if [ -n "$WORKTREE_GIT_DIR" ] && [ -d "$WORKTREE_GIT_DIR" ]; then
|
||||
export GIT_DIR="$WORKTREE_GIT_DIR"
|
||||
export GIT_WORK_TREE="$(pwd)"
|
||||
else
|
||||
# .git file exists but is malformed or points to non-existent directory
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
else
|
||||
# We're in the main repo (.git is a directory)
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
|
||||
# =============================================================================
|
||||
# If core.worktree is set in the main repo's config (pointing to a worktree),
|
||||
# this indicates previous corruption. Fix it automatically.
|
||||
if [ ! -f ".git" ]; then
|
||||
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
|
||||
if [ -n "$CORE_WORKTREE" ]; then
|
||||
echo "Warning: Detected corrupted core.worktree setting, removing it..."
|
||||
if ! git config --unset core.worktree 2>/dev/null; then
|
||||
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Git Executable Finder
|
||||
======================
|
||||
Git Executable Finder and Isolation
|
||||
====================================
|
||||
|
||||
Utility to find the git executable, with Windows-specific fallbacks.
|
||||
Also provides environment isolation to prevent pre-commit hooks and
|
||||
other git configurations from affecting worktree operations.
|
||||
|
||||
Separated into its own module to avoid circular imports.
|
||||
"""
|
||||
|
||||
@@ -12,9 +15,53 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Git environment variables that can interfere with worktree operations
|
||||
# when set by pre-commit hooks or other git configurations.
|
||||
# These must be cleared to prevent cross-worktree contamination.
|
||||
GIT_ENV_VARS_TO_CLEAR = [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
# Identity variables that could be set by hooks
|
||||
"GIT_AUTHOR_NAME",
|
||||
"GIT_AUTHOR_EMAIL",
|
||||
"GIT_AUTHOR_DATE",
|
||||
"GIT_COMMITTER_NAME",
|
||||
"GIT_COMMITTER_EMAIL",
|
||||
"GIT_COMMITTER_DATE",
|
||||
]
|
||||
|
||||
_cached_git_path: str | None = None
|
||||
|
||||
|
||||
def get_isolated_git_env(base_env: dict | None = None) -> dict:
|
||||
"""
|
||||
Create an isolated environment for git operations.
|
||||
|
||||
Clears git environment variables that may be set by pre-commit hooks
|
||||
or other git configurations, preventing cross-worktree contamination
|
||||
and ensuring git operations target the intended repository.
|
||||
|
||||
Args:
|
||||
base_env: Base environment dict to copy from. If None, uses os.environ.
|
||||
|
||||
Returns:
|
||||
Environment dict safe for git subprocess operations.
|
||||
"""
|
||||
env = dict(base_env) if base_env is not None else os.environ.copy()
|
||||
|
||||
for key in GIT_ENV_VARS_TO_CLEAR:
|
||||
env.pop(key, None)
|
||||
|
||||
# Disable user's pre-commit hooks during Auto-Claude managed git operations
|
||||
# to prevent double-hook execution and potential conflicts
|
||||
env["HUSKY"] = "0"
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def get_git_executable() -> str:
|
||||
"""Find the git executable, with Windows-specific fallbacks.
|
||||
|
||||
@@ -102,19 +149,27 @@ def run_git(
|
||||
cwd: Path | str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
env: dict | None = None,
|
||||
isolate_env: bool = True,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a git command with proper executable finding.
|
||||
"""Run a git command with proper executable finding and environment isolation.
|
||||
|
||||
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
|
||||
env: Custom environment dict. If None and isolate_env=True, uses isolated env.
|
||||
isolate_env: If True (default), clears git env vars to prevent hook interference.
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
git = get_git_executable()
|
||||
|
||||
if env is None and isolate_env:
|
||||
env = get_isolated_git_env()
|
||||
|
||||
try:
|
||||
return subprocess.run(
|
||||
[git] + args,
|
||||
@@ -125,6 +180,7 @@ def run_git(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
|
||||
@@ -17,7 +17,6 @@ This module has been refactored for better maintainability:
|
||||
Public API is exported via workspace/__init__.py for backward compatibility.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Import git command helper for centralized logging and allowlist compliance
|
||||
@@ -188,11 +187,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
|
||||
current_branch_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
current_branch_result = run_git(
|
||||
["rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
current_branch = (
|
||||
current_branch_result.stdout.strip()
|
||||
@@ -569,11 +566,9 @@ def _try_smart_merge_inner(
|
||||
base_branch = git_conflicts.get("base_branch", "main")
|
||||
|
||||
# Get merge-base for diff
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, spec_branch],
|
||||
merge_base_result = run_git(
|
||||
["merge-base", base_branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
merge_base = (
|
||||
merge_base_result.stdout.strip()
|
||||
@@ -655,22 +650,19 @@ def _try_smart_merge_inner(
|
||||
|
||||
# Stage all files in a single git add call for efficiency
|
||||
if files_to_stage:
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "add"] + files_to_stage,
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
add_result = run_git(
|
||||
["add"] + files_to_stage,
|
||||
cwd=project_dir,
|
||||
)
|
||||
if add_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE, f"Failed to stage files for direct copy: {e.stderr}"
|
||||
MODULE,
|
||||
f"Failed to stage files for direct copy: {add_result.stderr}",
|
||||
)
|
||||
# Return failure - files were written but not staged
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to stage files: {e.stderr}",
|
||||
"error": f"Failed to stage files: {add_result.stderr}",
|
||||
"resolved_files": [],
|
||||
}
|
||||
|
||||
@@ -1137,11 +1129,9 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
|
||||
# Get merge-base commit
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, spec_branch],
|
||||
merge_base_result = run_git(
|
||||
["merge-base", base_branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
merge_base = (
|
||||
merge_base_result.stdout.strip() if merge_base_result.returncode == 0 else None
|
||||
@@ -1194,11 +1184,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
resolved_files.append(target_file_path)
|
||||
debug(MODULE, f"Copied new binary file: {file_path}")
|
||||
else:
|
||||
@@ -1207,11 +1193,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
@@ -1351,9 +1333,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / file_path
|
||||
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
|
||||
)
|
||||
run_git(["add", file_path], cwd=project_dir)
|
||||
resolved_files.append(file_path)
|
||||
# Show appropriate message based on merge type
|
||||
if file_path in auto_merged_simple:
|
||||
@@ -1372,11 +1352,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / file_path
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
subprocess.run(
|
||||
["git", "add", file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", file_path], cwd=project_dir)
|
||||
resolved_files.append(file_path)
|
||||
print(success(f" ✓ {file_path} (deleted)"))
|
||||
except Exception as e:
|
||||
@@ -1418,11 +1394,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / result.file_path
|
||||
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],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", result.file_path], cwd=project_dir)
|
||||
resolved_files.append(result.file_path)
|
||||
|
||||
if result.was_auto_merged:
|
||||
@@ -1537,11 +1509,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / result.file_path
|
||||
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],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", result.file_path], cwd=project_dir)
|
||||
resolved_files.append(result.file_path)
|
||||
|
||||
if result.was_auto_merged:
|
||||
@@ -1570,11 +1538,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / target_file_path
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
else:
|
||||
# Modified without path change - simple copy
|
||||
# Check if binary file to use correct read/write method
|
||||
@@ -1587,11 +1551,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
@@ -1604,11 +1564,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
|
||||
@@ -27,7 +27,7 @@ from pathlib import Path
|
||||
from typing import TypedDict, TypeVar
|
||||
|
||||
from core.gh_executable import get_gh_executable, invalidate_gh_cache
|
||||
from core.git_executable import get_git_executable, run_git
|
||||
from core.git_executable import get_git_executable, get_isolated_git_env, run_git
|
||||
from debug import debug_warning
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -884,6 +884,7 @@ class WorktreeManager:
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GIT_PUSH_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
@@ -1002,6 +1003,7 @@ class WorktreeManager:
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GH_CLI_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
# Check for "already exists" case (success, no retry needed)
|
||||
@@ -1154,6 +1156,7 @@ class WorktreeManager:
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GH_QUERY_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
|
||||
@@ -17,6 +17,8 @@ import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import get_isolated_git_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import debug utilities
|
||||
@@ -62,6 +64,7 @@ class TimelineGitHelper:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -86,6 +89,7 @@ class TimelineGitHelper:
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
@@ -117,6 +121,7 @@ class TimelineGitHelper:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
return [f for f in result.stdout.strip().split("\n") if f]
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -133,33 +138,34 @@ class TimelineGitHelper:
|
||||
Dictionary with keys: message, author, diff_summary
|
||||
"""
|
||||
info = {}
|
||||
env = get_isolated_git_env()
|
||||
try:
|
||||
# Get commit message
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["message"] = result.stdout.strip()
|
||||
|
||||
# Get author
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%an", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["author"] = result.stdout.strip()
|
||||
|
||||
# Get diff stat
|
||||
result = subprocess.run(
|
||||
["git", "diff-tree", "--stat", "--no-commit-id", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["diff_summary"] = (
|
||||
@@ -226,6 +232,7 @@ class TimelineGitHelper:
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -259,6 +266,7 @@ class TimelineGitHelper:
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -284,24 +292,23 @@ class TimelineGitHelper:
|
||||
Returns:
|
||||
The detected target branch name, defaults to 'main' if detection fails
|
||||
"""
|
||||
# Try to get the upstream tracking branch
|
||||
env = get_isolated_git_env()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
upstream = result.stdout.strip()
|
||||
# Extract branch name from origin/branch format
|
||||
if "/" in upstream:
|
||||
return upstream.split("/", 1)[1]
|
||||
return upstream
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try common branch names and find which one has a valid merge-base
|
||||
for branch in ["main", "master", "develop"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -309,13 +316,13 @@ class TimelineGitHelper:
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Default to main
|
||||
return "main"
|
||||
|
||||
def count_commits_between(self, from_commit: str, to_commit: str) -> int:
|
||||
@@ -335,6 +342,7 @@ class TimelineGitHelper:
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
|
||||
@@ -21,6 +21,8 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from core.git_executable import get_isolated_git_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default cleanup policies (can be overridden via environment variables)
|
||||
@@ -129,14 +131,15 @@ class PRWorktreeManager:
|
||||
|
||||
logger.debug(f"Creating worktree: {worktree_path}")
|
||||
|
||||
env = get_isolated_git_env()
|
||||
try:
|
||||
# Fetch the commit if not available locally (handles fork PRs)
|
||||
fetch_result = subprocess.run(
|
||||
["git", "fetch", "origin", head_sha],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env=env,
|
||||
)
|
||||
|
||||
if fetch_result.returncode != 0:
|
||||
@@ -149,13 +152,13 @@ class PRWorktreeManager:
|
||||
)
|
||||
|
||||
try:
|
||||
# Create detached worktree at the PR commit
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "add", "--detach", str(worktree_path), head_sha],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env=env,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -193,7 +196,7 @@ class PRWorktreeManager:
|
||||
|
||||
logger.debug(f"Removing worktree: {worktree_path}")
|
||||
|
||||
# Try 1: git worktree remove
|
||||
env = get_isolated_git_env()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(worktree_path)],
|
||||
@@ -201,6 +204,7 @@ class PRWorktreeManager:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env=env,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
@@ -211,7 +215,6 @@ class PRWorktreeManager:
|
||||
f"Timeout removing worktree {worktree_path.name}, falling back to shutil"
|
||||
)
|
||||
|
||||
# Try 2: shutil.rmtree fallback
|
||||
try:
|
||||
shutil.rmtree(worktree_path, ignore_errors=True)
|
||||
subprocess.run(
|
||||
@@ -219,6 +222,7 @@ class PRWorktreeManager:
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
logger.warning(
|
||||
f"[WorktreeManager] Used shutil fallback for: {worktree_path.name}"
|
||||
@@ -283,6 +287,7 @@ class PRWorktreeManager:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout listing worktrees, returning empty set")
|
||||
@@ -338,13 +343,13 @@ class PRWorktreeManager:
|
||||
shutil.rmtree(wt.path, ignore_errors=True)
|
||||
stats["orphaned"] += 1
|
||||
|
||||
# Refresh worktree list after orphan cleanup
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "worktree", "prune"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout pruning worktrees, continuing anyway")
|
||||
@@ -429,6 +434,7 @@ class PRWorktreeManager:
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout pruning worktrees after cleanup")
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*/
|
||||
|
||||
import { ipcMain, shell } from 'electron';
|
||||
import { execSync, execFileSync, spawn } from 'child_process';
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
import { getAugmentedEnv, findExecutable } from '../../env-utils';
|
||||
import { getIsolatedGitEnv } from '../../utils/git-isolation';
|
||||
import { openTerminalWithCommand } from '../claude-code-handlers';
|
||||
import type { GitLabAuthStartResult } from './types';
|
||||
|
||||
@@ -472,7 +473,7 @@ export function registerDetectGitLabProject(): void {
|
||||
encoding: 'utf-8',
|
||||
cwd: projectPath,
|
||||
stdio: 'pipe',
|
||||
env: getAugmentedEnv()
|
||||
env: getIsolatedGitEnv()
|
||||
}).trim();
|
||||
|
||||
debugLog('Remote URL:', remoteUrl);
|
||||
@@ -670,13 +671,15 @@ export function registerAddGitLabRemote(): void {
|
||||
execFileSync('git', ['remote', 'get-url', 'origin'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
// Remove existing origin
|
||||
execFileSync('git', ['remote', 'remove', 'origin'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
} catch {
|
||||
// No origin exists
|
||||
@@ -685,7 +688,8 @@ export function registerAddGitLabRemote(): void {
|
||||
execFileSync('git', ['remote', 'add', 'origin', remoteUrl], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
*/
|
||||
|
||||
import { readFile, access } from 'fs/promises';
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import type { Project } from '../../../shared/types';
|
||||
import { parseEnvFile } from '../utils';
|
||||
import type { GitLabConfig } from './types';
|
||||
import { getAugmentedEnv } from '../../env-utils';
|
||||
import { getIsolatedGitEnv } from '../../utils/git-isolation';
|
||||
|
||||
const DEFAULT_GITLAB_URL = 'https://gitlab.com';
|
||||
|
||||
@@ -357,7 +358,7 @@ export function detectGitLabProjectFromRemote(projectPath: string): { project: s
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env: getAugmentedEnv()
|
||||
env: getIsolatedGitEnv()
|
||||
}).trim();
|
||||
|
||||
if (!remoteUrl) return null;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './plan-file-utils';
|
||||
import { findTaskWorktree } from '../../worktree-paths';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getIsolatedGitEnv } from '../../utils/git-isolation';
|
||||
|
||||
/**
|
||||
* Atomic file write to prevent TOCTOU race conditions.
|
||||
@@ -403,20 +404,22 @@ export function registerTaskExecutionHandlers(
|
||||
// The worktree still has all changes, so nothing is lost
|
||||
if (hasWorktree) {
|
||||
// Step 1: Unstage all changes
|
||||
const resetResult = spawnSync('git', ['reset', 'HEAD'], {
|
||||
const resetResult = spawnSync(getToolPath('git'), ['reset', 'HEAD'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
if (resetResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Unstaged changes in main');
|
||||
}
|
||||
|
||||
// Step 2: Discard all working tree changes (restore to pre-merge state)
|
||||
const checkoutResult = spawnSync('git', ['checkout', '--', '.'], {
|
||||
const checkoutResult = spawnSync(getToolPath('git'), ['checkout', '--', '.'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
if (checkoutResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Discarded working tree changes in main');
|
||||
@@ -424,10 +427,11 @@ export function registerTaskExecutionHandlers(
|
||||
|
||||
// Step 3: Clean untracked files that came from the merge
|
||||
// IMPORTANT: Exclude .auto-claude directory to preserve specs and worktree data
|
||||
const cleanResult = spawnSync('git', ['clean', '-fd', '-e', '.auto-claude'], {
|
||||
const cleanResult = spawnSync(getToolPath('git'), ['clean', '-fd', '-e', '.auto-claude'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
if (cleanResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Cleaned untracked files in main (excluding .auto-claude)');
|
||||
@@ -569,7 +573,8 @@ export function registerTaskExecutionHandlers(
|
||||
branch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: worktreePath,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000
|
||||
timeout: 30000,
|
||||
env: getIsolatedGitEnv()
|
||||
}).trim();
|
||||
} catch (branchError) {
|
||||
// If we can't get branch name, use the default pattern
|
||||
@@ -582,7 +587,8 @@ export function registerTaskExecutionHandlers(
|
||||
execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000
|
||||
timeout: 30000,
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
console.warn(`[TASK_UPDATE_STATUS] Worktree removed: ${worktreePath}`);
|
||||
|
||||
@@ -591,7 +597,8 @@ export function registerTaskExecutionHandlers(
|
||||
execFileSync(getToolPath('git'), ['branch', '-D', branch], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000
|
||||
timeout: 30000,
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
console.warn(`[TASK_UPDATE_STATUS] Branch deleted: ${branch}`);
|
||||
} catch (branchDeleteError) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
findTaskWorktree,
|
||||
} from '../../worktree-paths';
|
||||
import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils';
|
||||
import { getIsolatedGitEnv } from '../../utils/git-isolation';
|
||||
import { killProcessGracefully } from '../../platform';
|
||||
|
||||
// Regex pattern for validating git branch names
|
||||
@@ -1893,9 +1894,10 @@ export function registerWorktreeHandlers(
|
||||
|
||||
// Check if changes are already staged (for stage-only mode)
|
||||
if (options?.noCommit) {
|
||||
const stagedResult = spawnSync('git', ['diff', '--staged', '--name-only'], {
|
||||
const stagedResult = spawnSync(getToolPath('git'), ['diff', '--staged', '--name-only'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
encoding: 'utf-8',
|
||||
env: getIsolatedGitEnv()
|
||||
});
|
||||
|
||||
if (stagedResult.status === 0 && stagedResult.stdout?.trim()) {
|
||||
@@ -1985,17 +1987,16 @@ export function registerWorktreeHandlers(
|
||||
const mergeProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd: sourcePath,
|
||||
env: {
|
||||
...process.env,
|
||||
...pythonEnv, // Include bundled packages PYTHONPATH
|
||||
...profileEnv, // Include active Claude profile OAuth token
|
||||
...getIsolatedGitEnv(),
|
||||
...pythonEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONUTF8: '1',
|
||||
// Utility feature settings for merge resolver
|
||||
UTILITY_MODEL: utilitySettings.model,
|
||||
UTILITY_MODEL_ID: utilitySettings.modelId,
|
||||
UTILITY_THINKING_BUDGET: utilitySettings.thinkingBudget === null ? '' : (utilitySettings.thinkingBudget?.toString() || '')
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'] // Don't connect stdin to avoid blocking
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
@@ -2467,7 +2468,7 @@ export function registerWorktreeHandlers(
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const previewProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd: sourcePath,
|
||||
env: { ...process.env, ...previewPythonEnv, ...previewProfileEnv, PYTHONUNBUFFERED: '1', PYTHONUTF8: '1', DEBUG: 'true' }
|
||||
env: { ...getIsolatedGitEnv(), ...previewPythonEnv, ...previewProfileEnv, PYTHONUNBUFFERED: '1', PYTHONUTF8: '1', DEBUG: 'true' }
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
@@ -2995,7 +2996,7 @@ export function registerWorktreeHandlers(
|
||||
const createPRProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd: sourcePath,
|
||||
env: {
|
||||
...process.env,
|
||||
...getIsolatedGitEnv(),
|
||||
...pythonEnv,
|
||||
...profileEnv,
|
||||
GITHUB_CLI_PATH: ghCliPath,
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
getTerminalWorktreeMetadataDir,
|
||||
getTerminalWorktreeMetadataPath,
|
||||
} from '../../worktree-paths';
|
||||
import { getIsolatedGitEnv } from '../../utils/git-isolation';
|
||||
import { getToolPath } from '../../cli-tool-manager';
|
||||
|
||||
// Promisify execFile for async operations
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -52,9 +54,9 @@ function fixMisconfiguredBareRepo(projectPath: string): boolean {
|
||||
try {
|
||||
// Check if bare=true is set
|
||||
const bareConfig = execFileSync(
|
||||
'git',
|
||||
getToolPath('git'),
|
||||
['config', '--get', 'core.bare'],
|
||||
{ cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
{ cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getIsolatedGitEnv() }
|
||||
).trim().toLowerCase();
|
||||
|
||||
if (bareConfig !== 'true') {
|
||||
@@ -133,9 +135,9 @@ function fixMisconfiguredBareRepo(projectPath: string): boolean {
|
||||
// Fix the misconfiguration
|
||||
debugLog('[TerminalWorktree] Detected misconfigured bare repository with source files. Auto-fixing by unsetting core.bare...');
|
||||
execFileSync(
|
||||
'git',
|
||||
getToolPath('git'),
|
||||
['config', '--unset', 'core.bare'],
|
||||
{ cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
{ cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getIsolatedGitEnv() }
|
||||
);
|
||||
debugLog('[TerminalWorktree] Fixed: core.bare has been unset. Git operations should now work correctly.');
|
||||
return true;
|
||||
@@ -180,10 +182,11 @@ function getDefaultBranch(projectPath: string): string {
|
||||
|
||||
for (const branch of ['main', 'master']) {
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', branch], {
|
||||
execFileSync(getToolPath('git'), ['rev-parse', '--verify', branch], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
debugLog('[TerminalWorktree] Auto-detected branch:', branch);
|
||||
return branch;
|
||||
@@ -194,10 +197,11 @@ function getDefaultBranch(projectPath: string): string {
|
||||
|
||||
// Fallback to current branch - wrap in try-catch
|
||||
try {
|
||||
const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
const currentBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
}).trim();
|
||||
debugLog('[TerminalWorktree] Falling back to current branch:', currentBranch);
|
||||
return currentBranch;
|
||||
@@ -396,10 +400,11 @@ async function createTerminalWorktree(
|
||||
|
||||
// Fetch the branch from remote
|
||||
try {
|
||||
execFileSync('git', ['fetch', 'origin', remoteBranchName], {
|
||||
execFileSync(getToolPath('git'), ['fetch', 'origin', remoteBranchName], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
debugLog('[TerminalWorktree] Fetched latest from origin/' + remoteBranchName);
|
||||
} catch {
|
||||
@@ -415,10 +420,11 @@ async function createTerminalWorktree(
|
||||
} else {
|
||||
// Check if remote version exists and use it for latest code
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', `origin/${baseBranch}`], {
|
||||
execFileSync(getToolPath('git'), ['rev-parse', '--verify', `origin/${baseBranch}`], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
baseRef = `origin/${baseBranch}`;
|
||||
debugLog('[TerminalWorktree] Using remote ref:', baseRef);
|
||||
@@ -428,17 +434,19 @@ async function createTerminalWorktree(
|
||||
}
|
||||
|
||||
if (createGitBranch) {
|
||||
execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
||||
execFileSync(getToolPath('git'), ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
debugLog('[TerminalWorktree] Created worktree with branch:', branchName, 'from', baseRef);
|
||||
} else {
|
||||
execFileSync('git', ['worktree', 'add', '--detach', worktreePath, baseRef], {
|
||||
execFileSync(getToolPath('git'), ['worktree', 'add', '--detach', worktreePath, baseRef], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
debugLog('[TerminalWorktree] Created worktree in detached HEAD mode from', baseRef);
|
||||
}
|
||||
@@ -475,10 +483,11 @@ async function createTerminalWorktree(
|
||||
debugLog('[TerminalWorktree] Cleaned up failed worktree directory:', worktreePath);
|
||||
// Also prune stale worktree registrations in case git worktree add partially succeeded
|
||||
try {
|
||||
execFileSync('git', ['worktree', 'prune'], {
|
||||
execFileSync(getToolPath('git'), ['worktree', 'prune'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
debugLog('[TerminalWorktree] Pruned stale worktree registrations');
|
||||
} catch {
|
||||
@@ -591,10 +600,11 @@ async function listOtherWorktrees(projectPath: string): Promise<OtherWorktreeInf
|
||||
];
|
||||
|
||||
try {
|
||||
const { stdout: output } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
|
||||
const { stdout: output } = await execFileAsync(getToolPath('git'), ['worktree', 'list', '--porcelain'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
|
||||
// Parse porcelain output
|
||||
@@ -699,10 +709,11 @@ async function removeTerminalWorktree(
|
||||
|
||||
try {
|
||||
if (existsSync(worktreePath)) {
|
||||
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
|
||||
execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
debugLog('[TerminalWorktree] Removed git worktree');
|
||||
}
|
||||
@@ -713,10 +724,11 @@ async function removeTerminalWorktree(
|
||||
debugError('[TerminalWorktree] Invalid branch name in config:', config.branchName);
|
||||
} else {
|
||||
try {
|
||||
execFileSync('git', ['branch', '-D', config.branchName], {
|
||||
execFileSync(getToolPath('git'), ['branch', '-D', config.branchName], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getIsolatedGitEnv(),
|
||||
});
|
||||
debugLog('[TerminalWorktree] Deleted branch:', config.branchName);
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EventEmitter } from 'events';
|
||||
import { app } from 'electron';
|
||||
import { findPythonCommand, getBundledPythonPath } from './python-detector';
|
||||
import { isLinux, isWindows, getPathDelimiter } from './platform';
|
||||
import { getIsolatedGitEnv } from './utils/git-isolation';
|
||||
|
||||
export interface PythonEnvStatus {
|
||||
ready: boolean;
|
||||
@@ -682,12 +683,17 @@ if sys.version_info >= (3, 12):
|
||||
* @see https://github.com/mhammond/pywin32/blob/main/win32/Lib/pywin32_bootstrap.py
|
||||
*/
|
||||
getPythonEnv(): Record<string, string> {
|
||||
// Start with process.env but explicitly remove problematic Python variables
|
||||
// PYTHONHOME causes "Could not find platform independent libraries" when set
|
||||
// to a different Python installation than the one we're spawning
|
||||
// Start with isolated git env to prevent git environment variable contamination.
|
||||
// When running Python scripts that call git (like merge resolver, PR creator),
|
||||
// we must not pass GIT_DIR, GIT_WORK_TREE, etc. or git operations will target
|
||||
// the wrong repository. getIsolatedGitEnv() removes these variables and sets HUSKY=0.
|
||||
//
|
||||
// Also remove PYTHONHOME - it causes "Could not find platform independent libraries"
|
||||
// when set to a different Python installation than the one we're spawning.
|
||||
const isolatedEnv = getIsolatedGitEnv();
|
||||
const baseEnv: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
for (const [key, value] of Object.entries(isolatedEnv)) {
|
||||
// Skip PYTHONHOME - it causes the "platform independent libraries" error
|
||||
// Use case-insensitive check for Windows compatibility (env vars are case-insensitive on Windows)
|
||||
// Skip undefined values (TypeScript type guard)
|
||||
@@ -710,7 +716,7 @@ if sys.version_info >= (3, 12):
|
||||
// Windows-specific pywin32 DLL loading fix
|
||||
// On Windows with bundled packages, we need to ensure pywin32 DLLs can be found.
|
||||
// The DLL copying in fixPywin32() is the primary fix - this PATH addition is a fallback.
|
||||
let windowsEnv: Record<string, string> = {};
|
||||
const windowsEnv: Record<string, string> = {};
|
||||
if (this.sitePackagesPath && isWindows()) {
|
||||
const pywin32System32 = path.join(this.sitePackagesPath, 'pywin32_system32');
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Tests for git-isolation module - environment isolation for git operations.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
GIT_ENV_VARS_TO_CLEAR,
|
||||
getIsolatedGitEnv,
|
||||
getIsolatedGitSpawnOptions,
|
||||
} from '../git-isolation';
|
||||
|
||||
describe('GIT_ENV_VARS_TO_CLEAR', () => {
|
||||
it('should contain GIT_DIR', () => {
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_DIR');
|
||||
});
|
||||
|
||||
it('should contain GIT_WORK_TREE', () => {
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_WORK_TREE');
|
||||
});
|
||||
|
||||
it('should contain GIT_INDEX_FILE', () => {
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_INDEX_FILE');
|
||||
});
|
||||
|
||||
it('should contain GIT_OBJECT_DIRECTORY', () => {
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_OBJECT_DIRECTORY');
|
||||
});
|
||||
|
||||
it('should contain GIT_ALTERNATE_OBJECT_DIRECTORIES', () => {
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_ALTERNATE_OBJECT_DIRECTORIES');
|
||||
});
|
||||
|
||||
it('should contain author identity variables', () => {
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_AUTHOR_NAME');
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_AUTHOR_EMAIL');
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_AUTHOR_DATE');
|
||||
});
|
||||
|
||||
it('should contain committer identity variables', () => {
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_COMMITTER_NAME');
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_COMMITTER_EMAIL');
|
||||
expect(GIT_ENV_VARS_TO_CLEAR).toContain('GIT_COMMITTER_DATE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIsolatedGitEnv', () => {
|
||||
describe('clears git environment variables', () => {
|
||||
it('should remove GIT_DIR from the environment', () => {
|
||||
const baseEnv = { GIT_DIR: '/some/path', PATH: '/usr/bin' };
|
||||
const env = getIsolatedGitEnv(baseEnv);
|
||||
expect(env.GIT_DIR).toBeUndefined();
|
||||
expect(env.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('should remove GIT_WORK_TREE from the environment', () => {
|
||||
const baseEnv = { GIT_WORK_TREE: '/some/worktree', HOME: '/home/user' };
|
||||
const env = getIsolatedGitEnv(baseEnv);
|
||||
expect(env.GIT_WORK_TREE).toBeUndefined();
|
||||
expect(env.HOME).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should remove all git env vars from the clear list', () => {
|
||||
const baseEnv: Record<string, string> = {
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
};
|
||||
for (const varName of GIT_ENV_VARS_TO_CLEAR) {
|
||||
baseEnv[varName] = `value_${varName}`;
|
||||
}
|
||||
|
||||
const env = getIsolatedGitEnv(baseEnv);
|
||||
|
||||
for (const varName of GIT_ENV_VARS_TO_CLEAR) {
|
||||
expect(env[varName]).toBeUndefined();
|
||||
}
|
||||
expect(env.PATH).toBe('/usr/bin');
|
||||
expect(env.HOME).toBe('/home/user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sets HUSKY=0', () => {
|
||||
it('should set HUSKY to 0 to disable user hooks', () => {
|
||||
const env = getIsolatedGitEnv({ PATH: '/usr/bin' });
|
||||
expect(env.HUSKY).toBe('0');
|
||||
});
|
||||
|
||||
it('should override any existing HUSKY value', () => {
|
||||
const baseEnv = { HUSKY: '1', PATH: '/usr/bin' };
|
||||
const env = getIsolatedGitEnv(baseEnv);
|
||||
expect(env.HUSKY).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preserves other environment variables', () => {
|
||||
it('should preserve unrelated environment variables', () => {
|
||||
const baseEnv = {
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
LANG: 'en_US.UTF-8',
|
||||
CUSTOM_VAR: 'custom_value',
|
||||
GIT_DIR: '/should/be/cleared',
|
||||
};
|
||||
|
||||
const env = getIsolatedGitEnv(baseEnv);
|
||||
|
||||
expect(env.PATH).toBe('/usr/bin');
|
||||
expect(env.HOME).toBe('/home/user');
|
||||
expect(env.LANG).toBe('en_US.UTF-8');
|
||||
expect(env.CUSTOM_VAR).toBe('custom_value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('does not modify original environment', () => {
|
||||
it('should not mutate the input base environment', () => {
|
||||
const baseEnv = { GIT_DIR: '/some/path', PATH: '/usr/bin' };
|
||||
const originalGitDir = baseEnv.GIT_DIR;
|
||||
|
||||
getIsolatedGitEnv(baseEnv);
|
||||
|
||||
expect(baseEnv.GIT_DIR).toBe(originalGitDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uses process.env by default', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv, GIT_DIR: '/test/path', PATH: '/usr/bin' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should use process.env when no base env is provided', () => {
|
||||
const env = getIsolatedGitEnv();
|
||||
expect(env.GIT_DIR).toBeUndefined();
|
||||
expect(env.PATH).toBe('/usr/bin');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIsolatedGitSpawnOptions', () => {
|
||||
it('should return options with cwd and isolated env', () => {
|
||||
const opts = getIsolatedGitSpawnOptions('/project/path');
|
||||
|
||||
expect(opts.cwd).toBe('/project/path');
|
||||
expect(opts.env).toBeDefined();
|
||||
expect((opts.env as Record<string, string>).HUSKY).toBe('0');
|
||||
expect(opts.encoding).toBe('utf-8');
|
||||
});
|
||||
|
||||
it('should merge additional options', () => {
|
||||
const opts = getIsolatedGitSpawnOptions('/project/path', {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
expect(opts.cwd).toBe('/project/path');
|
||||
expect(opts.timeout).toBe(5000);
|
||||
expect(opts.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow additional options to override defaults', () => {
|
||||
const opts = getIsolatedGitSpawnOptions('/project/path', {
|
||||
encoding: 'ascii',
|
||||
});
|
||||
|
||||
expect(opts.encoding).toBe('ascii');
|
||||
});
|
||||
|
||||
it('should not include git env vars in the returned env', () => {
|
||||
const opts = getIsolatedGitSpawnOptions('/project/path');
|
||||
const env = opts.env as Record<string, string | undefined>;
|
||||
|
||||
for (const varName of GIT_ENV_VARS_TO_CLEAR) {
|
||||
expect(env[varName]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Git Environment Isolation Utility
|
||||
*
|
||||
* Prevents git environment variable contamination between worktrees
|
||||
* and the main repository. When running git commands in a worktree context,
|
||||
* environment variables like GIT_DIR, GIT_WORK_TREE, etc. can leak and
|
||||
* cause files to appear in the wrong repository.
|
||||
*
|
||||
* This utility clears problematic git env vars before spawning git processes,
|
||||
* ensuring each git operation targets the correct repository.
|
||||
*
|
||||
* Related fix: .husky/pre-commit hook also clears these vars.
|
||||
* Backend equivalent: apps/backend/core/git_executable.py:get_isolated_git_env()
|
||||
*/
|
||||
|
||||
/**
|
||||
* Git environment variables that can cause cross-contamination between worktrees.
|
||||
*
|
||||
* GIT_DIR: Overrides the location of the .git directory
|
||||
* GIT_WORK_TREE: Overrides the working tree location
|
||||
* GIT_INDEX_FILE: Overrides the index file location
|
||||
* GIT_OBJECT_DIRECTORY: Overrides the object store location
|
||||
* GIT_ALTERNATE_OBJECT_DIRECTORIES: Additional object stores
|
||||
* GIT_AUTHOR_*: Can cause wrong commit attribution in automated contexts
|
||||
* GIT_COMMITTER_*: Can cause wrong commit attribution in automated contexts
|
||||
*/
|
||||
export const GIT_ENV_VARS_TO_CLEAR = [
|
||||
'GIT_DIR',
|
||||
'GIT_WORK_TREE',
|
||||
'GIT_INDEX_FILE',
|
||||
'GIT_OBJECT_DIRECTORY',
|
||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
||||
'GIT_AUTHOR_NAME',
|
||||
'GIT_AUTHOR_EMAIL',
|
||||
'GIT_AUTHOR_DATE',
|
||||
'GIT_COMMITTER_NAME',
|
||||
'GIT_COMMITTER_EMAIL',
|
||||
'GIT_COMMITTER_DATE',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Creates a clean environment for git subprocess operations.
|
||||
*
|
||||
* Copies the current process environment and removes git-specific
|
||||
* variables that can interfere with worktree operations.
|
||||
*
|
||||
* Also sets HUSKY=0 to disable the user's pre-commit hooks when
|
||||
* Auto-Claude manages commits, preventing double-hook execution
|
||||
* and potential conflicts.
|
||||
*
|
||||
* @param baseEnv - Optional base environment to start from. Defaults to process.env
|
||||
* @returns Clean environment object safe for git subprocess operations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { spawn } from 'child_process';
|
||||
* import { getIsolatedGitEnv } from './git-isolation';
|
||||
*
|
||||
* spawn('git', ['status'], {
|
||||
* cwd: worktreePath,
|
||||
* env: getIsolatedGitEnv(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function getIsolatedGitEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env
|
||||
): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = { ...baseEnv };
|
||||
|
||||
for (const varName of GIT_ENV_VARS_TO_CLEAR) {
|
||||
delete env[varName];
|
||||
}
|
||||
|
||||
env.HUSKY = '0';
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates spawn options with isolated git environment.
|
||||
*
|
||||
* Convenience function that returns common spawn options
|
||||
* with the isolated environment already set.
|
||||
*
|
||||
* @param cwd - Working directory for the command
|
||||
* @param additionalOptions - Additional spawn options to merge
|
||||
* @returns Spawn options object with isolated git environment
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { execFileSync } from 'child_process';
|
||||
* import { getIsolatedGitSpawnOptions } from './git-isolation';
|
||||
*
|
||||
* execFileSync('git', ['status'], getIsolatedGitSpawnOptions(worktreePath));
|
||||
* ```
|
||||
*/
|
||||
export function getIsolatedGitSpawnOptions(
|
||||
cwd: string,
|
||||
additionalOptions: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
cwd,
|
||||
env: getIsolatedGitEnv(),
|
||||
encoding: 'utf-8',
|
||||
...additionalOptions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for git_executable module - environment isolation and git executable finding."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.git_executable import (
|
||||
GIT_ENV_VARS_TO_CLEAR,
|
||||
get_git_executable,
|
||||
get_isolated_git_env,
|
||||
run_git,
|
||||
)
|
||||
|
||||
|
||||
class TestGetIsolatedGitEnv:
|
||||
"""Tests for get_isolated_git_env() function."""
|
||||
|
||||
def test_clears_git_dir(self):
|
||||
"""GIT_DIR should be removed from the environment."""
|
||||
base_env = {"GIT_DIR": "/some/path", "PATH": "/usr/bin"}
|
||||
env = get_isolated_git_env(base_env)
|
||||
assert "GIT_DIR" not in env
|
||||
assert env["PATH"] == "/usr/bin"
|
||||
|
||||
def test_clears_git_work_tree(self):
|
||||
"""GIT_WORK_TREE should be removed from the environment."""
|
||||
base_env = {"GIT_WORK_TREE": "/some/worktree", "HOME": "/home/user"}
|
||||
env = get_isolated_git_env(base_env)
|
||||
assert "GIT_WORK_TREE" not in env
|
||||
assert env["HOME"] == "/home/user"
|
||||
|
||||
def test_clears_all_git_env_vars(self):
|
||||
"""All variables in GIT_ENV_VARS_TO_CLEAR should be removed."""
|
||||
# Create env with all the git vars set
|
||||
base_env = {var: f"value_{var}" for var in GIT_ENV_VARS_TO_CLEAR}
|
||||
base_env["PATH"] = "/usr/bin"
|
||||
base_env["HOME"] = "/home/user"
|
||||
|
||||
env = get_isolated_git_env(base_env)
|
||||
|
||||
# None of the git vars should remain
|
||||
for var in GIT_ENV_VARS_TO_CLEAR:
|
||||
assert var not in env, f"{var} should have been cleared"
|
||||
|
||||
# Non-git vars should be preserved
|
||||
assert env["PATH"] == "/usr/bin"
|
||||
assert env["HOME"] == "/home/user"
|
||||
|
||||
def test_sets_husky_zero(self):
|
||||
"""HUSKY should be set to '0' to disable user hooks."""
|
||||
env = get_isolated_git_env({"PATH": "/usr/bin"})
|
||||
assert env["HUSKY"] == "0"
|
||||
|
||||
def test_husky_overrides_existing_value(self):
|
||||
"""HUSKY=0 should override any existing HUSKY value."""
|
||||
base_env = {"HUSKY": "1", "PATH": "/usr/bin"}
|
||||
env = get_isolated_git_env(base_env)
|
||||
assert env["HUSKY"] == "0"
|
||||
|
||||
def test_does_not_modify_original_env(self):
|
||||
"""The original environment dict should not be modified."""
|
||||
base_env = {"GIT_DIR": "/some/path", "PATH": "/usr/bin"}
|
||||
original_git_dir = base_env["GIT_DIR"]
|
||||
|
||||
get_isolated_git_env(base_env)
|
||||
|
||||
assert base_env["GIT_DIR"] == original_git_dir
|
||||
|
||||
def test_uses_os_environ_by_default(self):
|
||||
"""When no base_env is provided, should use os.environ."""
|
||||
with patch.dict(os.environ, {"GIT_DIR": "/test/path"}, clear=False):
|
||||
env = get_isolated_git_env()
|
||||
assert "GIT_DIR" not in env
|
||||
|
||||
def test_preserves_unrelated_vars(self):
|
||||
"""Environment variables not in the clear list should be preserved."""
|
||||
base_env = {
|
||||
"PATH": "/usr/bin",
|
||||
"HOME": "/home/user",
|
||||
"LANG": "en_US.UTF-8",
|
||||
"CUSTOM_VAR": "custom_value",
|
||||
"GIT_DIR": "/should/be/cleared",
|
||||
}
|
||||
|
||||
env = get_isolated_git_env(base_env)
|
||||
|
||||
assert env["PATH"] == "/usr/bin"
|
||||
assert env["HOME"] == "/home/user"
|
||||
assert env["LANG"] == "en_US.UTF-8"
|
||||
assert env["CUSTOM_VAR"] == "custom_value"
|
||||
|
||||
|
||||
class TestGitEnvVarsToClear:
|
||||
"""Tests for the GIT_ENV_VARS_TO_CLEAR constant."""
|
||||
|
||||
def test_contains_git_dir(self):
|
||||
"""GIT_DIR must be in the list."""
|
||||
assert "GIT_DIR" in GIT_ENV_VARS_TO_CLEAR
|
||||
|
||||
def test_contains_git_work_tree(self):
|
||||
"""GIT_WORK_TREE must be in the list."""
|
||||
assert "GIT_WORK_TREE" in GIT_ENV_VARS_TO_CLEAR
|
||||
|
||||
def test_contains_git_index_file(self):
|
||||
"""GIT_INDEX_FILE must be in the list."""
|
||||
assert "GIT_INDEX_FILE" in GIT_ENV_VARS_TO_CLEAR
|
||||
|
||||
def test_contains_author_identity_vars(self):
|
||||
"""Author identity variables must be in the list."""
|
||||
assert "GIT_AUTHOR_NAME" in GIT_ENV_VARS_TO_CLEAR
|
||||
assert "GIT_AUTHOR_EMAIL" in GIT_ENV_VARS_TO_CLEAR
|
||||
assert "GIT_AUTHOR_DATE" in GIT_ENV_VARS_TO_CLEAR
|
||||
|
||||
def test_contains_committer_identity_vars(self):
|
||||
"""Committer identity variables must be in the list."""
|
||||
assert "GIT_COMMITTER_NAME" in GIT_ENV_VARS_TO_CLEAR
|
||||
assert "GIT_COMMITTER_EMAIL" in GIT_ENV_VARS_TO_CLEAR
|
||||
assert "GIT_COMMITTER_DATE" in GIT_ENV_VARS_TO_CLEAR
|
||||
|
||||
|
||||
class TestRunGit:
|
||||
"""Tests for run_git() function."""
|
||||
|
||||
def test_uses_isolated_env_by_default(self):
|
||||
"""run_git should use isolated environment by default."""
|
||||
with patch("core.git_executable.subprocess.run") as mock_run:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=["git", "status"], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
|
||||
run_git(["status"])
|
||||
|
||||
# Check that env was passed and doesn't contain GIT_DIR
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert "env" in call_kwargs
|
||||
assert "GIT_DIR" not in call_kwargs["env"]
|
||||
assert call_kwargs["env"]["HUSKY"] == "0"
|
||||
|
||||
def test_respects_isolate_env_false(self):
|
||||
"""run_git with isolate_env=False should not modify environment."""
|
||||
with patch("core.git_executable.subprocess.run") as mock_run:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=["git", "status"], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
|
||||
run_git(["status"], isolate_env=False)
|
||||
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
# When isolate_env=False and no env provided, env should be None
|
||||
assert call_kwargs.get("env") is None
|
||||
|
||||
def test_allows_custom_env(self):
|
||||
"""run_git should accept custom environment."""
|
||||
custom_env = {"PATH": "/custom/path", "CUSTOM": "value"}
|
||||
|
||||
with patch("core.git_executable.subprocess.run") as mock_run:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=["git", "status"], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
|
||||
run_git(["status"], env=custom_env)
|
||||
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs["env"] == custom_env
|
||||
|
||||
def test_handles_timeout(self):
|
||||
"""run_git should handle timeout gracefully."""
|
||||
with patch("core.git_executable.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=60)
|
||||
|
||||
result = run_git(["status"], timeout=60)
|
||||
|
||||
assert result.returncode == -1
|
||||
assert "timed out" in result.stderr
|
||||
|
||||
def test_handles_file_not_found(self):
|
||||
"""run_git should handle missing git executable gracefully."""
|
||||
with patch("core.git_executable.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = FileNotFoundError()
|
||||
|
||||
result = run_git(["status"])
|
||||
|
||||
assert result.returncode == -1
|
||||
assert "not found" in result.stderr
|
||||
|
||||
|
||||
class TestGetGitExecutable:
|
||||
"""Tests for get_git_executable() function."""
|
||||
|
||||
def test_returns_string(self):
|
||||
"""get_git_executable should return a string path."""
|
||||
result = get_git_executable()
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_caches_result(self):
|
||||
"""get_git_executable should cache the result."""
|
||||
# Call twice and verify same result
|
||||
result1 = get_git_executable()
|
||||
result2 = get_git_executable()
|
||||
assert result1 == result2
|
||||
Reference in New Issue
Block a user