550 lines
17 KiB
Python
550 lines
17 KiB
Python
"""Tests for file listing functionality."""
|
|
|
|
import os
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from ra_aid.file_listing import (
|
|
DirectoryAccessError,
|
|
DirectoryNotFoundError,
|
|
FileListerError,
|
|
GitCommandError,
|
|
get_file_listing,
|
|
is_git_repo,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def empty_git_repo(tmp_path):
|
|
"""Create an empty git repository."""
|
|
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True)
|
|
return tmp_path
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_git_repo(empty_git_repo):
|
|
"""Create a git repository with sample files."""
|
|
# Create some files
|
|
files = [
|
|
"README.md",
|
|
"src/main.py",
|
|
"src/utils.py",
|
|
"tests/test_main.py",
|
|
"docs/index.html",
|
|
]
|
|
|
|
for file_path in files:
|
|
full_path = empty_git_repo / file_path
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
full_path.write_text(f"Content of {file_path}")
|
|
|
|
# Add and commit files
|
|
subprocess.run(["git", "add", "."], cwd=empty_git_repo)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "Initial commit"],
|
|
cwd=empty_git_repo,
|
|
env={
|
|
"GIT_AUTHOR_NAME": "Test",
|
|
"GIT_AUTHOR_EMAIL": "test@example.com",
|
|
"GIT_COMMITTER_NAME": "Test",
|
|
"GIT_COMMITTER_EMAIL": "test@example.com",
|
|
},
|
|
)
|
|
|
|
return empty_git_repo
|
|
|
|
|
|
@pytest.fixture
|
|
def git_repo_with_untracked(sample_git_repo):
|
|
"""Create a git repository with both tracked and untracked files."""
|
|
# Create untracked files
|
|
untracked_files = [
|
|
"untracked.txt",
|
|
"src/untracked.py",
|
|
"docs/draft.md"
|
|
]
|
|
|
|
for file_path in untracked_files:
|
|
full_path = sample_git_repo / file_path
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
full_path.write_text(f"Untracked content of {file_path}")
|
|
|
|
return sample_git_repo
|
|
|
|
|
|
@pytest.fixture
|
|
def git_repo_with_ignores(git_repo_with_untracked):
|
|
"""Create a git repository with .gitignore rules."""
|
|
# Create .gitignore file
|
|
gitignore_content = """
|
|
# Python
|
|
__pycache__/
|
|
*.pyc
|
|
|
|
# Project specific
|
|
*.log
|
|
temp/
|
|
ignored.txt
|
|
docs/draft.md
|
|
"""
|
|
gitignore_path = git_repo_with_untracked / ".gitignore"
|
|
gitignore_path.write_text(gitignore_content)
|
|
|
|
# Add and commit .gitignore first
|
|
subprocess.run(["git", "add", ".gitignore"], cwd=git_repo_with_untracked)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "Add .gitignore"],
|
|
cwd=git_repo_with_untracked,
|
|
env={
|
|
"GIT_AUTHOR_NAME": "Test",
|
|
"GIT_AUTHOR_EMAIL": "test@example.com",
|
|
"GIT_COMMITTER_NAME": "Test",
|
|
"GIT_COMMITTER_EMAIL": "test@example.com",
|
|
},
|
|
)
|
|
|
|
# Create some ignored files
|
|
ignored_files = [
|
|
"ignored.txt",
|
|
"temp/temp.txt",
|
|
"src/__pycache__/main.cpython-39.pyc"
|
|
]
|
|
|
|
for file_path in ignored_files:
|
|
full_path = git_repo_with_untracked / file_path
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
full_path.write_text(f"Ignored content of {file_path}")
|
|
|
|
return git_repo_with_untracked
|
|
|
|
|
|
@pytest.fixture
|
|
def git_repo_with_aider_files(sample_git_repo):
|
|
"""Create a git repository with .aider files that should be ignored."""
|
|
# Create .aider files
|
|
aider_files = [
|
|
".aider.chat.history.md",
|
|
".aider.input.history",
|
|
".aider.tags.cache.v3/some_file",
|
|
"src/.aider.local.settings"
|
|
]
|
|
|
|
# Create regular files
|
|
regular_files = [
|
|
"main.cpp",
|
|
"src/helper.cpp"
|
|
]
|
|
|
|
# Create all files
|
|
for file_path in aider_files + regular_files:
|
|
full_path = sample_git_repo / file_path
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
full_path.write_text(f"Content of {file_path}")
|
|
|
|
# Add all files (both .aider and regular) to git
|
|
subprocess.run(["git", "add", "."], cwd=sample_git_repo)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "Add files including .aider"],
|
|
cwd=sample_git_repo,
|
|
env={
|
|
"GIT_AUTHOR_NAME": "Test",
|
|
"GIT_AUTHOR_EMAIL": "test@example.com",
|
|
"GIT_COMMITTER_NAME": "Test",
|
|
"GIT_COMMITTER_EMAIL": "test@example.com",
|
|
},
|
|
)
|
|
|
|
return sample_git_repo
|
|
|
|
|
|
def test_is_git_repo(sample_git_repo, tmp_path_factory):
|
|
"""Test git repository detection."""
|
|
# Create a new directory that is not a git repository
|
|
non_repo_dir = tmp_path_factory.mktemp("non_repo")
|
|
# Assert that sample_git_repo is identified as a git repository
|
|
assert is_git_repo(str(sample_git_repo)) is True
|
|
# Assert that non_repo_dir is not identified as a git repository
|
|
assert is_git_repo(str(non_repo_dir)) is False
|
|
|
|
|
|
def test_get_file_listing_no_limit(sample_git_repo):
|
|
"""Test getting complete file listing."""
|
|
files, total = get_file_listing(str(sample_git_repo))
|
|
assert len(files) == 5
|
|
assert total == 5
|
|
assert "README.md" in files
|
|
assert "src/main.py" in files
|
|
assert all(isinstance(f, str) for f in files)
|
|
|
|
|
|
def test_get_file_listing_with_limit(sample_git_repo):
|
|
"""Test file listing with limit."""
|
|
files, total = get_file_listing(str(sample_git_repo), limit=2)
|
|
assert len(files) == 2
|
|
assert total == 5 # Total should still be 5
|
|
|
|
|
|
def test_empty_git_repo(empty_git_repo):
|
|
"""Test handling of empty git repository."""
|
|
files, total = get_file_listing(str(empty_git_repo))
|
|
assert len(files) == 0
|
|
assert total == 0
|
|
|
|
|
|
def test_non_git_directory(tmp_path):
|
|
"""Test handling of non-git directory."""
|
|
files, total = get_file_listing(str(tmp_path))
|
|
assert len(files) == 0
|
|
assert total == 0
|
|
|
|
|
|
def test_nonexistent_directory():
|
|
"""Test handling of non-existent directory."""
|
|
with pytest.raises(DirectoryNotFoundError):
|
|
get_file_listing("/nonexistent/path/123456")
|
|
|
|
|
|
def test_file_as_directory(tmp_path):
|
|
"""Test handling of file path instead of directory."""
|
|
test_file = tmp_path / "test.txt"
|
|
test_file.write_text("test")
|
|
|
|
with pytest.raises(DirectoryNotFoundError):
|
|
get_file_listing(str(test_file))
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="Permission tests unreliable on Windows")
|
|
def test_permission_error(tmp_path):
|
|
"""Test handling of permission errors."""
|
|
try:
|
|
# Make directory unreadable
|
|
os.chmod(tmp_path, 0o000)
|
|
|
|
with pytest.raises(DirectoryAccessError):
|
|
get_file_listing(str(tmp_path))
|
|
finally:
|
|
# Restore permissions to allow cleanup
|
|
os.chmod(tmp_path, 0o755)
|
|
|
|
|
|
# Constants for test data
|
|
DUMMY_PATH = "dummy/path"
|
|
EMPTY_FILE_LIST = []
|
|
EMPTY_FILE_TOTAL = 0
|
|
SINGLE_FILE_NAME = "file1.txt"
|
|
MULTI_FILE_NAMES = ["file1.txt", "file2.py", "file3.md"]
|
|
|
|
# Test cases for get_file_listing
|
|
FILE_LISTING_TEST_CASES = [
|
|
{
|
|
"name": "empty_repository",
|
|
"git_output": "",
|
|
"expected_files": EMPTY_FILE_LIST,
|
|
"expected_total": EMPTY_FILE_TOTAL,
|
|
"limit": None,
|
|
},
|
|
{
|
|
"name": "single_file",
|
|
"git_output": f"{SINGLE_FILE_NAME}\n",
|
|
"expected_files": [SINGLE_FILE_NAME],
|
|
"expected_total": 1,
|
|
"limit": None,
|
|
},
|
|
{
|
|
"name": "multiple_files",
|
|
"git_output": "\n".join(MULTI_FILE_NAMES) + "\n",
|
|
"expected_files": MULTI_FILE_NAMES,
|
|
"expected_total": len(MULTI_FILE_NAMES),
|
|
"limit": None,
|
|
},
|
|
{
|
|
"name": "duplicate_files",
|
|
"git_output": "\n".join(
|
|
[SINGLE_FILE_NAME, SINGLE_FILE_NAME] + MULTI_FILE_NAMES[1:]
|
|
)
|
|
+ "\n",
|
|
"expected_files": [SINGLE_FILE_NAME] + MULTI_FILE_NAMES[1:],
|
|
"expected_total": 3, # After deduplication
|
|
"limit": None,
|
|
},
|
|
{
|
|
"name": "with_limit",
|
|
"git_output": "\n".join(MULTI_FILE_NAMES) + "\n",
|
|
"expected_files": MULTI_FILE_NAMES[:2],
|
|
"expected_total": len(MULTI_FILE_NAMES),
|
|
"limit": 2,
|
|
},
|
|
{
|
|
"name": "with_empty_lines",
|
|
"git_output": f"\n{SINGLE_FILE_NAME}\n\n{MULTI_FILE_NAMES[1]}\n\n",
|
|
"expected_files": [SINGLE_FILE_NAME, MULTI_FILE_NAMES[1]],
|
|
"expected_total": 2,
|
|
"limit": None,
|
|
},
|
|
{
|
|
"name": "with_whitespace",
|
|
"git_output": f" {SINGLE_FILE_NAME} \n {MULTI_FILE_NAMES[1]} \n",
|
|
"expected_files": [SINGLE_FILE_NAME, MULTI_FILE_NAMES[1]],
|
|
"expected_total": 2,
|
|
"limit": None,
|
|
},
|
|
{
|
|
"name": "limit_larger_than_total",
|
|
"git_output": f"{SINGLE_FILE_NAME}\n{MULTI_FILE_NAMES[1]}\n",
|
|
"expected_files": [SINGLE_FILE_NAME, MULTI_FILE_NAMES[1]],
|
|
"expected_total": 2,
|
|
"limit": 5,
|
|
},
|
|
{
|
|
"name": "limit_zero",
|
|
"git_output": "\n".join(MULTI_FILE_NAMES) + "\n",
|
|
"expected_files": EMPTY_FILE_LIST,
|
|
"expected_total": len(MULTI_FILE_NAMES),
|
|
"limit": 0,
|
|
},
|
|
{
|
|
"name": "nested_paths",
|
|
"git_output": "dir1/file1.txt\ndir1/dir2/file2.py\nfile3.md\n",
|
|
"expected_files": sorted(["dir1/file1.txt", "dir1/dir2/file2.py", "file3.md"]),
|
|
"expected_total": 3,
|
|
"limit": None,
|
|
},
|
|
{
|
|
"name": "special_characters",
|
|
"git_output": "file-1.txt\nfile_2.py\nfile 3.md\n",
|
|
"expected_files": sorted(["file-1.txt", "file_2.py", "file 3.md"]),
|
|
"expected_total": 3,
|
|
"limit": None,
|
|
},
|
|
{
|
|
"name": "duplicate_nested_paths",
|
|
"git_output": "dir1/file1.txt\ndir1/file1.txt\ndir2/file1.txt\n",
|
|
"expected_files": sorted(["dir1/file1.txt", "dir2/file1.txt"]),
|
|
"expected_total": 2,
|
|
"limit": None,
|
|
},
|
|
]
|
|
|
|
|
|
def create_mock_process(git_output: str) -> MagicMock:
|
|
"""Create a mock process with the given git output."""
|
|
mock_process = MagicMock()
|
|
mock_process.stdout = git_output
|
|
mock_process.returncode = 0
|
|
return mock_process
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_subprocess():
|
|
"""Fixture to mock subprocess.run."""
|
|
with patch("subprocess.run") as mock_run:
|
|
yield mock_run
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_is_git_repo():
|
|
"""Fixture to mock is_git_repo function."""
|
|
with patch("ra_aid.file_listing.is_git_repo") as mock:
|
|
mock.return_value = True
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_os_path(monkeypatch):
|
|
"""Mock os.path functions."""
|
|
def mock_exists(path):
|
|
return True
|
|
|
|
def mock_isdir(path):
|
|
return True
|
|
|
|
monkeypatch.setattr(os.path, 'exists', mock_exists)
|
|
monkeypatch.setattr(os.path, 'isdir', mock_isdir)
|
|
return monkeypatch
|
|
|
|
|
|
@pytest.mark.parametrize("test_case", FILE_LISTING_TEST_CASES, ids=lambda x: x["name"])
|
|
def test_get_file_listing(test_case, mock_subprocess, mock_is_git_repo, mock_os_path):
|
|
"""Test get_file_listing with various inputs."""
|
|
mock_subprocess.return_value = create_mock_process(test_case["git_output"])
|
|
files, total = get_file_listing(DUMMY_PATH, limit=test_case["limit"])
|
|
|
|
assert files == test_case["expected_files"]
|
|
assert total == test_case["expected_total"]
|
|
|
|
|
|
def test_get_file_listing_non_git_repo(mock_is_git_repo, mock_os_path):
|
|
"""Test get_file_listing with non-git repository."""
|
|
mock_is_git_repo.return_value = False
|
|
files, total = get_file_listing(DUMMY_PATH)
|
|
assert files == []
|
|
assert total == 0
|
|
|
|
|
|
def test_get_file_listing_git_error(mock_subprocess, mock_is_git_repo, mock_os_path):
|
|
"""Test get_file_listing when git command fails."""
|
|
mock_subprocess.side_effect = GitCommandError("Git command failed")
|
|
with pytest.raises(GitCommandError):
|
|
get_file_listing(DUMMY_PATH)
|
|
|
|
|
|
def test_get_file_listing_permission_error(mock_subprocess, mock_is_git_repo, mock_os_path):
|
|
"""Test get_file_listing with permission error."""
|
|
mock_subprocess.side_effect = PermissionError("Permission denied")
|
|
with pytest.raises(DirectoryAccessError):
|
|
get_file_listing(DUMMY_PATH)
|
|
|
|
|
|
def test_get_file_listing_unexpected_error(mock_subprocess, mock_is_git_repo, mock_os_path):
|
|
"""Test get_file_listing with unexpected error."""
|
|
mock_subprocess.side_effect = Exception("Unexpected error")
|
|
with pytest.raises(FileListerError):
|
|
get_file_listing(DUMMY_PATH)
|
|
|
|
|
|
def test_get_file_listing_with_untracked(git_repo_with_untracked):
|
|
"""Test that file listing includes both tracked and untracked files."""
|
|
files, count = get_file_listing(str(git_repo_with_untracked))
|
|
|
|
# Check tracked files are present
|
|
assert "README.md" in files
|
|
assert "src/main.py" in files
|
|
|
|
# Check untracked files are present
|
|
assert "untracked.txt" in files
|
|
assert "src/untracked.py" in files
|
|
|
|
# Verify count includes both tracked and untracked
|
|
expected_count = 8 # 5 tracked + 3 untracked (excluding .gitignore)
|
|
assert count == expected_count
|
|
|
|
def test_get_file_listing_with_untracked_and_limit(git_repo_with_untracked):
|
|
"""Test that file listing with limit works correctly with untracked files."""
|
|
limit = 3
|
|
files, count = get_file_listing(str(git_repo_with_untracked), limit=limit)
|
|
|
|
# Total count should still be full count
|
|
assert count == 8 # 5 tracked + 3 untracked (excluding .gitignore)
|
|
|
|
# Only limit number of files should be returned
|
|
assert len(files) == limit
|
|
|
|
# Files should be sorted, so we can check first 3
|
|
assert files == sorted(files)
|
|
|
|
def test_get_file_listing_respects_gitignore(git_repo_with_ignores):
|
|
"""Test that file listing respects .gitignore rules."""
|
|
# First test with hidden files excluded (default)
|
|
files, count = get_file_listing(str(git_repo_with_ignores))
|
|
|
|
# These files should be included (tracked or untracked but not ignored)
|
|
assert "README.md" in files
|
|
assert "src/main.py" in files
|
|
assert "untracked.txt" in files
|
|
assert "src/untracked.py" in files
|
|
|
|
# .gitignore should be excluded as it's hidden
|
|
assert ".gitignore" not in files
|
|
|
|
# These files should be excluded (ignored)
|
|
assert "ignored.txt" not in files
|
|
assert "temp/temp.txt" not in files
|
|
assert "src/__pycache__/main.cpython-39.pyc" not in files
|
|
assert "docs/draft.md" not in files # Explicitly ignored in .gitignore
|
|
|
|
# Count should include non-ignored, non-hidden files
|
|
expected_count = 7 # 4 tracked + 2 untracked (excluding .gitignore)
|
|
assert count == expected_count
|
|
|
|
# Now test with hidden files included
|
|
files, count = get_file_listing(str(git_repo_with_ignores), include_hidden=True)
|
|
|
|
# .gitignore should now be included
|
|
assert ".gitignore" in files
|
|
|
|
# Count should include non-ignored files plus .gitignore
|
|
expected_count = 8 # 5 tracked + 2 untracked + .gitignore
|
|
assert count == expected_count
|
|
|
|
def test_aider_files_excluded(git_repo_with_aider_files):
|
|
"""Test that .aider files are excluded from the file listing."""
|
|
files, count = get_file_listing(str(git_repo_with_aider_files))
|
|
|
|
# Regular files should be included
|
|
assert "main.cpp" in files
|
|
assert "src/helper.cpp" in files
|
|
|
|
# .aider files should be excluded
|
|
assert ".aider.chat.history.md" not in files
|
|
assert ".aider.input.history" not in files
|
|
assert ".aider.tags.cache.v3/some_file" not in files
|
|
assert "src/.aider.local.settings" not in files
|
|
|
|
# Only the regular files should be counted
|
|
expected_count = 7 # 5 original files from sample_git_repo + 2 new regular files
|
|
assert count == expected_count
|
|
assert len(files) == expected_count
|
|
|
|
def test_hidden_files_excluded_by_default(git_repo_with_aider_files):
|
|
"""Test that hidden files are excluded by default."""
|
|
# Create some hidden files
|
|
hidden_files = [
|
|
".config",
|
|
".env",
|
|
"src/.local",
|
|
".gitattributes"
|
|
]
|
|
|
|
# Create regular files
|
|
regular_files = [
|
|
"main.cpp",
|
|
"src/helper.cpp"
|
|
]
|
|
|
|
# Create all files
|
|
for file_path in hidden_files + regular_files:
|
|
full_path = git_repo_with_aider_files / file_path
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
full_path.write_text(f"Content of {file_path}")
|
|
|
|
# Add all files to git
|
|
subprocess.run(["git", "add", "."], cwd=git_repo_with_aider_files)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "Add files including hidden files"],
|
|
cwd=git_repo_with_aider_files,
|
|
env={
|
|
"GIT_AUTHOR_NAME": "Test",
|
|
"GIT_AUTHOR_EMAIL": "test@example.com",
|
|
"GIT_COMMITTER_NAME": "Test",
|
|
"GIT_COMMITTER_EMAIL": "test@example.com",
|
|
},
|
|
)
|
|
|
|
# Test default behavior (hidden files excluded)
|
|
files, count = get_file_listing(str(git_repo_with_aider_files))
|
|
|
|
# Regular files should be included
|
|
assert "main.cpp" in files
|
|
assert "src/helper.cpp" in files
|
|
|
|
# Hidden files should be excluded
|
|
for hidden_file in hidden_files:
|
|
assert hidden_file not in files
|
|
|
|
# Only regular files should be counted
|
|
assert count == 7 # 5 original files + 2 new regular files
|
|
|
|
# Test with include_hidden=True
|
|
files, count = get_file_listing(str(git_repo_with_aider_files), include_hidden=True)
|
|
|
|
# Both regular and hidden files should be included
|
|
assert "main.cpp" in files
|
|
assert "src/helper.cpp" in files
|
|
for hidden_file in hidden_files:
|
|
assert hidden_file in files
|
|
|
|
# All files should be counted
|
|
assert count == 11 # 5 original + 2 regular + 4 hidden
|