1#!/usr/bin/env python3 2# Copyright 2024 The Pigweed Authors 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); you may not 5# use this file except in compliance with the License. You may obtain a copy of 6# the License at 7# 8# https://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13# License for the specific language governing permissions and limitations under 14# the License. 15"""git repo module tests""" 16 17import os 18from pathlib import Path 19from subprocess import CompletedProcess 20from typing import Dict 21import re 22import shlex 23import unittest 24from unittest import mock 25 26from pw_cli.tool_runner import ToolRunner 27from pw_cli.git_repo import GitRepo, GitRepoFinder 28from pyfakefs import fake_filesystem_unittest 29 30 31class FakeGitToolRunner(ToolRunner): 32 def __init__(self, command_results: Dict[str, CompletedProcess]): 33 self._results = command_results 34 35 def _run_tool(self, tool: str, args, **kwargs) -> CompletedProcess: 36 full_command = shlex.join((tool, *tuple(args))) 37 for cmd, result in self._results.items(): 38 if cmd in full_command: 39 return result 40 41 return CompletedProcess( 42 args=full_command, 43 returncode=0xFF, 44 stderr=f'I do not know how to `{full_command}`'.encode(), 45 stdout=b'Failed to execute command', 46 ) 47 48 49def git_ok(cmd: str, stdout: str) -> CompletedProcess: 50 return CompletedProcess( 51 args=cmd, 52 returncode=0, 53 stderr='', 54 stdout=stdout.encode(), 55 ) 56 57 58def git_err(cmd: str, stderr: str, returncode: int = 1) -> CompletedProcess: 59 return CompletedProcess( 60 args=cmd, 61 returncode=returncode, 62 stderr=stderr.encode(), 63 stdout='', 64 ) 65 66 67class TestGitRepo(unittest.TestCase): 68 """Tests for git_repo.py""" 69 70 GIT_ROOT = Path("/dev/null/test").resolve() 71 SUBMODULES = [ 72 Path("/dev/null/test/third_party/pigweed").resolve(), 73 Path("/dev/null/test/vendor/anycom/p1").resolve(), 74 Path("/dev/null/test/vendor/anycom/p2").resolve(), 75 ] 76 GIT_SUBMODULES_OUT = "\n".join([str(x) for x in SUBMODULES]) 77 78 EXPECTED_SUBMODULE_LIST_CMD = shlex.join( 79 ( 80 'submodule', 81 'foreach', 82 '--quiet', 83 '--recursive', 84 'echo $toplevel/$sm_path', 85 ) 86 ) 87 88 def make_fake_git_repo(self, cmds): 89 return GitRepo(self.GIT_ROOT, FakeGitToolRunner(cmds)) 90 91 def test_mock_root(self): 92 """Ensure our mock works since so many of our tests depend upon it.""" 93 cmds = {} 94 repo = self.make_fake_git_repo(cmds) 95 self.assertEqual(repo.root(), self.GIT_ROOT) 96 97 def test_list_submodules_1(self): 98 """Ensures the root git repo appears in the submodule list.""" 99 cmds = { 100 self.EXPECTED_SUBMODULE_LIST_CMD: git_ok( 101 self.EXPECTED_SUBMODULE_LIST_CMD, self.GIT_SUBMODULES_OUT 102 ) 103 } 104 repo = self.make_fake_git_repo(cmds) 105 paths = repo.list_submodules() 106 self.assertNotIn(self.GIT_ROOT, paths) 107 108 def test_list_submodules_2(self): 109 cmds = { 110 self.EXPECTED_SUBMODULE_LIST_CMD: git_ok( 111 self.EXPECTED_SUBMODULE_LIST_CMD, self.GIT_SUBMODULES_OUT 112 ) 113 } 114 repo = self.make_fake_git_repo(cmds) 115 paths = repo.list_submodules() 116 self.assertIn(self.SUBMODULES[2], paths) 117 118 def test_list_submodules_with_exclude_str(self): 119 cmds = { 120 self.EXPECTED_SUBMODULE_LIST_CMD: git_ok( 121 self.EXPECTED_SUBMODULE_LIST_CMD, self.GIT_SUBMODULES_OUT 122 ) 123 } 124 repo = self.make_fake_git_repo(cmds) 125 paths = repo.list_submodules( 126 excluded_paths=(self.GIT_ROOT.as_posix(),), 127 ) 128 self.assertNotIn(self.GIT_ROOT, paths) 129 130 def test_list_submodules_with_exclude_regex(self): 131 cmds = { 132 self.EXPECTED_SUBMODULE_LIST_CMD: git_ok( 133 self.EXPECTED_SUBMODULE_LIST_CMD, self.GIT_SUBMODULES_OUT 134 ) 135 } 136 repo = self.make_fake_git_repo(cmds) 137 paths = repo.list_submodules( 138 excluded_paths=(re.compile("third_party/.*"),), 139 ) 140 self.assertNotIn(self.SUBMODULES[0], paths) 141 142 def test_list_submodules_with_exclude_str_miss(self): 143 cmds = { 144 self.EXPECTED_SUBMODULE_LIST_CMD: git_ok( 145 self.EXPECTED_SUBMODULE_LIST_CMD, self.GIT_SUBMODULES_OUT 146 ) 147 } 148 repo = self.make_fake_git_repo(cmds) 149 paths = repo.list_submodules( 150 excluded_paths=(re.compile("pigweed"),), 151 ) 152 self.assertIn(self.SUBMODULES[-1], paths) 153 154 def test_list_submodules_with_exclude_regex_miss_1(self): 155 cmds = { 156 self.EXPECTED_SUBMODULE_LIST_CMD: git_ok( 157 self.EXPECTED_SUBMODULE_LIST_CMD, self.GIT_SUBMODULES_OUT 158 ) 159 } 160 repo = self.make_fake_git_repo(cmds) 161 paths = repo.list_submodules( 162 excluded_paths=(re.compile("foo/.*"),), 163 ) 164 self.assertNotIn(self.GIT_ROOT, paths) 165 for module in self.SUBMODULES: 166 self.assertIn(module, paths) 167 168 def test_list_submodules_with_exclude_regex_miss_2(self): 169 cmds = { 170 self.EXPECTED_SUBMODULE_LIST_CMD: git_ok( 171 self.EXPECTED_SUBMODULE_LIST_CMD, self.GIT_SUBMODULES_OUT 172 ) 173 } 174 repo = self.make_fake_git_repo(cmds) 175 paths = repo.list_submodules( 176 excluded_paths=(re.compile("pigweed"),), 177 ) 178 self.assertNotIn(self.GIT_ROOT, paths) 179 for module in self.SUBMODULES: 180 self.assertIn(module, paths) 181 182 def test_list_files_unknown_hash(self): 183 bad_cmd = "diff --name-only --diff-filter=d 'something' --" 184 good_cmd = 'ls-files --' 185 fake_path = 'path/to/foo.h' 186 cmds = { 187 bad_cmd: git_err(bad_cmd, "fatal: bad revision 'something'"), 188 good_cmd: git_ok(good_cmd, fake_path + '\n'), 189 } 190 191 expected_file_path = self.GIT_ROOT / Path(fake_path) 192 repo = self.make_fake_git_repo(cmds) 193 194 # This function needs to be mocked because it does a `is_file()` check 195 # on returned paths. Since we're not using real files, nothing will 196 # be yielded. 197 repo._ls_files = mock.MagicMock( # pylint: disable=protected-access 198 return_value=[expected_file_path] 199 ) 200 paths = repo.list_files(commit='something') 201 self.assertIn(expected_file_path, paths) 202 203 def test_fake_uncommitted_changes(self): 204 index_update = 'update-index -q --refresh' 205 diff_index = 'diff-index --quiet HEAD --' 206 cmds = { 207 index_update: git_ok(index_update, ''), 208 diff_index: git_err(diff_index, '', returncode=1), 209 } 210 repo = self.make_fake_git_repo(cmds) 211 self.assertTrue(repo.has_uncommitted_changes()) 212 213 214def _resolve(path: str) -> str: 215 """Needed to make Windows happy. 216 217 Since resolved paths start with drive letters, any literal string 218 paths in these tests need to be resolved so they are prefixed with `C:`. 219 """ 220 # Avoid manipulation on other OSes since they don't strictly require it. 221 if os.name != 'nt': 222 return path 223 return str(Path(path).resolve()) 224 225 226class TestGitRepoFinder(fake_filesystem_unittest.TestCase): 227 """Tests for GitRepoFinder.""" 228 229 FAKE_ROOT = _resolve('/dev/null/fake/root') 230 FAKE_NESTED_REPO = _resolve('/dev/null/fake/root/third_party/bogus') 231 232 def setUp(self): 233 self.setUpPyfakefs() 234 self.fs.create_dir(self.FAKE_ROOT) 235 os.chdir(self.FAKE_ROOT) 236 237 def test_cwd_is_root(self): 238 """Tests when cwd is the root of a repo.""" 239 expected_repo_query = shlex.join( 240 ( 241 '-C', 242 '.', 243 'rev-parse', 244 '--show-toplevel', 245 ) 246 ) 247 runner = FakeGitToolRunner( 248 {expected_repo_query: git_ok(expected_repo_query, self.FAKE_ROOT)} 249 ) 250 finder = GitRepoFinder(runner) 251 path_to_search = '.' 252 maybe_repo = finder.find_git_repo(path_to_search) 253 self.assertNotEqual( 254 maybe_repo, None, f'Could not resolve {path_to_search}' 255 ) 256 self.assertEqual(maybe_repo.root(), Path(self.FAKE_ROOT)) 257 258 def test_cwd_is_not_repo(self): 259 """Tests when cwd is not tracked by a repo.""" 260 expected_repo_query = shlex.join( 261 ( 262 '-C', 263 '.', 264 'rev-parse', 265 '--show-toplevel', 266 ) 267 ) 268 runner = FakeGitToolRunner( 269 {expected_repo_query: git_err(expected_repo_query, self.FAKE_ROOT)} 270 ) 271 finder = GitRepoFinder(runner) 272 self.assertEqual(finder.find_git_repo('.'), None) 273 274 def test_file(self): 275 """Tests a file at the root of a repo.""" 276 expected_repo_query = shlex.join( 277 ( 278 '-C', 279 '.', 280 'rev-parse', 281 '--show-toplevel', 282 ) 283 ) 284 runner = FakeGitToolRunner( 285 {expected_repo_query: git_ok(expected_repo_query, self.FAKE_ROOT)} 286 ) 287 finder = GitRepoFinder(runner) 288 path_to_search = 'foo.txt' 289 self.fs.create_file(path_to_search) 290 maybe_repo = finder.find_git_repo(path_to_search) 291 self.assertNotEqual( 292 maybe_repo, None, f'Could not resolve {path_to_search}' 293 ) 294 self.assertEqual(maybe_repo.root(), Path(self.FAKE_ROOT)) 295 296 def test_parents_memoized(self): 297 """Tests multiple queries that are optimized via memoization.""" 298 expected_repo_query = shlex.join( 299 ( 300 '-C', 301 str(Path('subdir/nested')), 302 'rev-parse', 303 '--show-toplevel', 304 ) 305 ) 306 runner = FakeGitToolRunner( 307 {expected_repo_query: git_ok(expected_repo_query, self.FAKE_ROOT)} 308 ) 309 finder = GitRepoFinder(runner) 310 311 # Because of the ordering, only ONE call to git should be necessary. 312 paths = [ 313 'subdir/nested/foo.txt', 314 'subdir/bar.txt', 315 'subdir/nested/baz.txt', 316 'bleh.txt', 317 ] 318 for file_to_find in paths: 319 self.fs.create_file(file_to_find) 320 maybe_repo = finder.find_git_repo(file_to_find) 321 self.assertNotEqual( 322 maybe_repo, None, f'Could not resolve {file_to_find}' 323 ) 324 self.assertEqual(maybe_repo.root(), Path(self.FAKE_ROOT)) 325 326 def test_absolute_path(self): 327 """Test that absolute paths hit memoized paths.""" 328 expected_repo_query = shlex.join( 329 ( 330 '-C', 331 str(Path('subdir/nested')), 332 'rev-parse', 333 '--show-toplevel', 334 ) 335 ) 336 runner = FakeGitToolRunner( 337 {expected_repo_query: git_ok(expected_repo_query, self.FAKE_ROOT)} 338 ) 339 finder = GitRepoFinder(runner) 340 341 # Because of the ordering, only ONE call to git should be necessary. 342 paths = [ 343 'subdir/nested/foo.txt', 344 _resolve(f'{self.FAKE_ROOT}/subdir/bar.txt'), 345 ] 346 for file_to_find in paths: 347 self.fs.create_file(file_to_find) 348 maybe_repo = finder.find_git_repo(file_to_find) 349 self.assertNotEqual( 350 maybe_repo, None, f'Could not resolve {file_to_find}' 351 ) 352 self.assertEqual(maybe_repo.root(), Path(self.FAKE_ROOT)) 353 354 def test_subdir(self): 355 """Test that querying a dir properly memoizes things.""" 356 expected_repo_query = shlex.join( 357 ( 358 '-C', 359 'subdir', 360 'rev-parse', 361 '--show-toplevel', 362 ) 363 ) 364 runner = FakeGitToolRunner( 365 {expected_repo_query: git_ok(expected_repo_query, self.FAKE_ROOT)} 366 ) 367 finder = GitRepoFinder(runner) 368 369 dir_to_check = 'subdir' 370 self.fs.create_dir(dir_to_check) 371 maybe_repo = finder.find_git_repo(dir_to_check) 372 self.assertNotEqual( 373 maybe_repo, None, f'Could not resolve {dir_to_check}' 374 ) 375 self.assertEqual(maybe_repo.root(), Path(self.FAKE_ROOT)) 376 377 def test_nested_repo(self): 378 """Test a nested repo works as expected.""" 379 expected_inner_repo_query = shlex.join( 380 ( 381 '-C', 382 str(Path('third_party/bogus/test')), 383 'rev-parse', 384 '--show-toplevel', 385 ) 386 ) 387 expected_outer_repo_query = shlex.join( 388 ( 389 '-C', 390 'test', 391 'rev-parse', 392 '--show-toplevel', 393 ) 394 ) 395 runner = FakeGitToolRunner( 396 { 397 expected_inner_repo_query: git_ok( 398 expected_inner_repo_query, self.FAKE_NESTED_REPO 399 ), 400 expected_outer_repo_query: git_ok( 401 expected_outer_repo_query, self.FAKE_ROOT 402 ), 403 } 404 ) 405 finder = GitRepoFinder(runner) 406 407 inner_repo_file = "third_party/bogus/test/baz.txt" 408 self.fs.create_file(inner_repo_file) 409 maybe_repo = finder.find_git_repo(inner_repo_file) 410 self.assertNotEqual( 411 maybe_repo, None, f'Could not resolve {inner_repo_file}' 412 ) 413 self.assertEqual(maybe_repo.root(), Path(self.FAKE_NESTED_REPO)) 414 415 outer_repo_file = "test/baz.txt" 416 self.fs.create_file(outer_repo_file) 417 maybe_repo = finder.find_git_repo(outer_repo_file) 418 self.assertNotEqual( 419 maybe_repo, None, f'Could not resolve {outer_repo_file}' 420 ) 421 self.assertEqual(maybe_repo.root(), Path(self.FAKE_ROOT)) 422 423 def test_absolute_repo_not_under_cwd(self): 424 """Test an absolute path that isn't a subdir of cwd works.""" 425 fake_parallel_repo = _resolve('/dev/null/fake/parallel') 426 expected_repo_query = shlex.join( 427 ( 428 '-C', 429 _resolve('/dev/null/fake/parallel/yep'), 430 'rev-parse', 431 '--show-toplevel', 432 ) 433 ) 434 runner = FakeGitToolRunner( 435 { 436 expected_repo_query: git_ok( 437 expected_repo_query, fake_parallel_repo 438 ) 439 } 440 ) 441 finder = GitRepoFinder(runner) 442 path_to_search = _resolve('/dev/null/fake/parallel/yep/foo.txt') 443 self.fs.create_file(path_to_search) 444 maybe_repo = finder.find_git_repo(path_to_search) 445 self.assertNotEqual( 446 maybe_repo, None, f'Could not resolve {path_to_search}' 447 ) 448 self.assertEqual(maybe_repo.root(), Path(fake_parallel_repo)) 449 450 def test_absolute_not_under_cwd(self): 451 """Test files not tracked by a repo.""" 452 expected_repo_query = shlex.join( 453 ( 454 '-C', 455 _resolve('/dev/null/fake/parallel/yep'), 456 'rev-parse', 457 '--show-toplevel', 458 ) 459 ) 460 runner = FakeGitToolRunner( 461 {expected_repo_query: git_err(expected_repo_query, '')} 462 ) 463 finder = GitRepoFinder(runner) 464 # Because of the ordering, only ONE call to git should be necessary. 465 paths = [ 466 _resolve('/dev/null/fake/parallel/yep/foo.txt'), 467 _resolve('/dev/null/fake/bar.txt'), 468 _resolve('/dev/null/fake/parallel/yep'), 469 ] 470 for file_to_find in paths: 471 if file_to_find.endswith('.txt'): 472 self.fs.create_file(file_to_find) 473 self.assertEqual(finder.find_git_repo(file_to_find), None) 474 475 def test_make_pathspec_relative(self): 476 """Tests that pathspec relativization works.""" 477 expected_queries = ( 478 ( 479 shlex.join( 480 ( 481 '-C', 482 str(Path('george/one')), 483 'rev-parse', 484 '--show-toplevel', 485 ) 486 ), 487 self.FAKE_ROOT, 488 ), 489 ( 490 shlex.join( 491 ( 492 '-C', 493 str(Path('third_party/bogus')), 494 'rev-parse', 495 '--show-toplevel', 496 ) 497 ), 498 self.FAKE_NESTED_REPO, 499 ), 500 ( 501 shlex.join( 502 ( 503 '-C', 504 str(Path('third_party/bogus/frob')), 505 'rev-parse', 506 '--show-toplevel', 507 ) 508 ), 509 self.FAKE_NESTED_REPO, 510 ), 511 ) 512 runner = FakeGitToolRunner( 513 { 514 expected_args: git_ok(expected_args, repo) 515 for expected_args, repo in expected_queries 516 } 517 ) 518 finder = GitRepoFinder(runner) 519 520 files = [ 521 'george/one/two.txt', 522 'third_party/bogus/sad.png', 523 ] 524 for file_to_find in files: 525 self.fs.create_file(file_to_find) 526 self.fs.create_dir('third_party/bogus/frob') 527 528 pathspecs = { 529 'george/one/two.txt': str(Path('george/one/two.txt')), 530 'a/': 'a', 531 'third_party/bogus/sad.png': 'sad.png', 532 'third_party/bogus/': '.', 533 'third_party/bogus/frob/j*': str(Path('frob/j*')), 534 } 535 for pathspec, expected in pathspecs.items(): 536 maybe_repo, relativized = finder.make_pathspec_relative(pathspec) 537 self.assertNotEqual( 538 maybe_repo, None, f'Could not resolve {pathspec}' 539 ) 540 self.assertEqual(relativized, expected) 541 542 def test_make_pathspec_relative_untracked(self): 543 """Tests that untracked files work with relativization.""" 544 expected_repo_query = shlex.join( 545 ( 546 '-C', 547 str(Path('subdir/nested')), 548 'rev-parse', 549 '--show-toplevel', 550 ) 551 ) 552 runner = FakeGitToolRunner( 553 {expected_repo_query: git_err(expected_repo_query, '')} 554 ) 555 finder = GitRepoFinder(runner) 556 557 self.fs.create_file('george/one/two.txt') 558 559 pathspecs = { 560 'george/one/two.txt': 'george/one/two.txt', 561 } 562 for pathspec, expected in pathspecs.items(): 563 maybe_repo, relativized = finder.make_pathspec_relative(pathspec) 564 self.assertEqual( 565 maybe_repo, None, f'Unexpectedly resolved {pathspec}' 566 ) 567 self.assertEqual(relativized, expected) 568 569 def test_make_pathspec_relative_absolute(self): 570 """Tests that absolute paths work with relativization.""" 571 expected_repo_query = shlex.join( 572 ( 573 '-C', 574 _resolve('/dev/null/fake/root/third_party/bogus/one'), 575 'rev-parse', 576 '--show-toplevel', 577 ) 578 ) 579 runner = FakeGitToolRunner( 580 { 581 expected_repo_query: git_ok( 582 expected_repo_query, self.FAKE_NESTED_REPO 583 ) 584 } 585 ) 586 finder = GitRepoFinder(runner) 587 588 self.fs.create_file('third_party/bogus/one/two.txt') 589 590 pathspecs = { 591 _resolve('/dev/null/fake/root/third_party/bogus/one/two.txt'): str( 592 Path('one/two.txt') 593 ), 594 } 595 for pathspec, expected in pathspecs.items(): 596 maybe_repo, relativized = finder.make_pathspec_relative(pathspec) 597 self.assertNotEqual( 598 maybe_repo, None, f'Could not resolve {pathspec}' 599 ) 600 self.assertEqual(relativized, expected) 601 602 603if __name__ == '__main__': 604 unittest.main() 605