Skip to content

Commit 167f0f9

Browse files
committed
tests(_internal[copy]): Add comprehensive tests for copy utilities
what: - Add copytree_reflink scenario tests (simple, ignore, empty) - Add fallback tests for cp failure, not found, OS error - Add integration tests for git-like structure copying
1 parent ada596f commit 167f0f9

File tree

1 file changed

+389
-0
lines changed

1 file changed

+389
-0
lines changed

tests/_internal/test_copy.py

Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
"""Tests for libvcs._internal.copy."""
2+
3+
from __future__ import annotations
4+
5+
import shutil
6+
import subprocess
7+
import typing as t
8+
from pathlib import Path
9+
from unittest.mock import MagicMock, patch
10+
11+
import pytest
12+
13+
from libvcs._internal.copy import _apply_ignore_patterns, copytree_reflink
14+
15+
# =============================================================================
16+
# copytree_reflink Tests
17+
# =============================================================================
18+
19+
20+
class CopyFixture(t.NamedTuple):
21+
"""Test fixture for copytree_reflink scenarios."""
22+
23+
test_id: str
24+
setup_files: dict[str, str] # filename -> content
25+
ignore_pattern: str | None
26+
expected_files: list[str]
27+
description: str
28+
29+
30+
COPY_FIXTURES = [
31+
CopyFixture(
32+
test_id="simple_copy",
33+
setup_files={"file.txt": "hello", "subdir/nested.txt": "world"},
34+
ignore_pattern=None,
35+
expected_files=["file.txt", "subdir/nested.txt"],
36+
description="Copy all files without ignore patterns",
37+
),
38+
CopyFixture(
39+
test_id="ignore_pyc",
40+
setup_files={
41+
"keep.py": "code",
42+
"skip.pyc": "compiled",
43+
"subdir/keep2.py": "more code",
44+
"subdir/skip2.pyc": "also compiled",
45+
},
46+
ignore_pattern="*.pyc",
47+
expected_files=["keep.py", "subdir/keep2.py"],
48+
description="Ignore .pyc files",
49+
),
50+
CopyFixture(
51+
test_id="ignore_directory",
52+
setup_files={
53+
"keep.txt": "keep",
54+
"__pycache__/cached.pyc": "cache",
55+
"src/code.py": "code",
56+
},
57+
ignore_pattern="__pycache__",
58+
expected_files=["keep.txt", "src/code.py"],
59+
description="Ignore __pycache__ directory",
60+
),
61+
CopyFixture(
62+
test_id="empty_directory",
63+
setup_files={},
64+
ignore_pattern=None,
65+
expected_files=[],
66+
description="Copy empty directory",
67+
),
68+
]
69+
70+
71+
class TestCopytreeReflink:
72+
"""Tests for copytree_reflink function."""
73+
74+
@pytest.mark.parametrize(
75+
list(CopyFixture._fields),
76+
COPY_FIXTURES,
77+
ids=[f.test_id for f in COPY_FIXTURES],
78+
)
79+
def test_copy_scenarios(
80+
self,
81+
tmp_path: Path,
82+
test_id: str,
83+
setup_files: dict[str, str],
84+
ignore_pattern: str | None,
85+
expected_files: list[str],
86+
description: str,
87+
) -> None:
88+
"""Test various copy scenarios."""
89+
# Setup source directory
90+
src = tmp_path / "source"
91+
src.mkdir()
92+
for filename, content in setup_files.items():
93+
file_path = src / filename
94+
file_path.parent.mkdir(parents=True, exist_ok=True)
95+
file_path.write_text(content)
96+
97+
dst = tmp_path / "dest"
98+
ignore = shutil.ignore_patterns(ignore_pattern) if ignore_pattern else None
99+
100+
# Perform copy
101+
result = copytree_reflink(src, dst, ignore=ignore)
102+
103+
# Verify result
104+
assert result == dst
105+
assert dst.exists()
106+
107+
# Verify expected files exist
108+
for expected_file in expected_files:
109+
expected_path = dst / expected_file
110+
assert expected_path.exists(), f"Expected {expected_file} to exist"
111+
112+
# Verify ignored files don't exist (if pattern was provided)
113+
if ignore_pattern and setup_files:
114+
for filename in setup_files:
115+
if filename.endswith(ignore_pattern.replace("*", "")):
116+
file_exists = (dst / filename).exists()
117+
assert not file_exists, f"{filename} should be ignored"
118+
119+
def test_preserves_content(self, tmp_path: Path) -> None:
120+
"""Test that file contents are preserved."""
121+
src = tmp_path / "source"
122+
src.mkdir()
123+
(src / "file.txt").write_text("original content")
124+
125+
dst = tmp_path / "dest"
126+
copytree_reflink(src, dst)
127+
128+
assert (dst / "file.txt").read_text() == "original content"
129+
130+
def test_creates_parent_directories(self, tmp_path: Path) -> None:
131+
"""Test that parent directories are created if needed."""
132+
src = tmp_path / "source"
133+
src.mkdir()
134+
(src / "file.txt").write_text("content")
135+
136+
dst = tmp_path / "deep" / "nested" / "dest"
137+
copytree_reflink(src, dst)
138+
139+
assert dst.exists()
140+
assert (dst / "file.txt").exists()
141+
142+
def test_fallback_on_cp_failure(self, tmp_path: Path) -> None:
143+
"""Test fallback to shutil.copytree when cp fails."""
144+
src = tmp_path / "source"
145+
src.mkdir()
146+
(src / "file.txt").write_text("content")
147+
148+
dst = tmp_path / "dest"
149+
150+
# Mock subprocess.run to simulate cp failure
151+
with patch("subprocess.run") as mock_run:
152+
mock_run.side_effect = subprocess.CalledProcessError(1, "cp")
153+
result = copytree_reflink(src, dst)
154+
155+
assert result == dst
156+
assert (dst / "file.txt").exists()
157+
158+
def test_fallback_on_cp_not_found(self, tmp_path: Path) -> None:
159+
"""Test fallback when cp command is not found (e.g., Windows)."""
160+
src = tmp_path / "source"
161+
src.mkdir()
162+
(src / "file.txt").write_text("content")
163+
164+
dst = tmp_path / "dest"
165+
166+
# Mock subprocess.run to simulate FileNotFoundError
167+
with patch("subprocess.run") as mock_run:
168+
mock_run.side_effect = FileNotFoundError("cp not found")
169+
result = copytree_reflink(src, dst)
170+
171+
assert result == dst
172+
assert (dst / "file.txt").exists()
173+
174+
def test_fallback_on_os_error(self, tmp_path: Path) -> None:
175+
"""Test fallback on OSError."""
176+
src = tmp_path / "source"
177+
src.mkdir()
178+
(src / "file.txt").write_text("content")
179+
180+
dst = tmp_path / "dest"
181+
182+
# Mock subprocess.run to simulate OSError
183+
with patch("subprocess.run") as mock_run:
184+
mock_run.side_effect = OSError("Unexpected error")
185+
result = copytree_reflink(src, dst)
186+
187+
assert result == dst
188+
assert (dst / "file.txt").exists()
189+
190+
def test_uses_cp_reflink_auto(self, tmp_path: Path) -> None:
191+
"""Test that cp --reflink=auto is attempted first."""
192+
src = tmp_path / "source"
193+
src.mkdir()
194+
(src / "file.txt").write_text("content")
195+
196+
dst = tmp_path / "dest"
197+
198+
with patch("subprocess.run") as mock_run:
199+
# Simulate successful cp
200+
mock_run.return_value = MagicMock(returncode=0)
201+
copytree_reflink(src, dst)
202+
203+
# Verify cp was called with correct arguments
204+
mock_run.assert_called_once()
205+
call_args = mock_run.call_args
206+
assert call_args[0][0] == ["cp", "-a", "--reflink=auto", str(src), str(dst)]
207+
208+
def test_returns_pathlib_path(self, tmp_path: Path) -> None:
209+
"""Test that result is always a pathlib.Path."""
210+
src = tmp_path / "source"
211+
src.mkdir()
212+
213+
dst = tmp_path / "dest"
214+
result = copytree_reflink(src, dst)
215+
216+
assert isinstance(result, Path)
217+
218+
219+
# =============================================================================
220+
# _apply_ignore_patterns Tests
221+
# =============================================================================
222+
223+
224+
class IgnorePatternFixture(t.NamedTuple):
225+
"""Test fixture for ignore pattern scenarios."""
226+
227+
test_id: str
228+
setup_files: list[str]
229+
ignore_pattern: str
230+
expected_remaining: list[str]
231+
description: str
232+
233+
234+
IGNORE_PATTERN_FIXTURES = [
235+
IgnorePatternFixture(
236+
test_id="ignore_pyc",
237+
setup_files=["keep.py", "skip.pyc"],
238+
ignore_pattern="*.pyc",
239+
expected_remaining=["keep.py"],
240+
description="Remove .pyc files",
241+
),
242+
IgnorePatternFixture(
243+
test_id="ignore_directory",
244+
setup_files=["keep.txt", "__pycache__/file.pyc"],
245+
ignore_pattern="__pycache__",
246+
expected_remaining=["keep.txt"],
247+
description="Remove __pycache__ directory",
248+
),
249+
IgnorePatternFixture(
250+
test_id="nested_pattern",
251+
setup_files=["a/keep.txt", "a/b/skip.tmp", "a/c/keep2.txt"],
252+
ignore_pattern="*.tmp",
253+
expected_remaining=["a/keep.txt", "a/c/keep2.txt"],
254+
description="Remove nested .tmp files",
255+
),
256+
]
257+
258+
259+
class TestApplyIgnorePatterns:
260+
"""Tests for _apply_ignore_patterns function."""
261+
262+
@pytest.mark.parametrize(
263+
list(IgnorePatternFixture._fields),
264+
IGNORE_PATTERN_FIXTURES,
265+
ids=[f.test_id for f in IGNORE_PATTERN_FIXTURES],
266+
)
267+
def test_ignore_pattern_scenarios(
268+
self,
269+
tmp_path: Path,
270+
test_id: str,
271+
setup_files: list[str],
272+
ignore_pattern: str,
273+
expected_remaining: list[str],
274+
description: str,
275+
) -> None:
276+
"""Test various ignore pattern scenarios."""
277+
# Setup directory with files
278+
for filepath in setup_files:
279+
file_path = tmp_path / filepath
280+
file_path.parent.mkdir(parents=True, exist_ok=True)
281+
file_path.write_text("content")
282+
283+
# Apply ignore patterns
284+
ignore = shutil.ignore_patterns(ignore_pattern)
285+
_apply_ignore_patterns(tmp_path, ignore)
286+
287+
# Verify expected files remain
288+
for expected in expected_remaining:
289+
assert (tmp_path / expected).exists(), f"Expected {expected} to remain"
290+
291+
# Verify ignored files are removed
292+
for filepath in setup_files:
293+
if filepath not in expected_remaining:
294+
# Check if file or any parent directory was removed
295+
full_path = tmp_path / filepath
296+
assert not full_path.exists(), f"Expected {filepath} to be removed"
297+
298+
def test_empty_directory(self, tmp_path: Path) -> None:
299+
"""Test ignore patterns on empty directory."""
300+
ignore = shutil.ignore_patterns("*.pyc")
301+
# Should not raise
302+
_apply_ignore_patterns(tmp_path, ignore)
303+
304+
def test_no_matches(self, tmp_path: Path) -> None:
305+
"""Test when no files match ignore pattern."""
306+
(tmp_path / "file.txt").write_text("content")
307+
(tmp_path / "other.py").write_text("code")
308+
309+
ignore = shutil.ignore_patterns("*.pyc")
310+
_apply_ignore_patterns(tmp_path, ignore)
311+
312+
# All files should remain
313+
assert (tmp_path / "file.txt").exists()
314+
assert (tmp_path / "other.py").exists()
315+
316+
317+
# =============================================================================
318+
# Integration Tests
319+
# =============================================================================
320+
321+
322+
class TestCopyIntegration:
323+
"""Integration tests for copy operations."""
324+
325+
def test_copy_git_like_structure(self, tmp_path: Path) -> None:
326+
"""Test copying a git-like directory structure."""
327+
src = tmp_path / "repo"
328+
src.mkdir()
329+
330+
# Create git-like structure
331+
(src / ".git" / "HEAD").parent.mkdir(parents=True)
332+
(src / ".git" / "HEAD").write_text("ref: refs/heads/main")
333+
(src / ".git" / "config").write_text("[core]\nrepositoryformatversion = 0")
334+
(src / "README.md").write_text("# Project")
335+
(src / "src" / "main.py").parent.mkdir(parents=True)
336+
(src / "src" / "main.py").write_text("print('hello')")
337+
338+
dst = tmp_path / "clone"
339+
copytree_reflink(src, dst)
340+
341+
# Verify structure
342+
assert (dst / ".git" / "HEAD").exists()
343+
assert (dst / ".git" / "config").exists()
344+
assert (dst / "README.md").exists()
345+
assert (dst / "src" / "main.py").exists()
346+
347+
# Verify content
348+
assert (dst / ".git" / "HEAD").read_text() == "ref: refs/heads/main"
349+
assert (dst / "README.md").read_text() == "# Project"
350+
351+
def test_copy_with_marker_file_ignore(self, tmp_path: Path) -> None:
352+
"""Test ignoring marker files like fixtures do."""
353+
src = tmp_path / "master"
354+
src.mkdir()
355+
356+
(src / ".libvcs_master_initialized").write_text("")
357+
(src / ".git" / "HEAD").parent.mkdir(parents=True)
358+
(src / ".git" / "HEAD").write_text("ref: refs/heads/main")
359+
(src / "file.txt").write_text("content")
360+
361+
dst = tmp_path / "workspace"
362+
copytree_reflink(
363+
src,
364+
dst,
365+
ignore=shutil.ignore_patterns(".libvcs_master_initialized"),
366+
)
367+
368+
# Marker file should be ignored
369+
assert not (dst / ".libvcs_master_initialized").exists()
370+
371+
# Other files should be copied
372+
assert (dst / ".git" / "HEAD").exists()
373+
assert (dst / "file.txt").exists()
374+
375+
def test_workspace_is_writable(self, tmp_path: Path) -> None:
376+
"""Test that copied files are writable (important for test fixtures)."""
377+
src = tmp_path / "source"
378+
src.mkdir()
379+
(src / "file.txt").write_text("original")
380+
381+
dst = tmp_path / "dest"
382+
copytree_reflink(src, dst)
383+
384+
# Modify copied file (tests should be able to do this)
385+
(dst / "file.txt").write_text("modified")
386+
assert (dst / "file.txt").read_text() == "modified"
387+
388+
# Original should be unchanged
389+
assert (src / "file.txt").read_text() == "original"

0 commit comments

Comments
 (0)