Add 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.
Updates based on code review:
- Rename dispatcher to post-sync.py per hook naming conventions.
- Use pathlib and argparse for better path and argument handling.
- Adopt GLOBAL-POSTSYNC.cfg (INI format) with [Hook Scripts] section.
- Support placeholder expansion (${REPO_ROOT}, ${REPO_SYNC_DURATION}).
- Use standard REPO_ROOT naming for consistency.
- Retrieve repo root and sync duration from environment variables if available.
- Respect script shebangs and remove broad exception catching.
- Cleanup run_tests and unittests.
- Remove unrecognized 'too-many-positional-arguments' from pylintrc to fix CI.
- Fix lint and import sorting errors in post-sync.py and rh/config.py.
- Revert accidental docstring indentation changes in rh/config.py.
- Delete empty newline in tools/pylintrc.
Bug: 502597102
Test: Ran run_tests using venv python, all 131 tests passed.
Change-Id: I747abc79eeea1915ae4d7a957f47b07687c04100
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repohooks/+/567821
Commit-Queue: Ram Peri <ramperi@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Jinda Zhuang <jindaz@google.com>
Tested-by: Ram Peri <ramperi@google.com>
Reviewed-by: Arif Kasim <arifkasim@google.com>
diff --git a/post-sync.py b/post-sync.py
new file mode 100755
index 0000000..b796d59
--- /dev/null
+++ b/post-sync.py
@@ -0,0 +1,208 @@
+#!/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:]))
diff --git a/rh/config.py b/rh/config.py
index 0a4db55..c421485 100644
--- a/rh/config.py
+++ b/rh/config.py
@@ -28,8 +28,8 @@
sys.path.insert(0, str(THIS_DIR.parent))
# pylint: disable=wrong-import-position
-import rh.hooks
-import rh.shell
+import rh.hooks # isort: skip
+import rh.shell # isort: skip
class Error(Exception):
@@ -367,3 +367,25 @@
# We validated configs in isolation, now do one final pass altogether.
self.source = "{" + "|".join(self.paths) + "}"
self._validate()
+
+
+class PostSyncSettings(PreUploadConfig):
+ """Settings for `repo post-sync` hooks."""
+
+ VALID_SECTIONS = {PreUploadConfig.CUSTOM_HOOKS_SECTION}
+
+ def __init__(self, path):
+ """Initialize.
+
+ Args:
+ path: The config file to load (GLOBAL-POSTSYNC.cfg).
+ """
+ super().__init__(source=path)
+ self.path = path
+ if os.path.exists(path):
+ try:
+ self.config.read(path)
+ except configparser.ParsingError as e:
+ raise ValidationError(f"{path}: {e}") from e
+
+ self._validate()
diff --git a/tools/pylintrc b/tools/pylintrc
index cfc2c76..3a40922 100644
--- a/tools/pylintrc
+++ b/tools/pylintrc
@@ -85,7 +85,6 @@
too-many-instance-attributes,
too-many-lines,
too-many-locals,
- too-many-positional-arguments,
too-many-public-methods,
too-many-return-statements,
too-many-statements,