| # 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. |
| |
| """SCM and multi-repository discovery library for Fuchsia devshell tools.""" |
| |
| import collections |
| import dataclasses |
| import os |
| import pathlib |
| import subprocess |
| from collections.abc import Collection, Sequence |
| |
| |
| @dataclasses.dataclass(frozen=True) |
| class RepoGroup: |
| """A Git repository target for SHAC execution. |
| |
| Attributes: |
| repo_root: Absolute path to repository root (e.g. /fuchsia or |
| /fuchsia/vendor/google). |
| has_shac: True if repo_root contains shac.textproto. |
| files: Relative path strings for target files (empty for default |
| --git / --all modes). |
| """ |
| |
| repo_root: pathlib.Path |
| has_shac: bool |
| files: Sequence[str] |
| |
| |
| def _find_active_repositories( |
| fuchsia_dir: pathlib.Path, |
| ) -> Sequence[pathlib.Path]: |
| """Discovers first-party Git repositories across the Fuchsia checkout. |
| |
| Performs a shallow inspection of $FUCHSIA_DIR and immediate subdirectories |
| under $FUCHSIA_DIR/vendor/*/ containing a .git directory. |
| Skips heavy directories (out/, third_party/) to complete in <2ms. |
| |
| Returns a deterministically sorted sequence with $FUCHSIA_DIR first, |
| followed by vendor repositories sorted lexicographically by path. |
| """ |
| fuchsia_dir = fuchsia_dir.resolve() |
| |
| # 1. Root repository |
| if (fuchsia_dir / ".git").exists() or (fuchsia_dir / ".fx-root").exists(): |
| root_repos: Sequence[pathlib.Path] = (fuchsia_dir,) |
| else: |
| root_repos = () |
| |
| # 2. Vendor repositories |
| vendor_dir = fuchsia_dir / "vendor" |
| if vendor_dir.is_dir(): |
| vendor_repos = tuple( |
| sorted( |
| entry.resolve() |
| for entry in vendor_dir.iterdir() |
| if entry.is_dir() and (entry / ".git").exists() |
| ) |
| ) |
| else: |
| vendor_repos = () |
| |
| return (*root_repos, *vendor_repos) |
| |
| |
| def _check_has_shac(repo_root: pathlib.Path) -> bool: |
| """Checks if a repository root contains a shac.textproto configuration.""" |
| return (repo_root / "shac.textproto").is_file() |
| |
| |
| def _get_repo_root_for_path(path: pathlib.Path) -> pathlib.Path: |
| """Resolves the enclosing repository root for any arbitrary file or directory. |
| |
| Walks up parent directories checking for .git or .fx-root to find the |
| innermost repository root (handling nested repositories and uncreated files |
| without subprocess overhead), falling back to `git rev-parse --show-toplevel`. |
| """ |
| if path.is_dir(): |
| current = path |
| else: |
| current = path.parent |
| |
| for candidate in (current, *current.parents): |
| if (candidate / ".git").exists() or (candidate / ".fx-root").exists(): |
| return candidate.resolve() |
| |
| target_dir = current |
| while not target_dir.exists() and target_dir != target_dir.parent: |
| target_dir = target_dir.parent |
| completed_process = subprocess.run( |
| ["git", "-C", str(target_dir), "rev-parse", "--show-toplevel"], |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| return pathlib.Path(completed_process.stdout.strip()).resolve() |
| |
| |
| def canonicalize_path( |
| raw_path: str, cwd: pathlib.Path, fuchsia_dir: pathlib.Path |
| ) -> pathlib.Path: |
| """Normalizes user-supplied paths into absolute filesystem paths. |
| |
| Handles GN-style '//' prefixes (relative to $FUCHSIA_DIR) and relative paths |
| (relative to current working directory). Resolves parent directory symlinks |
| without dereferencing leaf symlinks. |
| |
| Args: |
| raw_path: Raw path string provided by the user (e.g. '//src/foo.cc' or |
| 'foo.cc'). |
| cwd: Current working directory used to resolve relative paths. |
| fuchsia_dir: Absolute path to the Fuchsia checkout root. |
| |
| Returns: |
| Normalized absolute path with parent symlinks resolved and leaf symlinks |
| preserved. |
| """ |
| if raw_path.startswith("//"): |
| if relative_path := raw_path[2:].lstrip("/"): |
| path = fuchsia_dir / relative_path |
| else: |
| path = fuchsia_dir |
| else: |
| path = pathlib.Path(raw_path) |
| if not path.is_absolute(): |
| path = cwd / path |
| path = pathlib.Path(os.path.normpath(path)) |
| if path.is_symlink(): |
| return path.parent.resolve() / path.name |
| else: |
| return path.resolve() |
| |
| |
| def get_repo_groups( |
| fuchsia_dir: pathlib.Path, |
| cwd: pathlib.Path, |
| explicit_files: Collection[str] | None = None, |
| ) -> Sequence[RepoGroup]: |
| """Discovers active repositories and partitions explicit files if provided. |
| |
| - In default (--git) or --all mode (when explicit_files is None): |
| returns RepoGroups for all active repositories with empty `files=()`. |
| SHAC handles all git diffing and file discovery internally. |
| - In --files / --target mode (when explicit_files is not None): resolves |
| each file's enclosing repository and populates RepoGroup.files with |
| relative path strings for `shac -C <repo_root>`. If explicit_files is |
| empty, returns (). |
| |
| Args: |
| fuchsia_dir: Absolute path to the Fuchsia checkout root. |
| cwd: Current working directory for resolving relative paths. |
| explicit_files: Optional collection of user-specified file paths. If |
| None, returns all active repositories for whole-repo checks. |
| |
| Returns: |
| A deterministically sorted sequence of RepoGroups (root repo first, |
| followed by other repos sorted lexicographically) with sorted, |
| deduplicated `files` sequences. |
| |
| Raises: |
| subprocess.CalledProcessError: If a target path is not inside any Git |
| repository and `git rev-parse --show-toplevel` fails. |
| FileNotFoundError: If a target path requires invoking `git` and the |
| `git` executable cannot be found. |
| """ |
| fuchsia_dir = fuchsia_dir.resolve() |
| |
| def repo_sort_key(group: RepoGroup) -> tuple[int, pathlib.Path]: |
| if group.repo_root == fuchsia_dir: |
| rootness_subkey = 0 |
| else: |
| rootness_subkey = 1 |
| return (rootness_subkey, group.repo_root) |
| |
| if explicit_files is None: |
| repos = { |
| *_find_active_repositories(fuchsia_dir), |
| _get_repo_root_for_path(cwd), |
| } |
| return tuple( |
| sorted( |
| ( |
| RepoGroup( |
| repo_root=repo_root, |
| has_shac=_check_has_shac(repo_root), |
| files=(), |
| ) |
| for repo_root in repos |
| ), |
| key=repo_sort_key, |
| ) |
| ) |
| |
| repo_files = collections.defaultdict(set) |
| for raw_path in explicit_files: |
| abs_path = canonicalize_path(raw_path, cwd, fuchsia_dir) |
| repo_root = _get_repo_root_for_path(abs_path) |
| |
| if abs_path.is_relative_to(repo_root): |
| repo_files[repo_root].add(str(abs_path.relative_to(repo_root))) |
| |
| return tuple( |
| sorted( |
| ( |
| RepoGroup( |
| repo_root=repo_root, |
| has_shac=_check_has_shac(repo_root), |
| files=tuple(sorted(files)), |
| ) |
| for repo_root, files in repo_files.items() |
| ), |
| key=repo_sort_key, |
| ) |
| ) |