Skip to content

Commit phase

commit_phase

Commit-phase hook action.

Stages all changes and commits with [axm] {phase_name}. Supports a from_outputs mode that reads commit_spec from context for targeted file staging.

CommitPhaseHook dataclass

Commit all changes with message [axm] {phase_name}.

Reads phase_name from context. An optional message_format can be provided via params (default "[axm] {phase}"). Skips gracefully when there is nothing to commit or no git repository.

When from_outputs=True is passed in params, reads commit_spec from context instead: {message, body?, files}. Only the listed files are staged (no git add -A).

Source code in packages/axm-git/src/axm_git/hooks/commit_phase.py
Python
@dataclass
class CommitPhaseHook:
    """Commit all changes with message ``[axm] {phase_name}``.

    Reads ``phase_name`` from *context*.  An optional
    ``message_format`` can be provided via *params*
    (default ``"[axm] {phase}"``).  Skips gracefully when
    there is nothing to commit or no git repository.

    When ``from_outputs=True`` is passed in *params*, reads
    ``commit_spec`` from *context* instead: ``{message, body?, files}``.
    Only the listed files are staged (no ``git add -A``).
    """

    def execute(self, context: dict[str, object], **params: object) -> HookResult:
        """Execute the hook action.

        Args:
            context: Session context dictionary.
            **params: Optional ``message_format``, ``from_outputs``,
                ``working_dir``, ``skip_hooks`` (default ``False`` for
                ``from_outputs`` mode — pass ``True`` to append
                ``--no-verify`` and bypass project pre-commit hooks).

        Returns:
            HookResult with ``commit`` hash and ``message`` in metadata.
        """
        wd_raw = params.get("working_dir", context.get("working_dir", "."))
        working_dir = Path(cast("str | Path", wd_raw))

        if not params.get("enabled", True):
            return HookResult.ok(skipped=True, reason="git disabled")

        if find_git_root(working_dir) is None:
            return HookResult.ok(skipped=True, reason="not a git repo")

        profile = cast("str | None", params.pop("profile", None))

        if params.get("from_outputs"):
            skip_hooks = bool(params.get("skip_hooks", False))
            return self.commit_from_outputs(
                context, working_dir, skip_hooks=skip_hooks, profile=profile
            )

        return self._commit_legacy(context, working_dir, profile=profile, **params)

    def _commit_legacy(
        self,
        context: dict[str, object],
        working_dir: Path,
        *,
        profile: str | None = None,
        **params: object,
    ) -> HookResult:
        """Legacy mode: stage all changes and commit with a format string.

        Resolves the author identity via :func:`resolve_identity` and
        injects ``--author`` into the commit command when a profile is
        found.  Identity metadata (name, email) is included in the
        returned :class:`HookResult`.

        Args:
            context: Session context containing ``phase_name``.
            working_dir: Repository working directory.
            profile: Optional identity profile name override.
            **params: Extra params; ``message_format`` controls the
                commit message template (default ``"[axm] {phase}"``).
        """
        phase_name = cast("str", context["phase_name"])

        # Resolve author identity
        identity = resolve_identity(working_dir, profile_override=profile)
        author = f"{identity.name} <{identity.email}>" if identity else None

        # Stage all changes (scoped to working_dir for workspace layouts)
        run_git(["add", "-A", "."], working_dir)

        # Check if there's anything to commit
        status = run_git(["status", "--porcelain", "--", "."], working_dir)
        if not status.stdout.strip():
            return HookResult.ok(skipped=True, reason="nothing to commit")

        # Commit
        message_format = cast("str", params.get("message_format", "[axm] {phase}"))
        msg = message_format.format(phase=phase_name)
        commit_cmd = build_commit_cmd(msg, None, author=author)
        result = run_git(commit_cmd, working_dir)
        if result.returncode != 0:
            return HookResult.fail(f"git commit failed: {result.stderr}")

        # Get commit hash
        hash_result = run_git(["rev-parse", "--short", "HEAD"], working_dir)
        result_kw: dict[str, str] = {
            "commit": hash_result.stdout.strip(),
            "message": msg,
        }
        if identity:
            result_kw["author_name"] = identity.name
            result_kw["author_email"] = identity.email
        return HookResult.ok(**result_kw)

    def commit_from_outputs(
        self,
        context: dict[str, object],
        working_dir: Path,
        *,
        skip_hooks: bool = False,
        profile: str | None = None,
    ) -> HookResult:
        """Outputs mode: read ``commit_spec`` from context, stage listed files.

        Resolves the author identity via :func:`resolve_identity` and
        injects ``--author`` into the commit command when a profile is
        found.  If the commit fails because pre-commit hooks auto-fixed
        files (stderr contains ``"files were modified"``), the listed
        files are re-staged and the commit is retried once.

        Args:
            context: Session context containing ``commit_spec``.
            working_dir: Repository working directory.
            skip_hooks: When *True*, append ``--no-verify`` to bypass
                project pre-commit hooks.  Defaults to ``False`` so
                hooks run and surface failures via ``HookResult.fail``.
            profile: Optional identity profile name override.
        """
        spec, err = _validate_commit_spec(
            cast("dict[str, object] | None", context.get("commit_spec"))
        )
        if err:
            return HookResult.fail(err)
        # spec is guaranteed non-None when err is None
        spec = cast("dict[str, object]", spec)

        files = cast("list[str]", spec["files"])
        message = cast("str", spec["message"])
        body = cast("str | None", spec.get("body"))

        git_root = find_git_root(working_dir) or working_dir

        # Resolve author identity
        identity = resolve_identity(git_root, profile_override=profile)
        author = f"{identity.name} <{identity.email}>" if identity else None

        _format_spec_files(files, git_root, working_dir=working_dir)

        warnings: list[str] = []
        stage_err = stage_spec_files(
            files, git_root, working_dir=working_dir, warnings=warnings
        )
        if stage_err:
            return HookResult.fail(stage_err)

        # Check if there's anything to commit
        status = run_git(["diff", "--cached", "--name-only"], git_root)
        if not status.stdout.strip():
            return HookResult.ok(skipped=True, reason="nothing to commit")

        commit_cmd = build_commit_cmd(
            message, body, skip_hooks=skip_hooks, author=author
        )

        result: _GitResultLike = run_git(commit_cmd, git_root)
        if result.returncode != 0:
            result = retry_commit_on_autofix(
                files, commit_cmd, git_root, result, working_dir=working_dir
            )
            if result.returncode != 0:
                return HookResult.fail(f"git commit failed: {result.stderr}")

        return build_commit_result(git_root, message, identity, warnings)
