Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
89 changes: 47 additions & 42 deletions src/project_context/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 3 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down
8 changes: 5 additions & 3 deletions tests/test_tree.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
46 changes: 42 additions & 4 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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