blob: b796d59a42cf72f6e661062630924fbcfa3a3223 [file] [edit]
#!/usr/bin/env python3
# Copyright 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Repo post-sync hook dispatcher.
This script acts as an entry point for repo post-sync hooks. It reads a
configuration file from the manifest repository to discover and execute
registered post-sync hooks.
"""
import argparse
from pathlib import Path
import sys
from typing import List, Optional
# Assert some minimum Python versions as we don't test or support any others.
if sys.version_info < (3, 6):
print("repohooks: error: Python-3.6+ is required", file=sys.stderr)
sys.exit(1)
THIS_FILE = Path(__file__).resolve()
THIS_DIR = THIS_FILE.parent
sys.path.insert(0, str(THIS_DIR.parent))
# We have to import our local modules after the sys.path tweak. We can't use
# relative imports because this is an executable program, not a module.
# pylint: disable=wrong-import-position
import rh.config # isort: skip
import rh.git # isort: skip
import rh.hooks # isort: skip
import rh.terminal # isort: skip
import rh.utils # isort: skip
class PostSyncPlaceholders(rh.hooks.Placeholders):
"""Placeholders for post-sync hooks."""
def __init__(self, repo_root: Path, sync_duration: Optional[int] = None):
"""Initialize.
Args:
repo_root: The top level of the repo checkout.
sync_duration: The total time taken by the sync operation.
"""
super().__init__()
self._repo_root = repo_root
self._sync_duration = sync_duration
@property
def var_REPO_ROOT(self) -> str:
"""The absolute path of the root of the repo checkout."""
return str(self._repo_root)
@property
def var_REPO_OUTER_ROOT(self) -> str:
"""The absolute path of the outermost root of the repo checkout."""
return str(self._repo_root)
@property
def var_REPO_SYNC_DURATION(self) -> str:
"""The total time taken by the sync operation.
Validation of this value is deferred to the hook scripts.
"""
return (
str(self._sync_duration) if self._sync_duration is not None else ""
)
def _run_post_sync_hooks(
repo_root_path: Path, sync_duration_seconds: Optional[int]
) -> int:
"""Run the registered post-sync hooks."""
config_file = repo_root_path / ".repo" / "manifests" / "GLOBAL-POSTSYNC.cfg"
if not config_file.exists():
return 0
try:
settings = rh.config.PostSyncSettings(str(config_file))
except rh.config.ValidationError as e:
print(f"error: invalid config: {e}", file=sys.stderr)
return 1
if not settings.custom_hooks:
return 0
# Prepare environment for the subprocess calls.
extra_env = {
"REPO_ROOT": str(repo_root_path),
}
if sync_duration_seconds is not None:
extra_env["REPO_HOOK_SYNC_DURATION_SECONDS"] = str(
sync_duration_seconds
)
exit_code = 0
placeholders = PostSyncPlaceholders(repo_root_path, sync_duration_seconds)
color = rh.terminal.Color()
for name in settings.custom_hooks:
cmd = settings.custom_hook(name)
if not cmd:
continue
# Expand placeholders in the command arguments.
cmd = placeholders.expand_vars(cmd)
# Resolve the hook path relative to the repo root if it is not absolute.
hook_path = Path(cmd[0])
if not hook_path.is_absolute():
hook_path = repo_root_path / hook_path
if not hook_path.exists():
print(
f"error: Registered post-sync hook '{name}' not found: "
f"{hook_path}",
file=sys.stderr,
)
return 1
# Replace the first element with the resolved path.
cmd[0] = str(hook_path.resolve())
# Print running status.
status_line = f"[{color.color(color.YELLOW, 'RUNNING')}] {name}"
rh.terminal.print_status_line(status_line)
# Execute the hook as a subprocess.
result = rh.utils.run(
cmd, cwd=repo_root_path, extra_env=extra_env, check=False
)
if result.returncode:
exit_code = result.returncode
status_line = f"[{color.color(color.RED, 'FAILED')}] {name}"
rh.terminal.print_status_line(status_line, print_newline=True)
else:
status_line = f"[{color.color(color.GREEN, 'PASSED')}] {name}"
rh.terminal.print_status_line(status_line, print_newline=True)
return exit_code
def main(repo_topdir=None, **kwargs) -> int:
"""Main function invoked directly by repo.
We must use the name "main" as that is what repo requires.
Args:
repo_topdir: The absolute path to the top-level directory of the repo
workspace.
kwargs: Leave this here for forward-compatibility.
"""
if not repo_topdir:
try:
repo_root = rh.git.find_repo_root()
except Exception as e: # pylint: disable=broad-exception-caught
print(f"error: {e}", file=sys.stderr)
return 1
else:
repo_root = repo_topdir
sync_duration_seconds = kwargs.get("sync_duration_seconds")
return _run_post_sync_hooks(Path(repo_root), sync_duration_seconds)
def direct_main(argv: List[str]) -> int:
"""Run hooks directly (outside of the context of repo).
Args:
argv: The command line args to process.
"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repo-root", help="The top level of the repo checkout."
)
parser.add_argument(
"--sync-duration-seconds",
type=int,
help="The total time taken by the sync operation.",
)
opts = parser.parse_args(argv)
return main(
repo_topdir=opts.repo_root,
sync_duration_seconds=opts.sync_duration_seconds,
)
if __name__ == "__main__":
sys.exit(direct_main(sys.argv[1:]))