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,