blob: 2fe11a1a705409ec7dba18e8443035d274213a39 [file]
#!/usr/bin/env fuchsia-vendored-python
# 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.
"""Fuchsia Git hook CLI runner and hook dispatcher entrypoints."""
from __future__ import annotations
import argparse
import os
import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import cast
# Bootstrap sys.path when executed directly from the source tree under hermetic-env.
_FUCHSIA_DIR = os.environ.get("FUCHSIA_DIR")
_root = (
Path(_FUCHSIA_DIR) if _FUCHSIA_DIR else Path(__file__).resolve().parents[4]
)
_tools_dir = str(_root / "tools")
if (_root / "tools").is_dir() and _tools_dir not in sys.path:
sys.path.insert(0, _tools_dir)
from agents.lib import git_staging, paths
from agents.lib.githooks.adapters import (
DEFAULT_FORMATTABLE_EXTENSIONS,
DEFAULT_PRE_COMMIT_ACTIONS,
HookAction,
HookContext,
commit_msg_action,
)
from agents.lib.githooks.reporters import ConsoleReporter
AGENT_ENV_VARS: tuple[str, ...] = (
"ANTIGRAVITY_AGENT",
"GEMINI_CLI",
"ANTIGRAVITY_EDITOR_APP_ROOT",
)
_FALSY_VALUES: frozenset[str] = frozenset({"0", "false", "no", "n", "off", ""})
def _is_env_truthy(
name: str,
env: Mapping[str, str] | None = None,
) -> bool:
"""Returns True if the environment variable represents a truthy boolean or non-empty string."""
target_env = env if env is not None else os.environ
val = target_env.get(name)
if val is None:
return False
cleaned = val.strip().lower()
if cleaned in _FALSY_VALUES:
return False
return True
def is_invoked_by_agent(
env: Mapping[str, str] | None = None,
) -> bool:
"""Detects whether the process was invoked by an automated AI agent."""
target_env = env if env is not None else os.environ
return any(_is_env_truthy(var, env=target_env) for var in AGENT_ENV_VARS)
def run_pre_commit_hook(
*,
repo_dir: Path | None = None,
actions: Sequence[HookAction] = DEFAULT_PRE_COMMIT_ACTIONS,
reporter: ConsoleReporter | None = None,
is_agent: bool | None = None,
) -> int:
"""Executes pre-commit hook checks using run_staged_pipeline."""
if _is_env_truthy("FUCHSIA_SKIP_HOOKS"):
return 0
is_agent = is_invoked_by_agent() if is_agent is None else is_agent
active_reporter = reporter or ConsoleReporter()
target_dir = repo_dir if repo_dir is not None else Path.cwd()
try:
repo_root = paths.get_repo_root(target_dir)
except RuntimeError as e:
active_reporter.on_error(str(e))
active_reporter.finish()
return 1
context = HookContext(
repo_root=repo_root,
check_only=False,
is_agent=is_agent,
reporter=active_reporter,
)
if any(a.extensions is None for a in actions):
exts = None
else:
exts = (
tuple(
{ext for a in actions if a.extensions for ext in a.extensions}
)
or DEFAULT_FORMATTABLE_EXTENSIONS
)
def _wrap_action(action: HookAction) -> git_staging.StagedFileAction:
def wrapped(
ctx: git_staging.ActionContext, files: Sequence[str]
) -> bool:
matched_files = (
[f for f in files if f.endswith(tuple(action.extensions))]
if action.extensions
else list(files)
)
if not matched_files:
return True
ok = action.action_fn(cast(HookContext, ctx), matched_files)
if (
not ok
and not (action.is_mutating and ctx.check_only)
and not active_reporter.has_errors
):
active_reporter.on_error(f"Action '{action.name}' failed.")
return ok
return wrapped
mutating = [_wrap_action(a) for a in actions if a.is_mutating]
read_only = [_wrap_action(a) for a in actions if not a.is_mutating]
result = git_staging.run_staged_pipeline(
context=context,
mutating_actions=mutating,
read_only_checks=read_only,
extensions=exts,
)
if result.restaged_files:
active_reporter.on_files_restaged(
list(result.restaged_files),
message="Auto-formatted and re-staged",
)
if result.partial_conflicts:
remediation_cmds: list[str] = []
for action in actions:
if action.remediation_cmd_fn is not None:
matched_conflicts = (
[
f
for f in result.partial_conflicts
if f.endswith(tuple(action.extensions))
]
if action.extensions
else list(result.partial_conflicts)
)
if matched_conflicts:
remediation_cmds.extend(
action.remediation_cmd_fn(matched_conflicts)
)
active_reporter.on_partial_staging_conflict(
list(result.partial_conflicts),
message=result.error
or (
"Formatting issues in partially staged files. Auto-fix skipped"
" to prevent stash conflicts."
),
remediation_cmds=remediation_cmds,
)
if not result.success:
if (
result.error
and not result.partial_conflicts
and not active_reporter.has_errors
):
active_reporter.on_error(result.error)
active_reporter.finish()
return 0 if result.success else 1
def run_commit_msg_hook(
msg_file_path: str | Path,
*,
repo_dir: Path | None = None,
checker_fn: Callable[
[HookContext, Sequence[str]], bool
] = commit_msg_action,
reporter: ConsoleReporter | None = None,
is_agent: bool | None = None,
) -> int:
"""Executes commit-msg hook checks on the target commit message file."""
if _is_env_truthy("FUCHSIA_SKIP_HOOKS"):
return 0
if is_agent is None:
is_agent = is_invoked_by_agent()
active_reporter = reporter or ConsoleReporter()
target_dir = repo_dir if repo_dir is not None else Path.cwd()
try:
repo_root = paths.get_repo_root(target_dir)
except RuntimeError as e:
active_reporter.on_error(str(e))
active_reporter.finish()
return 1
msg_path = Path(msg_file_path)
msg_file = (
msg_path if msg_path.is_absolute() else (repo_root / msg_path)
).resolve()
if not msg_file.is_file():
active_reporter.on_error(f"Commit message file not found: {msg_file}")
active_reporter.finish()
return 1
context = HookContext(
repo_root=repo_root,
check_only=False,
is_agent=is_agent,
reporter=active_reporter,
)
ok = checker_fn(context, [str(msg_file)])
if not ok:
if not active_reporter.has_errors:
active_reporter.on_error("Commit message check failed.")
active_reporter.finish()
return 0 if ok else 1
def _build_parser() -> argparse.ArgumentParser:
"""Constructs the standard ArgumentParser for Git hooks CLI dispatcher."""
parser = argparse.ArgumentParser(
prog="runner.py",
description="Fuchsia Git hook CLI runner and hook dispatcher.",
)
subparsers = parser.add_subparsers(
dest="subcommand",
title="subcommands",
description="Supported Git hooks",
required=True,
)
subparsers.add_parser(
"pre-commit",
help="Run pre-commit hook checks.",
)
commit_msg_parser = subparsers.add_parser(
"commit-msg",
help="Run commit-msg hook checks.",
)
commit_msg_parser.add_argument(
"msg_file",
help="Path to commit message file.",
)
return parser
def main(raw_args: Sequence[str] | None = None) -> int:
"""Main CLI entrypoint for Git hook dispatcher."""
if raw_args is None:
raw_args = sys.argv[1:]
is_agent = is_invoked_by_agent()
parser = _build_parser()
try:
args = parser.parse_args(raw_args)
except SystemExit as e:
return (
int(e.code)
if isinstance(e.code, int)
else (0 if e.code is None else 2)
)
reporter = ConsoleReporter()
if args.subcommand == "pre-commit":
return run_pre_commit_hook(reporter=reporter, is_agent=is_agent)
else:
return run_commit_msg_hook(
msg_file_path=args.msg_file,
reporter=reporter,
is_agent=is_agent,
)
if __name__ == "__main__":
sys.exit(main())