commit_from_outputs(context, working_dir, *, skip_hooks=False, profile=None)

Outputs mode: read commit_spec from context, stage listed files.

Resolves the author identity via :func:resolve_identity and injects --author into the commit command when a profile is found. If the commit fails because pre-commit hooks auto-fixed files (stderr contains "files were modified"), the listed files are re-staged and the commit is retried once.

Parameters:

Name Type Description Default
context dict[str, object]

Session context containing commit_spec.

required
working_dir Path

Repository working directory.

required
skip_hooks bool

When True, append --no-verify to bypass project pre-commit hooks. Defaults to False so hooks run and surface failures via HookResult.fail.

False
profile str | None

Optional identity profile name override.

None
Source code in packages/axm-git/src/axm_git/hooks/commit_phase.py
Python
def commit_from_outputs(
    self,
    context: dict[str, object],
    working_dir: Path,
    *,
    skip_hooks: bool = False,
    profile: str | None = None,
) -> HookResult:
    """Outputs mode: read ``commit_spec`` from context, stage listed files.

    Resolves the author identity via :func:`resolve_identity` and
    injects ``--author`` into the commit command when a profile is
    found.  If the commit fails because pre-commit hooks auto-fixed
    files (stderr contains ``"files were modified"``), the listed
    files are re-staged and the commit is retried once.

    Args:
        context: Session context containing ``commit_spec``.
        working_dir: Repository working directory.
        skip_hooks: When *True*, append ``--no-verify`` to bypass
            project pre-commit hooks.  Defaults to ``False`` so
            hooks run and surface failures via ``HookResult.fail``.
        profile: Optional identity profile name override.
    """
    spec, err = _validate_commit_spec(
        cast("dict[str, object] | None", context.get("commit_spec"))
    )
    if err:
        return HookResult.fail(err)
    # spec is guaranteed non-None when err is None
    spec = cast("dict[str, object]", spec)

    files = cast("list[str]", spec["files"])
    message = cast("str", spec["message"])
    body = cast("str | None", spec.get("body"))

    git_root = find_git_root(working_dir) or working_dir

    # Resolve author identity
    identity = resolve_identity(git_root, profile_override=profile)
    author = f"{identity.name} <{identity.email}>" if identity else None

    _format_spec_files(files, git_root, working_dir=working_dir)

    warnings: list[str] = []
    stage_err = stage_spec_files(
        files, git_root, working_dir=working_dir, warnings=warnings
    )
    if stage_err:
        return HookResult.fail(stage_err)

    # Check if there's anything to commit
    status = run_git(["diff", "--cached", "--name-only"], git_root)
    if not status.stdout.strip():
        return HookResult.ok(skipped=True, reason="nothing to commit")

    commit_cmd = build_commit_cmd(
        message, body, skip_hooks=skip_hooks, author=author
    )

    result: _GitResultLike = run_git(commit_cmd, git_root)
    if result.returncode != 0:
        result = retry_commit_on_autofix(
            files, commit_cmd, git_root, result, working_dir=working_dir
        )
        if result.returncode != 0:
            return HookResult.fail(f"git commit failed: {result.stderr}")

    return build_commit_result(git_root, message, identity, warnings)
