blob: a7f1a637a6ba61588e8b7cdeccf25404ee969a05 [file]
# 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.
"""Common SHAC execution helpers for fx lint and fx format-code."""
import os
import pathlib
import shutil
import subprocess
import sys
from collections.abc import Collection, Sequence
import scm
def _resolve_shac_binary() -> pathlib.Path:
"""Locates the prebuilt shac binary from PREBUILT_SHAC or PATH.
Returns:
Path to the shac executable.
Raises:
FileNotFoundError: If PREBUILT_SHAC points to a non-existent file or
shac cannot be found on PATH.
"""
if prebuilt_shac := os.environ.get("PREBUILT_SHAC"):
candidate = pathlib.Path(prebuilt_shac)
if candidate.is_file():
return candidate
raise FileNotFoundError(
f"shac binary not found at PREBUILT_SHAC={prebuilt_shac}"
)
if (which_shac := shutil.which("shac")) is not None:
return pathlib.Path(which_shac)
raise FileNotFoundError("shac binary not found.")
def _validate_explicit_files(
explicit_files: Collection[str],
fuchsia_dir: pathlib.Path,
cwd: pathlib.Path,
) -> Sequence[str]:
"""Validates explicit file paths and prints an error for missing files."""
valid_files = []
for raw_path in explicit_files:
stripped_path = raw_path.strip()
if not stripped_path:
continue
absolute_path = scm.canonicalize_path(stripped_path, cwd, fuchsia_dir)
if not absolute_path.is_file():
print(f"ERROR: no such file: {absolute_path}", file=sys.stderr)
continue
valid_files.append(stripped_path)
return tuple(valid_files)
def split_comma_separated(
entries: Collection[str],
) -> Sequence[str]:
"""Splits repeatable comma-separated CLI flag entries into stripped items.
Args:
entries: Collection of raw CLI flag values (each possibly
comma-separated).
Returns:
A sequence of non-empty stripped strings in order of appearance.
"""
return tuple(
stripped
for entry in entries
for item in entry.split(",")
if (stripped := item.strip())
)
def resolve_workspace_paths(
default_fuchsia_dir: pathlib.Path,
) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path]:
"""Resolves FUCHSIA_DIR, FUCHSIA_BUILD_DIR, and current working directory.
Args:
default_fuchsia_dir: Fallback Fuchsia root directory when FUCHSIA_DIR
is not set in the environment.
Returns:
A tuple of (fuchsia_dir, build_dir, cwd) as resolved absolute paths.
"""
if (fuchsia_dir_env := os.environ.get("FUCHSIA_DIR")) is not None:
fuchsia_dir = pathlib.Path(fuchsia_dir_env).resolve()
else:
fuchsia_dir = default_fuchsia_dir.resolve()
if (
fuchsia_build_dir_env := os.environ.get("FUCHSIA_BUILD_DIR")
) is not None:
build_dir = pathlib.Path(fuchsia_build_dir_env).resolve()
else:
build_dir = fuchsia_dir / "out" / "default"
cwd = pathlib.Path.cwd().resolve()
return (fuchsia_dir, build_dir, cwd)
def _build_shac_command(
shac_binary: pathlib.Path,
subcommand: str,
group: scm.RepoGroup,
fuchsia_dir: pathlib.Path,
build_dir: pathlib.Path,
*,
all_files: bool,
only_checks: Collection[str],
skip_checks: Collection[str],
extra_args: Collection[str],
verbose: bool,
) -> Sequence[str]:
"""Builds the shac command-line invocation for a single repository group."""
relative_fuchsia_dir = os.path.relpath(fuchsia_dir, group.repo_root)
relative_build_dir = os.path.relpath(build_dir, fuchsia_dir)
command = [
str(shac_binary),
subcommand,
"-C",
str(group.repo_root),
f"--var=fuchsia_dir={relative_fuchsia_dir}",
f"--var=fuchsia_build_dir={relative_build_dir}",
]
if all_files:
command.append("--all")
if verbose:
command.append("--verbose")
if only_checks:
command.append(f"--only={','.join(only_checks)}")
if skip_checks:
command.append(f"--skip={','.join(skip_checks)}")
if extra_args:
command.extend(extra_args)
if group.files:
command.extend(group.files)
return tuple(command)
def run_shac(
subcommand: str,
fuchsia_dir: pathlib.Path,
build_dir: pathlib.Path,
cwd: pathlib.Path,
*,
all_files: bool = False,
only_checks: Collection[str] = (),
skip_checks: Collection[str] = (),
explicit_files: Collection[str] | None = None,
extra_args: Collection[str] = (),
verbose: bool = False,
dry_run: bool = False,
) -> None:
"""Runs a shac subcommand ('check', 'fix', or 'fmt') across repository groups.
Args:
subcommand: The SHAC subcommand to execute ('check', 'fix', or 'fmt').
fuchsia_dir: Absolute path to the Fuchsia checkout root.
build_dir: Path to the active Fuchsia build directory.
cwd: Current working directory for resolving relative paths.
all_files: If True, passes '--all' to SHAC to check all tracked files.
only_checks: Collection of check names to pass via '--only'.
skip_checks: Collection of check names to pass via '--skip'.
explicit_files: Optional collection of explicit file paths to check, or
None to run across all active repositories without file filtering.
If an empty collection (or if all specified files are missing),
returns without invoking SHAC.
extra_args: Collection of extra CLI arguments forwarded to SHAC.
verbose: If True, passes '--verbose' to SHAC and logs command lines.
dry_run: If True, prints the SHAC command lines without executing them.
Raises:
subprocess.CalledProcessError: If `shac` or `git` exits with a non-zero
status code.
FileNotFoundError: If the `shac` binary cannot be found or if a target
path requires invoking `git` and the `git` executable cannot be
found.
"""
valid_files = None
if explicit_files is not None:
valid_files = _validate_explicit_files(explicit_files, fuchsia_dir, cwd)
if not valid_files:
return
shac_binary = _resolve_shac_binary()
shac_env = os.environ | {"FUCHSIA_DIR": str(fuchsia_dir)}
repo_groups = scm.get_repo_groups(
fuchsia_dir,
cwd,
explicit_files=valid_files,
)
for group in repo_groups:
if not group.has_shac:
continue
command = _build_shac_command(
shac_binary,
subcommand,
group,
fuchsia_dir,
build_dir,
all_files=all_files,
only_checks=only_checks,
skip_checks=skip_checks,
extra_args=extra_args,
verbose=verbose,
)
if verbose:
print(f"Running: {' '.join(command)}")
if dry_run:
print(f"[dry-run] Would execute: {' '.join(command)}")
continue
subprocess.run(command, cwd=group.repo_root, env=shac_env, check=True)