blob: 8e3f0c0248972b89a6d32651b0739d4f263b967b [file]
#!/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()