|
10 | 10 | import os
|
11 | 11 | import os.path as osp
|
12 | 12 | from pathlib import Path
|
| 13 | +import pickle |
13 | 14 | import re
|
14 | 15 | import shutil
|
15 | 16 | import subprocess
|
16 | 17 | import sys
|
17 |
| -from tempfile import TemporaryFile |
| 18 | +import tempfile |
18 | 19 | from unittest import skipUnless
|
19 | 20 |
|
20 | 21 | if sys.version_info >= (3, 8):
|
@@ -67,6 +68,32 @@ def _rollback_refresh():
|
67 | 68 | refresh()
|
68 | 69 |
|
69 | 70 |
|
| 71 | +@contextlib.contextmanager |
| 72 | +def _fake_git(*version_info): |
| 73 | + fake_version = ".".join(map(str, version_info)) |
| 74 | + fake_output = f"git version {fake_version} (fake)" |
| 75 | + |
| 76 | + with tempfile.TemporaryDirectory() as tdir: |
| 77 | + if os.name == "nt": |
| 78 | + fake_git = Path(tdir, "fake-git.cmd") |
| 79 | + script = f"@echo {fake_output}\n" |
| 80 | + fake_git.write_text(script, encoding="utf-8") |
| 81 | + else: |
| 82 | + fake_git = Path(tdir, "fake-git") |
| 83 | + script = f"#!/bin/sh\necho '{fake_output}'\n" |
| 84 | + fake_git.write_text(script, encoding="utf-8") |
| 85 | + fake_git.chmod(0o755) |
| 86 | + |
| 87 | + yield str(fake_git.absolute()) |
| 88 | + |
| 89 | + |
| 90 | +def _rename_with_stem(path, new_stem): |
| 91 | + if sys.version_info >= (3, 9): |
| 92 | + path.rename(path.with_stem(new_stem)) |
| 93 | + else: |
| 94 | + path.rename(path.with_name(new_stem + path.suffix)) |
| 95 | + |
| 96 | + |
70 | 97 | @ddt.ddt
|
71 | 98 | class TestGit(TestBase):
|
72 | 99 | @classmethod
|
@@ -260,13 +287,9 @@ def test_it_ignores_false_kwargs(self, git):
|
260 | 287 | self.assertTrue("pass_this_kwarg" not in git.call_args[1])
|
261 | 288 |
|
262 | 289 | def test_it_raises_proper_exception_with_output_stream(self):
|
263 |
| - tmp_file = TemporaryFile() |
264 |
| - self.assertRaises( |
265 |
| - GitCommandError, |
266 |
| - self.git.checkout, |
267 |
| - "non-existent-branch", |
268 |
| - output_stream=tmp_file, |
269 |
| - ) |
| 290 | + with tempfile.TemporaryFile() as tmp_file: |
| 291 | + with self.assertRaises(GitCommandError): |
| 292 | + self.git.checkout("non-existent-branch", output_stream=tmp_file) |
270 | 293 |
|
271 | 294 | def test_it_accepts_environment_variables(self):
|
272 | 295 | filename = fixture_path("ls_tree_empty")
|
@@ -314,12 +337,38 @@ def test_persistent_cat_file_command(self):
|
314 | 337 | self.assertEqual(typename, typename_two)
|
315 | 338 | self.assertEqual(size, size_two)
|
316 | 339 |
|
317 |
| - def test_version(self): |
| 340 | + def test_version_info(self): |
| 341 | + """The version_info attribute is a tuple of up to four ints.""" |
318 | 342 | v = self.git.version_info
|
319 | 343 | self.assertIsInstance(v, tuple)
|
| 344 | + self.assertLessEqual(len(v), 4) |
320 | 345 | for n in v:
|
321 | 346 | self.assertIsInstance(n, int)
|
322 |
| - # END verify number types |
| 347 | + |
| 348 | + def test_version_info_pickleable(self): |
| 349 | + """The version_info attribute is usable on unpickled Git instances.""" |
| 350 | + deserialized = pickle.loads(pickle.dumps(self.git)) |
| 351 | + v = deserialized.version_info |
| 352 | + self.assertIsInstance(v, tuple) |
| 353 | + self.assertLessEqual(len(v), 4) |
| 354 | + for n in v: |
| 355 | + self.assertIsInstance(n, int) |
| 356 | + |
| 357 | + @ddt.data( |
| 358 | + (("123", "456", "789"), (123, 456, 789)), |
| 359 | + (("12", "34", "56", "78"), (12, 34, 56, 78)), |
| 360 | + (("12", "34", "56", "78", "90"), (12, 34, 56, 78)), |
| 361 | + (("1", "2", "a", "3"), (1, 2)), |
| 362 | + (("1", "-2", "3"), (1,)), |
| 363 | + (("1", "2a", "3"), (1,)), # Subject to change. |
| 364 | + ) |
| 365 | + def test_version_info_is_leading_numbers(self, case): |
| 366 | + fake_fields, expected_version_info = case |
| 367 | + with _rollback_refresh(): |
| 368 | + with _fake_git(*fake_fields) as path: |
| 369 | + refresh(path) |
| 370 | + new_git = Git() |
| 371 | + self.assertEqual(new_git.version_info, expected_version_info) |
323 | 372 |
|
324 | 373 | def test_git_exc_name_is_git(self):
|
325 | 374 | self.assertEqual(self.git.git_exec_name, "git")
|
@@ -487,6 +536,150 @@ def test_refresh_with_good_relative_git_path_arg(self):
|
487 | 536 | refresh(basename)
|
488 | 537 | self.assertEqual(self.git.GIT_PYTHON_GIT_EXECUTABLE, absolute_path)
|
489 | 538 |
|
| 539 | + def test_version_info_is_cached(self): |
| 540 | + fake_version_info = (123, 456, 789) |
| 541 | + with _rollback_refresh(): |
| 542 | + with _fake_git(*fake_version_info) as path: |
| 543 | + new_git = Git() # Not cached yet. |
| 544 | + refresh(path) |
| 545 | + self.assertEqual(new_git.version_info, fake_version_info) |
| 546 | + os.remove(path) # Arrange that a second subprocess call would fail. |
| 547 | + self.assertEqual(new_git.version_info, fake_version_info) |
| 548 | + |
| 549 | + def test_version_info_cache_is_per_instance(self): |
| 550 | + with _rollback_refresh(): |
| 551 | + with _fake_git(123, 456, 789) as path: |
| 552 | + git1 = Git() |
| 553 | + git2 = Git() |
| 554 | + refresh(path) |
| 555 | + git1.version_info |
| 556 | + os.remove(path) # Arrange that the second subprocess call will fail. |
| 557 | + with self.assertRaises(GitCommandNotFound): |
| 558 | + git2.version_info |
| 559 | + git1.version_info |
| 560 | + |
| 561 | + def test_version_info_cache_is_not_pickled(self): |
| 562 | + with _rollback_refresh(): |
| 563 | + with _fake_git(123, 456, 789) as path: |
| 564 | + git1 = Git() |
| 565 | + refresh(path) |
| 566 | + git1.version_info |
| 567 | + git2 = pickle.loads(pickle.dumps(git1)) |
| 568 | + os.remove(path) # Arrange that the second subprocess call will fail. |
| 569 | + with self.assertRaises(GitCommandNotFound): |
| 570 | + git2.version_info |
| 571 | + git1.version_info |
| 572 | + |
| 573 | + def test_successful_refresh_with_arg_invalidates_cached_version_info(self): |
| 574 | + with _rollback_refresh(): |
| 575 | + with _fake_git(11, 111, 1) as path1: |
| 576 | + with _fake_git(22, 222, 2) as path2: |
| 577 | + new_git = Git() |
| 578 | + refresh(path1) |
| 579 | + new_git.version_info |
| 580 | + refresh(path2) |
| 581 | + self.assertEqual(new_git.version_info, (22, 222, 2)) |
| 582 | + |
| 583 | + def test_failed_refresh_with_arg_does_not_invalidate_cached_version_info(self): |
| 584 | + with _rollback_refresh(): |
| 585 | + with _fake_git(11, 111, 1) as path1: |
| 586 | + with _fake_git(22, 222, 2) as path2: |
| 587 | + new_git = Git() |
| 588 | + refresh(path1) |
| 589 | + new_git.version_info |
| 590 | + os.remove(path1) # Arrange that a repeat call for path1 would fail. |
| 591 | + os.remove(path2) # Arrange that the new call for path2 will fail. |
| 592 | + with self.assertRaises(GitCommandNotFound): |
| 593 | + refresh(path2) |
| 594 | + self.assertEqual(new_git.version_info, (11, 111, 1)) |
| 595 | + |
| 596 | + def test_successful_refresh_with_same_arg_invalidates_cached_version_info(self): |
| 597 | + """Changing git at the same path and refreshing affects version_info.""" |
| 598 | + with _rollback_refresh(): |
| 599 | + with _fake_git(11, 111, 1) as path1: |
| 600 | + with _fake_git(22, 222, 2) as path2: |
| 601 | + new_git = Git() |
| 602 | + refresh(path1) |
| 603 | + new_git.version_info |
| 604 | + shutil.copy(path2, path1) |
| 605 | + refresh(path1) # The fake git at path1 has a different version now. |
| 606 | + self.assertEqual(new_git.version_info, (22, 222, 2)) |
| 607 | + |
| 608 | + def test_successful_refresh_with_env_invalidates_cached_version_info(self): |
| 609 | + with contextlib.ExitStack() as stack: |
| 610 | + stack.enter_context(_rollback_refresh()) |
| 611 | + path1 = stack.enter_context(_fake_git(11, 111, 1)) |
| 612 | + path2 = stack.enter_context(_fake_git(22, 222, 2)) |
| 613 | + with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path1}): |
| 614 | + new_git = Git() |
| 615 | + refresh() |
| 616 | + new_git.version_info |
| 617 | + with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path2}): |
| 618 | + refresh() |
| 619 | + self.assertEqual(new_git.version_info, (22, 222, 2)) |
| 620 | + |
| 621 | + def test_failed_refresh_with_env_does_not_invalidate_cached_version_info(self): |
| 622 | + with contextlib.ExitStack() as stack: |
| 623 | + stack.enter_context(_rollback_refresh()) |
| 624 | + path1 = stack.enter_context(_fake_git(11, 111, 1)) |
| 625 | + path2 = stack.enter_context(_fake_git(22, 222, 2)) |
| 626 | + with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path1}): |
| 627 | + new_git = Git() |
| 628 | + refresh() |
| 629 | + new_git.version_info |
| 630 | + os.remove(path1) # Arrange that a repeat call for path1 would fail. |
| 631 | + os.remove(path2) # Arrange that the new call for path2 will fail. |
| 632 | + with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path2}): |
| 633 | + with self.assertRaises(GitCommandNotFound): |
| 634 | + refresh(path2) |
| 635 | + self.assertEqual(new_git.version_info, (11, 111, 1)) |
| 636 | + |
| 637 | + def test_successful_refresh_with_same_env_invalidates_cached_version_info(self): |
| 638 | + """Changing git at the same path/command and refreshing affects version_info.""" |
| 639 | + with contextlib.ExitStack() as stack: |
| 640 | + stack.enter_context(_rollback_refresh()) |
| 641 | + path1 = stack.enter_context(_fake_git(11, 111, 1)) |
| 642 | + path2 = stack.enter_context(_fake_git(22, 222, 2)) |
| 643 | + with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path1}): |
| 644 | + new_git = Git() |
| 645 | + refresh() |
| 646 | + new_git.version_info |
| 647 | + shutil.copy(path2, path1) |
| 648 | + refresh() # The fake git at path1 has a different version now. |
| 649 | + self.assertEqual(new_git.version_info, (22, 222, 2)) |
| 650 | + |
| 651 | + def test_successful_default_refresh_invalidates_cached_version_info(self): |
| 652 | + """Refreshing updates version after a filesystem change adds a git command.""" |
| 653 | + # The key assertion here is the last. The others mainly verify the test itself. |
| 654 | + with contextlib.ExitStack() as stack: |
| 655 | + stack.enter_context(_rollback_refresh()) |
| 656 | + |
| 657 | + path1 = Path(stack.enter_context(_fake_git(11, 111, 1))) |
| 658 | + path2 = Path(stack.enter_context(_fake_git(22, 222, 2))) |
| 659 | + |
| 660 | + new_path_var = f"{path1.parent}{os.pathsep}{path2.parent}" |
| 661 | + stack.enter_context(mock.patch.dict(os.environ, {"PATH": new_path_var})) |
| 662 | + stack.enter_context(_patch_out_env("GIT_PYTHON_GIT_EXECUTABLE")) |
| 663 | + |
| 664 | + if os.name == "nt": |
| 665 | + # On Windows, use a shell so "git" finds "git.cmd". (In the infrequent |
| 666 | + # case that this effect is desired in production code, it should not be |
| 667 | + # done with this technique. USE_SHELL=True is less secure and reliable, |
| 668 | + # as unintended shell expansions can occur, and is deprecated. Instead, |
| 669 | + # use a custom command, by setting the GIT_PYTHON_GIT_EXECUTABLE |
| 670 | + # environment variable to git.cmd or by passing git.cmd's full path to |
| 671 | + # git.refresh. Or wrap the script with a .exe shim. |
| 672 | + stack.enter_context(mock.patch.object(Git, "USE_SHELL", True)) |
| 673 | + |
| 674 | + new_git = Git() |
| 675 | + _rename_with_stem(path2, "git") # "Install" git, "late" in the PATH. |
| 676 | + refresh() |
| 677 | + self.assertEqual(new_git.version_info, (22, 222, 2), 'before "downgrade"') |
| 678 | + _rename_with_stem(path1, "git") # "Install" another, higher priority. |
| 679 | + self.assertEqual(new_git.version_info, (22, 222, 2), "stale version") |
| 680 | + refresh() |
| 681 | + self.assertEqual(new_git.version_info, (11, 111, 1), "fresh version") |
| 682 | + |
490 | 683 | def test_options_are_passed_to_git(self):
|
491 | 684 | # This works because any command after git --version is ignored.
|
492 | 685 | git_version = self.git(version=True).NoOp()
|
|
0 commit comments