execute(context, **params)

Execute the hook action.

Parameters:

Name Type Description Default
context dict[str, object]

Session context dictionary.

required
**params object

Optional message_format, from_outputs, working_dir, skip_hooks (default False for from_outputs mode — pass True to append --no-verify and bypass project pre-commit hooks).

{}

Returns:

Type Description
HookResult

HookResult with commit hash and message in metadata.

Source code in packages/axm-git/src/axm_git/hooks/commit_phase.py
Python
def execute(self, context: dict[str, object], **params: object) -> HookResult:
    """Execute the hook action.

    Args:
        context: Session context dictionary.
        **params: Optional ``message_format``, ``from_outputs``,
            ``working_dir``, ``skip_hooks`` (default ``False`` for
            ``from_outputs`` mode — pass ``True`` to append
            ``--no-verify`` and bypass project pre-commit hooks).

    Returns:
        HookResult with ``commit`` hash and ``message`` in metadata.
    """
    wd_raw = params.get("working_dir", context.get("working_dir", "."))
    working_dir = Path(cast("str | Path", wd_raw))

    if not params.get("enabled", True):
        return HookResult.ok(skipped=True, reason="git disabled")

    if find_git_root(working_dir) is None:
        return HookResult.ok(skipped=True, reason="not a git repo")

    profile = cast("str | None", params.pop("profile", None))

    if params.get("from_outputs"):
        skip_hooks = bool(params.get("skip_hooks", False))
        return self.commit_from_outputs(
            context, working_dir, skip_hooks=skip_hooks, profile=profile
        )

    return self._commit_legacy(context, working_dir, profile=profile, **params)

build_commit_cmd(message, body, *, skip_hooks=True, author=None)

Build the git commit argument list.

Parameters:

Name Type Description Default
message str

Commit summary line.

required
body str | None

Optional extended commit body.

required
skip_hooks bool

Append --no-verify when True.

True
author str | None

Git --author value ("Name <email>"). When None, git uses the default identity.

