| #!/usr/bin/env fuchsia-vendored-python | |
| # Copyright 2026 The Fuchsia Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Unit tests for tools/devshell/lib/scm.py.""" | |
| import pathlib | |
| import subprocess | |
| import tempfile | |
| import unittest | |
| from unittest import mock | |
| import scm | |
| class ScmTest(unittest.TestCase): | |
| def setUp(self) -> None: | |
| self.temp_dir = self.enterContext(tempfile.TemporaryDirectory()) | |
| self.root = pathlib.Path(self.temp_dir).resolve() | |
| # Create mock root repo with shac.textproto | |
| (self.root / ".git").mkdir(parents=True) | |
| (self.root / "shac.textproto").touch() | |
| # Create mock vendor repo with shac.textproto | |
| self.vendor_repo = self.root / "vendor" / "google" | |
| self.vendor_repo.mkdir(parents=True) | |
| (self.vendor_repo / ".git").mkdir(parents=True) | |
| (self.vendor_repo / "shac.textproto").touch() | |
| # Create non-repo vendor dir | |
| (self.root / "vendor" / "not_a_repo").mkdir(parents=True) | |
| def test_find_active_repositories(self) -> None: | |
| # Add another vendor repo to verify alphabetical sorting between vendor repos | |
| arm_repo = self.root / "vendor" / "arm" | |
| arm_repo.mkdir(parents=True) | |
| (arm_repo / ".git").mkdir(parents=True) | |
| groups = scm.get_repo_groups(self.root, self.root) | |
| self.assertSequenceEqual( | |
| [group.repo_root for group in groups], | |
| [self.root, arm_repo, self.vendor_repo], | |
| ) | |
| def test_find_active_repositories_no_vendor(self) -> None: | |
| no_vendor_dir = self.enterContext(tempfile.TemporaryDirectory()) | |
| no_vendor_root = pathlib.Path(no_vendor_dir).resolve() | |
| (no_vendor_root / ".git").mkdir() | |
| groups = scm.get_repo_groups(no_vendor_root, no_vendor_root) | |
| self.assertSequenceEqual( | |
| [group.repo_root for group in groups], [no_vendor_root] | |
| ) | |
| def test_check_has_shac(self) -> None: | |
| # shac.star without shac.textproto does not count | |
| star_only_repo = self.root / "vendor" / "star_only" | |
| star_only_repo.mkdir(parents=True) | |
| (star_only_repo / ".git").mkdir() | |
| (star_only_repo / "shac.star").touch() | |
| # shac.textproto as a directory does not count | |
| shac_as_dir_repo = self.root / "vendor" / "shac_dir" | |
| shac_as_dir_repo.mkdir(parents=True) | |
| (shac_as_dir_repo / ".git").mkdir() | |
| (shac_as_dir_repo / "shac.textproto").mkdir() | |
| groups_by_root = { | |
| group.repo_root: group | |
| for group in scm.get_repo_groups(self.root, self.root) | |
| } | |
| self.assertTrue(groups_by_root[self.root].has_shac) | |
| self.assertTrue(groups_by_root[self.vendor_repo].has_shac) | |
| self.assertFalse(groups_by_root[star_only_repo].has_shac) | |
| self.assertFalse(groups_by_root[shac_as_dir_repo].has_shac) | |
| def test_get_repo_root_for_path(self) -> None: | |
| git_dir = self.enterContext(tempfile.TemporaryDirectory()) | |
| git_root = pathlib.Path(git_dir).resolve() | |
| (git_root / ".git").mkdir() | |
| target_file = git_root / "nested" / "file.txt" | |
| target_file.parent.mkdir(parents=True) | |
| target_file.touch() | |
| groups = scm.get_repo_groups( | |
| self.root, self.root, explicit_files=[str(target_file)] | |
| ) | |
| self.assertEqual(len(groups), 1) | |
| self.assertEqual(groups[0].repo_root, git_root) | |
| self.assertSequenceEqual(groups[0].files, ("nested/file.txt",)) | |
| def test_get_repo_root_for_path_git_rev_parse_fallback(self) -> None: | |
| worktree_dir = self.enterContext(tempfile.TemporaryDirectory()) | |
| worktree_root = pathlib.Path(worktree_dir).resolve() | |
| target_file = worktree_root / "file.txt" | |
| target_file.touch() | |
| with mock.patch.object( | |
| subprocess, | |
| "run", | |
| autospec=True, | |
| return_value=subprocess.CompletedProcess( | |
| args=[], returncode=0, stdout=f"{worktree_root}\n" | |
| ), | |
| ) as mock_run: | |
| groups = scm.get_repo_groups( | |
| self.root, self.root, explicit_files=[str(target_file)] | |
| ) | |
| self.assertEqual(len(groups), 1) | |
| self.assertEqual(groups[0].repo_root, worktree_root) | |
| mock_run.assert_called_once() | |
| def test_canonicalize_path(self) -> None: | |
| cwd = self.root / "src" | |
| cwd.mkdir() | |
| # GN-style // | |
| gn_path = scm.canonicalize_path("//tools/test.py", cwd, self.root) | |
| self.assertEqual(gn_path, self.root / "tools" / "test.py") | |
| # Root alias // | |
| root_alias_path = scm.canonicalize_path("//", cwd, self.root) | |
| self.assertEqual(root_alias_path, self.root) | |
| # Extra leading slashes /// | |
| extra_slashes_path = scm.canonicalize_path( | |
| "///tools/test.py", cwd, self.root | |
| ) | |
| self.assertEqual(extra_slashes_path, self.root / "tools" / "test.py") | |
| # Relative to cwd | |
| relative_path = scm.canonicalize_path("foo.py", cwd, self.root) | |
| self.assertEqual(relative_path, cwd / "foo.py") | |
| # Absolute | |
| expected_abs_path = (self.root / "bar.py").resolve() | |
| abs_path = scm.canonicalize_path(str(expected_abs_path), cwd, self.root) | |
| self.assertEqual(abs_path, expected_abs_path) | |
| def test_canonicalize_path_preserves_leaf_symlink(self) -> None: | |
| target = self.root / "target.py" | |
| target.touch() | |
| symlink = self.vendor_repo / "link.py" | |
| symlink.symlink_to(target) | |
| resolved = scm.canonicalize_path( | |
| "//vendor/google/link.py", self.root, self.root | |
| ) | |
| self.assertEqual(resolved, self.vendor_repo / "link.py") | |
| groups = scm.get_repo_groups( | |
| self.root, self.root, explicit_files=["//vendor/google/link.py"] | |
| ) | |
| self.assertEqual(len(groups), 1) | |
| self.assertEqual(groups[0].repo_root, self.vendor_repo) | |
| self.assertSequenceEqual(groups[0].files, ("link.py",)) | |
| def test_get_repo_groups_default_mode(self) -> None: | |
| groups = scm.get_repo_groups(self.root, self.root) | |
| self.assertEqual(len(groups), 2) | |
| # Verify root repo is first | |
| self.assertEqual(groups[0].repo_root, self.root) | |
| self.assertEqual(groups[1].repo_root, self.vendor_repo) | |
| for group in groups: | |
| self.assertTrue(group.has_shac) | |
| self.assertSequenceEqual(group.files, ()) | |
| def test_get_repo_groups_default_mode_includes_cwd_subrepo(self) -> None: | |
| subrepo = self.root / "integration" | |
| subrepo.mkdir(parents=True) | |
| (subrepo / ".git").mkdir() | |
| (subrepo / "shac.textproto").touch() | |
| groups = scm.get_repo_groups(self.root, cwd=subrepo) | |
| self.assertEqual(len(groups), 3) | |
| self.assertEqual(groups[0].repo_root, self.root) | |
| self.assertEqual(groups[1].repo_root, subrepo) | |
| self.assertEqual(groups[2].repo_root, self.vendor_repo) | |
| def test_get_repo_groups_explicit_files(self) -> None: | |
| # Pass files in reverse order with duplicates to verify sorting and deduplication | |
| explicit = [ | |
| "//vendor/google/bar.py", | |
| "//src/z_test.cc", | |
| "//src/a_test.cc", | |
| "//src/a_test.cc", | |
| "//src/uncreated_file.cc", | |
| ] | |
| groups = scm.get_repo_groups( | |
| self.root, self.root, explicit_files=explicit | |
| ) | |
| self.assertEqual(len(groups), 2) | |
| # Root repo is always first in returned groups | |
| self.assertEqual(groups[0].repo_root, self.root) | |
| self.assertEqual(groups[1].repo_root, self.vendor_repo) | |
| # Files within each group are sorted and deduplicated, including uncreated files | |
| self.assertSequenceEqual( | |
| groups[0].files, | |
| ("src/a_test.cc", "src/uncreated_file.cc", "src/z_test.cc"), | |
| ) | |
| self.assertSequenceEqual(groups[1].files, ("bar.py",)) | |
| def test_get_repo_groups_empty_explicit(self) -> None: | |
| groups = scm.get_repo_groups(self.root, self.root, explicit_files=[]) | |
| self.assertSequenceEqual(groups, ()) | |
| def test_get_repo_groups_nested_subrepo(self) -> None: | |
| subrepo = self.root / "integration" | |
| subrepo.mkdir(parents=True) | |
| (subrepo / ".git").mkdir() | |
| (subrepo / "shac.textproto").touch() | |
| subrepo_file = subrepo / "manifest.xml" | |
| subrepo_file.touch() | |
| groups = scm.get_repo_groups( | |
| self.root, | |
| self.root, | |
| explicit_files=["//integration/manifest.xml", "//src/main.cc"], | |
| ) | |
| self.assertEqual(len(groups), 2) | |
| self.assertEqual(groups[0].repo_root, self.root) | |
| self.assertSequenceEqual(groups[0].files, ("src/main.cc",)) | |
| self.assertEqual(groups[1].repo_root, subrepo) | |
| self.assertTrue(groups[1].has_shac) | |
| self.assertSequenceEqual(groups[1].files, ("manifest.xml",)) | |
| def test_get_repo_groups_fallback_external_repo(self) -> None: | |
| # External git repository outside of fuchsia_dir | |
| external_dir = self.enterContext(tempfile.TemporaryDirectory()) | |
| external_root = pathlib.Path(external_dir).resolve() | |
| (external_root / ".git").mkdir() | |
| (external_root / "shac.textproto").touch() | |
| external_file = external_root / "external.py" | |
| external_file.touch() | |
| groups = scm.get_repo_groups( | |
| self.root, self.root, explicit_files=[str(external_file)] | |
| ) | |
| self.assertEqual(len(groups), 1) | |
| self.assertEqual(groups[0].repo_root, external_root) | |
| self.assertTrue(groups[0].has_shac) | |
| self.assertSequenceEqual(groups[0].files, ("external.py",)) | |
| def test_get_repo_groups_mixed_repos(self) -> None: | |
| external_dir = self.enterContext(tempfile.TemporaryDirectory()) | |
| external_root = pathlib.Path(external_dir).resolve() | |
| (external_root / ".git").mkdir() | |
| (external_root / "shac.textproto").touch() | |
| external_file = external_root / "external.py" | |
| external_file.touch() | |
| explicit = [ | |
| str(external_file), | |
| "//vendor/google/bar.py", | |
| "//src/test.cc", | |
| ] | |
| groups = scm.get_repo_groups( | |
| self.root, self.root, explicit_files=explicit | |
| ) | |
| self.assertEqual(len(groups), 3) | |
| # Root repo is always first | |
| self.assertEqual(groups[0].repo_root, self.root) | |
| self.assertSequenceEqual(groups[0].files, ("src/test.cc",)) | |
| # Remaining repos are sorted lexicographically by repo_root | |
| non_root_roots = [groups[1].repo_root, groups[2].repo_root] | |
| expected_non_root = sorted([external_root, self.vendor_repo]) | |
| self.assertSequenceEqual(non_root_roots, expected_non_root) | |
| def test_get_repo_groups_non_repo_file_raises(self) -> None: | |
| non_repo_dir = self.enterContext(tempfile.TemporaryDirectory()) | |
| non_repo_file = pathlib.Path(non_repo_dir) / "non_repo.txt" | |
| non_repo_file.touch() | |
| with mock.patch.object( | |
| subprocess, "run", autospec=True, side_effect=FileNotFoundError | |
| ): | |
| with self.assertRaises(FileNotFoundError): | |
| scm.get_repo_groups( | |
| self.root, self.root, explicit_files=[str(non_repo_file)] | |
| ) | |
| def test_get_repo_groups_nonexistent_non_repo_file_raises(self) -> None: | |
| non_repo_dir = self.enterContext(tempfile.TemporaryDirectory()) | |
| nonexistent_file = ( | |
| pathlib.Path(non_repo_dir) / "nonexistent" / "path" / "file.txt" | |
| ) | |
| with mock.patch.object( | |
| subprocess, "run", autospec=True, side_effect=FileNotFoundError | |
| ): | |
| with self.assertRaises(FileNotFoundError): | |
| scm.get_repo_groups( | |
| self.root, | |
| self.root, | |
| explicit_files=[str(nonexistent_file)], | |
| ) | |
| if __name__ == "__main__": | |
| unittest.main() |