• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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