None
Source code in packages/axm-git/src/axm_git/hooks/commit_phase.py
Python
def build_commit_cmd(
    message: str,
    body: str | None,
    *,
    skip_hooks: bool = True,
    author: str | None = None,
) -> list[str]:
    """Build the ``git commit`` argument list.

    Args:
        message: Commit summary line.
        body: Optional extended commit body.
        skip_hooks: Append ``--no-verify`` when *True*.
        author: Git ``--author`` value (``"Name <email>"``).
            When *None*, git uses the default identity.
    """
    cmd = ["commit", "-m", message]
    if body:
        cmd.extend(["-m", body])
    if skip_hooks:
        cmd.append("--no-verify")
    if author:
        cmd.append(f"--author={author}")
    return cmd

build_commit_result(git_root, message, identity, warnings)

Build a successful commit :class:HookResult.

Reads the current HEAD short hash and assembles the result dict with optional identity and warning fields.

Source code in packages/axm-git/src/axm_git/hooks/commit_phase.py
Python
def build_commit_result(
    git_root: Path,
    message: str,
    identity: GitIdentity | None,
    warnings: list[str],
) -> HookResult:
    """Build a successful commit :class:`HookResult`.

    Reads the current HEAD short hash and assembles the result dict
    with optional identity and warning fields.
    """
    hash_result = run_git(["rev-parse", "--short", "HEAD"], git_root)
    result_kw: dict[str, str | list[str]] = {
        "commit": hash_result.stdout.strip(),
        "message": message,
    }
    if identity:
        result_kw["author_name"] = identity.name
        result_kw["author_email"] = identity.email
    if warnings:
        result_kw["warnings"] = warnings
    return HookResult.ok(**result_kw)

retry_commit_on_autofix(files, cmd, git_root, first_result, *, working_dir=None)

Handle pre-commit autofix retry for a failed commit.

If first_result stderr contains "files were modified", re-stage files (using the same dual-resolution as the original staging) and retry the commit once. Otherwise return first_result unchanged.

Returns a GitResult-like object (has returncode, stdout, stderr).

Source code in packages/axm-git/src/axm_git/hooks/commit_phase.py
Python
def retry_commit_on_autofix(
    files: list[str],
    cmd: list[str],
    git_root: Path,
    first_result: _GitResultLike,
    *,
    working_dir: Path | None = None,
) -> _GitResultLike:
    """Handle pre-commit autofix retry for a failed commit.

    If *first_result* stderr contains ``"files were modified"``, re-stage
    *files* (using the same dual-resolution as the original staging) and
    retry the commit once.  Otherwise return *first_result* unchanged.

    Returns a GitResult-like object (has *returncode*, *stdout*, *stderr*).
    """
    if "files were modified" not in first_result.stderr:
        return first_result
    restage_err = stage_spec_files(files, git_root, working_dir=working_dir)
    if restage_err:
        from types import SimpleNamespace

        return SimpleNamespace(returncode=1, stdout="", stderr=restage_err)
    return run_git(cmd, git_root)

stage_spec_files(files, git_root, *, working_dir=None, warnings=None)

Stage each file in files, returning an error message on failure.

Paths in files are resolved against git_root first, then against working_dir (if provided and distinct), so both git-root-relative and package-relative inputs work transparently. Absolute inputs are accepted when they point inside git_root.

Tracked-but-deleted files (git status D) are staged as deletions. Gitignored files are skipped with a warning appended to warnings. Truly missing files (never tracked) produce a clear diagnostic error listing every absolute path that was attempted.

Source code in packages/axm-git/src/axm_git/hooks/commit_phase.py
Python
def stage_spec_files(
    files: list[str],
    git_root: Path,
    *,
    working_dir: Path | None = None,
    warnings: list[str] | None = None,
) -> str | None:
    """Stage each file in *files*, returning an error message on failure.

    Paths in *files* are resolved against *git_root* first, then against
    *working_dir* (if provided and distinct), so both git-root-relative
    and package-relative inputs work transparently. Absolute inputs are
    accepted when they point inside *git_root*.

    Tracked-but-deleted files (git status ``D``) are staged as deletions.
    Gitignored files are skipped with a warning appended to *warnings*.
    Truly missing files (never tracked) produce a clear diagnostic error
    listing every absolute path that was attempted.
    """
    for filepath in files:
        err = _stage_single_file(filepath, git_root, working_dir, warnings)
        if err:
            return err
    return None