From 6a266ee408891dec1ea4ceb814139ef4f27d4d53 Mon Sep 17 00:00:00 2001 From: Jeff Moore Date: Fri, 13 Jun 2025 15:07:20 -0600 Subject: [PATCH] fix: suppress git errors when using outside of repo --- .pre-commit-config.yaml | 3 -- src/project_context/utils.py | 89 +++++++++++++++++++----------------- tests/test_main.py | 4 +- tests/test_tree.py | 8 ++-- tests/test_utils.py | 46 +++++++++++++++++-- 5 files changed, 97 insertions(+), 53 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 44ed7f2..1780c32 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,15 +19,12 @@ repos: - id: ruff-check types_or: [ python ] args: [ "--select", "I", "--fix" ] - files: ^src/matchbite/.*\.py$ # Run the formatter. - id: ruff-format types_or: [ python ] - files: ^src/matchbite/.*\.py$ - id: ruff name: ruff-format-imports args: ["check", "--select", "I", "--fix"] - files: ^src/matchbite/.*\.py$ - repo: https://github.com/pre-commit/mirrors-mypy rev: "v1.16.0" hooks: diff --git a/src/project_context/utils.py b/src/project_context/utils.py index 36e5f39..5b0d6b3 100644 --- a/src/project_context/utils.py +++ b/src/project_context/utils.py @@ -2,55 +2,72 @@ from pathlib import Path -def is_file_tracked(path: str | Path) -> bool: - """Checks if a file is tracked by Git. +def get_root_paths(path: Path) -> tuple[Path | None, Path | None]: + """Returns the root path of the Git repository and the relative path from the root. + + If the path is not in a Git repository, returns (None, None). Args: - path: The path to the file. + path: The path to check. Returns: - True if the file is tracked, False otherwise. + A tuple containing the root path of the Git repository and the relative path from the root. + If the path is not in a Git repository, returns (None, None). """ try: - path_obj = Path(path) - # Determine working directory more safely - if path_obj.is_file(): - work_dir = path_obj.parent - elif path_obj.is_dir(): - work_dir = path_obj - else: - # Path doesn't exist, try parent directory - work_dir = path_obj.parent - - # Check if working directory exists - if not work_dir.exists(): - return False + if not path.exists(): + return None, None - # Check if we're in a git repository first + # Never use .git directory as cwd + if path.name == ".git" and path.is_dir(): + return None, None + + # Set the current working directory to the path's parent if it's a file result = subprocess.run( ["git", "rev-parse", "--git-dir"], - cwd=work_dir, + cwd=path if path.is_dir() else path.parent, capture_output=True, text=True, ) if result.returncode != 0: - return False + return None, None # Get the repo root relative to the path's directory repo_root = subprocess.check_output( ["git", "rev-parse", "--show-toplevel"], - cwd=work_dir, + cwd=path if path.is_dir() else path.parent, + stderr=subprocess.DEVNULL, # Suppress error output text=True, ).strip() + return Path(repo_root).resolve(), path.resolve().relative_to( + Path(repo_root).resolve() + ) + + except (subprocess.CalledProcessError, ValueError): + return None, None + + +def is_file_tracked(path: str | Path) -> bool: + """Checks if a file is tracked by Git. + + Args: + path: The path to the file. - # Make path relative to its git repo root - relative_path = Path(path).resolve().relative_to(Path(repo_root).resolve()) + Returns: + True if the file is tracked, False otherwise. + """ + try: + path = Path(path) + # First check whether we're in a git repository + repo_root, relative_path = get_root_paths(path) + if not repo_root: + return False # Run git ls-files to check if the file is tracked subprocess.check_output( ["git", "ls-files", "--error-unmatch", str(relative_path)], cwd=repo_root, - stderr=subprocess.STDOUT, + stderr=subprocess.DEVNULL, # Suppress error output text=True, ) return True @@ -68,29 +85,17 @@ def is_path_gitignored(path: str | Path) -> bool: True if the path is ignored, False otherwise. """ try: - # Check if we're in a git repository first - result = subprocess.run( - ["git", "rev-parse", "--git-dir"], - cwd=Path(path).parent if Path(path).is_file() else path, - capture_output=True, - text=True, - ) - if result.returncode != 0: + path = Path(path) + # First check whether we're in a git repository + repo_root, relative_path = get_root_paths(path) + if not repo_root: return False - # Get repo root relative to the path's directory - repo_root = subprocess.check_output( - ["git", "rev-parse", "--show-toplevel"], - cwd=Path(path).parent if Path(path).is_file() else path, - text=True, - ).strip() - - relative_path = Path(path).resolve().relative_to(Path(repo_root).resolve()) - result_bytes = subprocess.run( + result = subprocess.run( ["git", "check-ignore", "--quiet", str(relative_path)], cwd=repo_root, capture_output=True, ) - return result_bytes.returncode == 0 + return result.returncode == 0 except (subprocess.CalledProcessError, ValueError): return False diff --git a/tests/test_main.py b/tests/test_main.py index b8b8ac0..464af06 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,9 @@ -import pytest import tempfile from pathlib import Path from unittest.mock import patch + +import pytest + from project_context.main import main diff --git a/tests/test_tree.py b/tests/test_tree.py index 7925a91..d3a2966 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -1,12 +1,14 @@ -import pytest import tempfile from pathlib import Path -from unittest.mock import patch, mock_open +from unittest.mock import mock_open, patch + +import pytest + +from project_context.main import main from project_context.tree import ( ProjectPath, ProjectTree, ) -from project_context.main import main class TestProjectPath: diff --git a/tests/test_utils.py b/tests/test_utils.py index e2fc2b7..48517c7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import subprocess from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + from project_context.utils import ( is_file_tracked, is_path_gitignored, @@ -81,6 +82,21 @@ def test_is_file_tracked_returns_false_for_untracked_file( result = is_file_tracked("/repo/root/untracked.py") assert result is False + @patch("project_context.utils.subprocess.run") + @patch("project_context.utils.subprocess.check_output") + def test_is_file_tracked_returns_false_for_fake_file( + self, mock_check_output, mock_run + ): + # Mock the git rev-parse --git-dir call + mock_run.return_value = MagicMock(returncode=0) + + mock_check_output.side_effect = [ + "/repo/root\n", # git rev-parse --show-toplevel + subprocess.CalledProcessError(1, "git ls-files"), # git ls-files fails + ] + result = is_file_tracked("/repo/root/untracked.py") + assert result is False + @patch("project_context.utils.subprocess.run") def test_is_file_tracked_handles_git_error(self, mock_run): # Mock the git rev-parse --git-dir call to fail (not in git repo) @@ -101,9 +117,15 @@ def test_is_path_gitignored_returns_true_for_ignored_path( # Mock the git check-ignore call with patch("project_context.utils.subprocess.run") as mock_run_check: - mock_run_check.return_value = MagicMock(returncode=0) - result = is_path_gitignored("/repo/root/.env") - assert result is True + with patch("project_context.utils.Path") as mock_path: + # Mock Path behavior + mock_path_obj = MagicMock() + mock_path_obj.exists.return_value = True + mock_path_obj.is_dir.return_value = False + mock_path.return_value = mock_path_obj + mock_run_check.return_value = MagicMock(returncode=0) + result = is_path_gitignored("/repo/root/.env") + assert result is True @patch("project_context.utils.subprocess.run") @patch("project_context.utils.subprocess.check_output") @@ -120,3 +142,19 @@ def test_is_path_gitignored_returns_false_for_tracked_path( mock_run_check.return_value = MagicMock(returncode=1) result = is_path_gitignored("/repo/root/file.py") assert result is False + + @patch("project_context.utils.subprocess.run") + @patch("project_context.utils.subprocess.check_output") + def test_is_path_gitignored_returns_true_for_fake_path( + self, mock_check_output, mock_run_main + ): + # Mock the git rev-parse --git-dir call + mock_run_main.return_value = MagicMock(returncode=0) + + mock_check_output.return_value = "/repo/root\n" + + # Mock the git check-ignore call + with patch("project_context.utils.subprocess.run") as mock_run_check: + mock_run_check.return_value = MagicMock(returncode=0) + result = is_path_gitignored("/repo/root/.env") + assert result is False