Skip to content

Internal helpers and authentication

These source-derived modules are not root-level public exports. See identity models for author configuration and tool API for registered operations.

runner

Subprocess runners for git, gh, and uv commands.

detect_package_name(project_path)

Read the package name from pyproject.toml.

Parameters:

Name Type Description Default
project_path Path

Project root containing pyproject.toml.

required

Returns:

Type Description
str | None

Package name or None if not found.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def detect_package_name(project_path: Path) -> str | None:
    """Read the package name from ``pyproject.toml``.

    Args:
        project_path: Project root containing ``pyproject.toml``.

    Returns:
        Package name or ``None`` if not found.
    """
    pyproject = project_path / "pyproject.toml"
    if not pyproject.exists():
        return None

    try:
        import tomllib

        with open(pyproject, "rb") as f:
            data = tomllib.load(f)
        return data.get("project", {}).get("name")  # type: ignore[no-any-return]
    except (OSError, KeyError, ValueError):
        return None

find_git_root(path)

Find the git repository root containing path.

Uses git rev-parse --show-toplevel which walks up the directory tree, supporting mono-repo and workspace layouts where .git lives above the package directory.

Parameters:

Name Type Description Default
path Path

Any directory that may be inside a git repository.

required

Returns:

Type Description
Path | None

Repository root as a Path, or None if path is not

Path | None

inside a git repository.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def find_git_root(path: Path) -> Path | None:
    """Find the git repository root containing *path*.

    Uses ``git rev-parse --show-toplevel`` which walks up the directory
    tree, supporting mono-repo and workspace layouts where ``.git``
    lives above the package directory.

    Args:
        path: Any directory that may be inside a git repository.

    Returns:
        Repository root as a ``Path``, or ``None`` if *path* is not
        inside a git repository.
    """
    try:
        result = subprocess.run(
            ["git", "-C", str(path), "rev-parse", "--show-toplevel"],
            capture_output=True,
            text=True,
            check=False,
            timeout=DEFAULT_GIT_TIMEOUT,
        )
    except subprocess.TimeoutExpired:
        logger.warning("git rev-parse timed out after %ss", DEFAULT_GIT_TIMEOUT)
        return None
    if result.returncode != 0:
        return None
    return Path(result.stdout.strip())

gh_available()

Check whether the GitHub CLI is installed and authenticated.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def gh_available() -> bool:
    """Check whether the GitHub CLI is installed and authenticated."""
    if not shutil.which("gh"):
        return False
    try:
        result = subprocess.run(
            ["gh", "auth", "status"],
            capture_output=True,
            text=True,
            check=False,
            timeout=DEFAULT_GIT_TIMEOUT,
        )
    except subprocess.TimeoutExpired:
        logger.warning("gh auth status timed out after %ss", DEFAULT_GIT_TIMEOUT)
        return False
    return result.returncode == 0

not_a_repo_error(stderr, path)

Build a ToolResult for a failed git command.

If stderr contains "not a git repository" and path has child directories that are git repos, the error message is enriched with suggestions. Otherwise a standard error is returned.

Parameters:

Name Type Description Default
stderr str

Stderr output from the failed git command.

required
path Path

Directory that was used as cwd.

required

Returns:

Type Description
ToolResult

ToolResult(success=False, ...) with optional suggestions.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def not_a_repo_error(stderr: str, path: Path) -> ToolResult:
    """Build a ``ToolResult`` for a failed git command.

    If *stderr* contains ``"not a git repository"`` and *path* has
    child directories that are git repos, the error message is enriched
    with suggestions.  Otherwise a standard error is returned.

    Args:
        stderr: Stderr output from the failed git command.
        path: Directory that was used as ``cwd``.

    Returns:
        ``ToolResult(success=False, ...)`` with optional suggestions.
    """
    msg = stderr.strip()

    if "not a git repository" not in msg:
        return ToolResult(success=False, error=msg, text=msg)

    suggestions = suggest_git_repos(path)
    if suggestions:
        hint = ", ".join(suggestions)
        error = (
            f"{msg}. This directory contains git repos: {hint}. "
            f"Pass one of these as the path instead."
        )
        return ToolResult(
            success=False,
            error=error,
            data={"suggestions": suggestions},
            text=error,
        )

    return ToolResult(success=False, error=msg, text=msg)

parse_porcelain_z(status_stdout)

Parse git status --porcelain -z output into {path, status} rows.

Records are NUL-terminated rather than newline-terminated, so paths with spaces are emitted verbatim (unquoted, unescaped). Rename/copy entries (R/C) span two NUL-separated fields — XY <space>dest followed by the original source path — so the destination is kept as path and the trailing source field is consumed and discarded.

Parameters:

Name Type Description Default
status_stdout str

Raw stdout from git status --porcelain -z.

required

Returns:

Type Description
list[dict[str, str]]

List of {"path", "status"} dicts in encounter order.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def parse_porcelain_z(status_stdout: str) -> list[dict[str, str]]:
    """Parse ``git status --porcelain -z`` output into ``{path, status}`` rows.

    Records are NUL-terminated rather than newline-terminated, so paths with
    spaces are emitted verbatim (unquoted, unescaped). Rename/copy entries
    (``R``/``C``) span two NUL-separated fields — ``XY <space>dest`` followed
    by the original source path — so the destination is kept as ``path`` and
    the trailing source field is consumed and discarded.

    Args:
        status_stdout: Raw stdout from ``git status --porcelain -z``.

    Returns:
        List of ``{"path", "status"}`` dicts in encounter order.
    """
    records = [rec for rec in status_stdout.split("\x00") if rec]
    files: list[dict[str, str]] = []
    index = 0
    while index < len(records):
        record = records[index]
        index += 1
        if len(record) < _MIN_PORCELAIN_RECORD_LEN:
            continue
        status = record[:2].strip()
        files.append({"path": record[3:], "status": status})
        # Rename/copy entries carry a trailing source-path field; skip it.
        if record[:2].strip(" ?")[:1] in {"R", "C"}:
            index += 1
    return files

reset_paths(paths, git_root)

Unstage exactly paths via a scoped git reset -- <paths>.

Restoration is strictly scoped to paths: it never runs a bare git reset (which would unstage the whole index, including third-party staged files) and never touches the worktree (no checkout/clean). A no-op when paths is empty.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def reset_paths(paths: list[str], git_root: Path) -> None:
    """Unstage exactly *paths* via a scoped ``git reset -- <paths>``.

    Restoration is strictly scoped to *paths*: it never runs a bare
    ``git reset`` (which would unstage the whole index, including third-party
    staged files) and never touches the worktree (no checkout/clean). A
    no-op when *paths* is empty.
    """
    if not paths:
        return
    run_git(["reset", "--quiet", "--", *paths], git_root)

resolve_default_branch(working_dir)

Resolve the repository's default branch.

Reads git symbolic-ref refs/remotes/origin/HEAD (e.g. refs/remotes/origin/master) and strips the refs/remotes/origin/ prefix. Falls back to "main" when the command fails or returns an empty/unexpected value (for instance a repo with no origin/HEAD ref).

Parameters:

Name Type Description Default
working_dir Path

A directory inside the git repository.

required

Returns:

Type Description
str

The default branch name, or "main" as a fallback.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def resolve_default_branch(working_dir: Path) -> str:
    """Resolve the repository's default branch.

    Reads ``git symbolic-ref refs/remotes/origin/HEAD`` (e.g.
    ``refs/remotes/origin/master``) and strips the
    ``refs/remotes/origin/`` prefix. Falls back to ``"main"`` when the
    command fails or returns an empty/unexpected value (for instance a
    repo with no ``origin/HEAD`` ref).

    Args:
        working_dir: A directory inside the git repository.

    Returns:
        The default branch name, or ``"main"`` as a fallback.
    """
    result = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], working_dir)
    ref = result.stdout.strip()
    if result.returncode != 0 or not ref.startswith(_ORIGIN_HEAD_PREFIX):
        return "main"
    branch = ref.removeprefix(_ORIGIN_HEAD_PREFIX)
    return branch or "main"

run_gh(args, cwd, *, timeout=DEFAULT_GH_TIMEOUT)

Run a GitHub CLI command.

Parameters:

Name Type Description Default
args list[str]

gh subcommand and arguments.

required
cwd Path

Working directory (project root).

required
timeout float | None

Subprocess timeout in seconds (default 120.0). Use None to disable.

DEFAULT_GH_TIMEOUT

Returns:

Type Description
CompletedProcess[str]

Completed process result with capture_output=True and text=True.

Raises:

Type Description
FileNotFoundError

If gh is not installed.

TimeoutExpired

If the command exceeds timeout. Callers should catch and convert via :func:timeout_error_result.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def run_gh(
    args: list[str],
    cwd: Path,
    *,
    timeout: float | None = DEFAULT_GH_TIMEOUT,
) -> subprocess.CompletedProcess[str]:
    """Run a GitHub CLI command.

    Args:
        args: gh subcommand and arguments.
        cwd: Working directory (project root).
        timeout: Subprocess timeout in seconds (default 120.0). Use
            ``None`` to disable.

    Returns:
        Completed process result with ``capture_output=True`` and ``text=True``.

    Raises:
        FileNotFoundError: If ``gh`` is not installed.
        subprocess.TimeoutExpired: If the command exceeds *timeout*.
            Callers should catch and convert via :func:`timeout_error_result`.
    """
    try:
        return subprocess.run(
            ["gh", *args],
            cwd=str(cwd),
            timeout=timeout,
            capture_output=True,
            text=True,
            check=False,
        )
    except subprocess.TimeoutExpired:
        logger.warning("gh %s timed out after %ss", args[0] if args else "", timeout)
        raise

run_git(args, cwd, *, timeout=DEFAULT_GIT_TIMEOUT)

Run a git command in the given directory.

Parameters:

Name Type Description Default
args list[str]

Git subcommand and arguments (e.g. ["status", "--short"]).

required
cwd Path

Working directory (project root).

required
timeout float | None

Subprocess timeout in seconds (default 30.0). Use None to disable.

DEFAULT_GIT_TIMEOUT

Returns:

Type Description
CompletedProcess[str]

Completed process result with capture_output=True and text=True.

Raises:

Type Description
TimeoutExpired

If the command exceeds timeout. Callers should catch and convert via :func:timeout_error_result.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def run_git(
    args: list[str],
    cwd: Path,
    *,
    timeout: float | None = DEFAULT_GIT_TIMEOUT,
) -> subprocess.CompletedProcess[str]:
    """Run a git command in the given directory.

    Args:
        args: Git subcommand and arguments (e.g. ``["status", "--short"]``).
        cwd: Working directory (project root).
        timeout: Subprocess timeout in seconds (default 30.0). Use
            ``None`` to disable.

    Returns:
        Completed process result with ``capture_output=True`` and ``text=True``.

    Raises:
        subprocess.TimeoutExpired: If the command exceeds *timeout*.
            Callers should catch and convert via :func:`timeout_error_result`.
    """
    try:
        return subprocess.run(
            ["git", *args],
            cwd=str(cwd),
            timeout=timeout,
            capture_output=True,
            text=True,
            check=False,
        )
    except subprocess.TimeoutExpired:
        logger.warning("git %s timed out after %ss", args[0] if args else "", timeout)
        raise

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/core/runner.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

staged_delta(before, after)

Return the sorted paths staged between two index snapshots.

The delta is after - before — exactly the paths a staging operation introduced, excluding anything a third party had already staged before the call. Sorted for deterministic output.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def staged_delta(before: set[str], after: set[str]) -> list[str]:
    """Return the sorted paths staged between two index snapshots.

    The delta is ``after - before`` — exactly the paths a staging operation
    introduced, excluding anything a third party had already staged before
    the call. Sorted for deterministic output.
    """
    return sorted(after - before)

suggest_git_repos(path)

Find immediate child directories that are git repositories.

Scans one level deep for subdirectories containing a .git dir. Returns a sorted list of directory names. If path is itself a git repository (has .git/ at root), returns an empty list.

Parameters:

Name Type Description Default
path Path

Directory to scan.

required

Returns:

Type Description
list[str]

Sorted list of child directory names that are git repos.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def suggest_git_repos(path: Path) -> list[str]:
    """Find immediate child directories that are git repositories.

    Scans one level deep for subdirectories containing a ``.git`` dir.
    Returns a sorted list of directory names.  If *path* is itself a
    git repository (has ``.git/`` at root), returns an empty list.

    Args:
        path: Directory to scan.

    Returns:
        Sorted list of child directory names that are git repos.
    """
    if (path / ".git").is_dir():
        return []

    repos: list[str] = []
    try:
        children = sorted(path.iterdir())
    except (PermissionError, FileNotFoundError):
        return []

    for child in children:
        if not child.is_dir():
            continue
        try:
            if (child / ".git").is_dir():
                repos.append(child.name)
        except PermissionError:
            continue

    return repos

timeout_error_result(exc)

Build a ToolResult for a subprocess.TimeoutExpired.

Source code in packages/axm-git/src/axm_git/core/runner.py
Python
def timeout_error_result(exc: subprocess.TimeoutExpired) -> ToolResult:
    """Build a ``ToolResult`` for a ``subprocess.TimeoutExpired``."""
    cmd = exc.cmd
    if isinstance(cmd, (list, tuple)) and cmd:
        cmd_str = str(cmd[0])
    else:
        cmd_str = str(cmd)
    error = f"{cmd_str} timed out after {exc.timeout}s"
    return ToolResult(success=False, error=error, text=error)

semver

Semantic versioning — parse commits and compute next version.

VersionBump dataclass

Result of a semver computation.

Attributes:

Name Type Description
current str

Current tag (e.g. "v0.7.0").

next str

Next tag (e.g. "v0.8.0").

bump str

Bump type ("major", "minor", or "patch").

commits list[str]

One-line commit summaries since last tag.

breaking bool

Whether a breaking change was detected.

Source code in packages/axm-git/src/axm_git/core/semver.py
Python
@dataclass(frozen=True)
class VersionBump:
    """Result of a semver computation.

    Attributes:
        current: Current tag (e.g. ``"v0.7.0"``).
        next: Next tag (e.g. ``"v0.8.0"``).
        bump: Bump type (``"major"``, ``"minor"``, or ``"patch"``).
        commits: One-line commit summaries since last tag.
        breaking: Whether a breaking change was detected.
    """

    current: str
    next: str
    bump: str
    commits: list[str]
    breaking: bool

classify_commit(subject)

Classify a single conventional-commit subject.

Reuses the module regexes so per-commit labelling stays consistent with :func:compute_bump. Internal-public: importable by sibling tools (e.g. release_diff) but intentionally absent from __all__.

Parameters:

Name Type Description Default
subject str

A commit subject line, optionally prefixed by a short hash (<hash> <message>); the hash is stripped when present.

required

Returns:

Type Description
str

(type, breaking) where type is the real conventional type

bool

("feat", "fix", "docs", "refactor", "chore",

tuple[str, bool]

"build", "ci", "perf", "style", "revert", …)

tuple[str, bool]

when the subject carries a conventional prefix, falling back to

tuple[str, bool]

"other" otherwise. breaking is True for feat!:-style

tuple[str, bool]

commits or BREAKING CHANGE: bodies. Display-only: the bump

tuple[str, bool]

logic (:func:compute_bump) is unaffected by the extra types.

Source code in packages/axm-git/src/axm_git/core/semver.py
Python
def classify_commit(subject: str) -> tuple[str, bool]:
    """Classify a single conventional-commit *subject*.

    Reuses the module regexes so per-commit labelling stays consistent
    with :func:`compute_bump`. Internal-public: importable by sibling
    tools (e.g. ``release_diff``) but intentionally absent from ``__all__``.

    Args:
        subject: A commit subject line, optionally prefixed by a short
            hash (``<hash> <message>``); the hash is stripped when present.

    Returns:
        ``(type, breaking)`` where ``type`` is the real conventional type
        (``"feat"``, ``"fix"``, ``"docs"``, ``"refactor"``, ``"chore"``,
        ``"build"``, ``"ci"``, ``"perf"``, ``"style"``, ``"revert"``, …)
        when the subject carries a conventional prefix, falling back to
        ``"other"`` otherwise. ``breaking`` is True for ``feat!:``-style
        commits or ``BREAKING CHANGE:`` bodies. Display-only: the bump
        logic (:func:`compute_bump`) is unaffected by the extra types.
    """
    head, sep, rest = subject.partition(" ")
    msg = rest if sep and _SHORT_HASH_RE.match(head) else subject
    breaking = bool(_BREAKING_RE.match(msg)) or "BREAKING CHANGE:" in msg
    if _FEAT_RE.match(msg):
        return "feat", breaking
    if _FIX_RE.match(msg):
        return "fix", breaking
    match = _CONVENTIONAL_PREFIX_RE.match(msg)
    return (match.group(1) if match else "other"), breaking

compute_bump(commits, current_tag)

Compute the next semver version from commit messages.

Rules (pre-1.0, i.e. major == 0): - feat!: or BREAKING CHANGE:minor bump - feat:minor bump - everything else → patch bump

Rules (post-1.0): - feat!: or BREAKING CHANGE:major bump - feat:minor bump - everything else → patch bump

Accepts both git log --oneline lines (<short-hash> <message>) and raw conventional-commit messages (feat: x). The leading token is stripped only when it matches a short-hash shape (hex, 3-40 chars); otherwise the whole line is treated as the message.

Parameters:

Name Type Description Default
commits list[str]

Commit lines, either oneline (<hash> <msg>) or raw conventional-commit messages.

required
current_tag str

Current version tag (e.g. "v0.7.0").

required

Returns:

Type Description
VersionBump

VersionBump with computed next version.

Source code in packages/axm-git/src/axm_git/core/semver.py
Python
def compute_bump(commits: list[str], current_tag: str) -> VersionBump:
    """Compute the next semver version from commit messages.

    Rules (pre-1.0, i.e. major == 0):
        - ``feat!:`` or ``BREAKING CHANGE:`` → **minor** bump
        - ``feat:`` → **minor** bump
        - everything else → **patch** bump

    Rules (post-1.0):
        - ``feat!:`` or ``BREAKING CHANGE:`` → **major** bump
        - ``feat:`` → **minor** bump
        - everything else → **patch** bump

    Accepts both ``git log --oneline`` lines (``<short-hash> <message>``)
    and raw conventional-commit messages (``feat: x``). The leading token
    is stripped only when it matches a short-hash shape
    (hex, 3-40 chars); otherwise the whole line is treated as the message.

    Args:
        commits: Commit lines, either oneline (``<hash> <msg>``) or raw
            conventional-commit messages.
        current_tag: Current version tag (e.g. ``"v0.7.0"``).

    Returns:
        VersionBump with computed next version.
    """
    logger.info("Computing version bump from %s", current_tag)
    major, minor, patch = parse_tag(current_tag)
    has_breaking, has_feat = _classify_commits(commits)
    bump, next_version = _next_version(
        major, minor, patch, has_breaking=has_breaking, has_feat=has_feat
    )

    return VersionBump(
        current=current_tag,
        next=next_version,
        bump=bump,
        commits=commits,
        breaking=has_breaking,
    )

parse_tag(tag)

Parse a semver tag string into (major, minor, patch).

Parameters:

Name Type Description Default
tag str

Version string, with or without v prefix.

required

Returns:

Type Description
tuple[int, int, int]

Tuple of (major, minor, patch).

Raises:

Type Description
ValueError

If the tag doesn't match semver format.

Source code in packages/axm-git/src/axm_git/core/semver.py
Python
def parse_tag(tag: str) -> tuple[int, int, int]:
    """Parse a semver tag string into ``(major, minor, patch)``.

    Args:
        tag: Version string, with or without ``v`` prefix.

    Returns:
        Tuple of (major, minor, patch).

    Raises:
        ValueError: If the tag doesn't match semver format.
    """
    m = _TAG_RE.match(tag)
    if not m:
        msg = f"Invalid semver tag: {tag!r}"
        raise ValueError(msg)
    return int(m.group(1)), int(m.group(2)), int(m.group(3))

branch_naming

Branch naming convention for ticket-driven workflows.

CONVENTIONAL_COMMIT_FORMAT = '<type>[(scope)][!]: <description>' module-attribute

Human-readable expected Conventional Commit summary format.

branch_name_from_ticket(ticket_id, title, labels)

Build a deterministic branch name from ticket metadata.

Produces names in the format <type>/<TICKET_ID>-<slug> where type is derived from the ticket labels.

Parameters:

Name Type Description Default
ticket_id str

Ticket identifier (e.g. "AXM-42").

required
title str

Ticket title used to generate the slug.

required
labels list[str]

Ticket labels used to determine the branch type.

required

Returns:

Type Description
str

A URL-safe branch name.

Source code in packages/axm-git/src/axm_git/core/branch_naming.py
Python
def branch_name_from_ticket(
    ticket_id: str,
    title: str,
    labels: list[str],
) -> str:
    """Build a deterministic branch name from ticket metadata.

    Produces names in the format ``<type>/<TICKET_ID>-<slug>`` where
    *type* is derived from the ticket labels.

    Args:
        ticket_id: Ticket identifier (e.g. ``"AXM-42"``).
        title: Ticket title used to generate the slug.
        labels: Ticket labels used to determine the branch type.

    Returns:
        A URL-safe branch name.
    """
    branch_type = _resolve_type(labels, title)
    slug = slugify(title)
    return f"{branch_type}/{ticket_id}-{slug}"

is_conventional_commit(message)

Return whether message matches the Conventional Commit format.

A message is conventional when it starts with type: or type(scope): (an optional breaking ! marker is allowed before the colon, e.g. feat!: or fix(scope)!:) followed by a space. This is the single source of truth shared by branch-type inference and the git_commit validation path.

Parameters:

Name Type Description Default
message str

Commit summary line to validate.

required

Returns:

Type Description
bool

True if the message is conventionally formatted.

Source code in packages/axm-git/src/axm_git/core/branch_naming.py
Python
def is_conventional_commit(message: str) -> bool:
    """Return whether *message* matches the Conventional Commit format.

    A message is conventional when it starts with ``type:`` or
    ``type(scope):`` (an optional breaking ``!`` marker is allowed before
    the colon, e.g. ``feat!:`` or ``fix(scope)!:``) followed by a space.
    This is the single source of truth shared by branch-type inference
    and the ``git_commit`` validation path.

    Args:
        message: Commit summary line to validate.

    Returns:
        ``True`` if the message is conventionally formatted.
    """
    return _CONVENTIONAL_PREFIX_RE.match(message) is not None

slugify(title, *, max_len=40)

Convert a title string into a URL-safe slug.

Lowercases the input, replaces non-alphanumeric characters with hyphens, collapses consecutive hyphens, and strips leading/trailing hyphens.

Parameters:

Name Type Description Default
title str

The title to slugify.

required
max_len int

Maximum length of the slug (default 40). Truncation prefers word boundaries when possible.

40

Returns:

Type Description
str

A sanitized slug, or "untitled" if the title is empty or

str

contains only special characters.

Source code in packages/axm-git/src/axm_git/core/branch_naming.py
Python
def slugify(title: str, *, max_len: int = 40) -> str:
    """Convert a title string into a URL-safe slug.

    Lowercases the input, replaces non-alphanumeric characters with hyphens,
    collapses consecutive hyphens, and strips leading/trailing hyphens.

    Args:
        title: The title to slugify.
        max_len: Maximum length of the slug (default 40). Truncation
            prefers word boundaries when possible.

    Returns:
        A sanitized slug, or ``"untitled"`` if the title is empty or
        contains only special characters.
    """
    slug = title.lower()
    slug = re.sub(r"[^a-z0-9]+", "-", slug)
    slug = slug.strip("-")

    if not slug:
        return "untitled"

    if len(slug) <= max_len:
        return slug

    # Truncate at word boundary
    truncated = slug[:max_len]
    last_hyphen = truncated.rfind("-")
    if last_hyphen > 0:
        truncated = truncated[:last_hyphen]

    return truncated.rstrip("-")

commit_cmd

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/core/commit_cmd.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

commit_spec

Shared commit spec validation and Git hook autofix-retry plumbing.

AutofixRetry dataclass

Outcome of an autofix-aware commit retry.

Attributes:

Name Type Description
result _GitResultLike

The final GitResult-like object (returncode/stdout/stderr).

retried bool

Whether a re-stage + retry was actually performed.

auto_fixed list[str]

Files the commit hook modified, captured before re-staging (the subsequent git add would empty the diff). Empty when no auto-fix occurred.

Source code in packages/axm-git/src/axm_git/core/commit_spec.py
Python
@dataclass
class AutofixRetry:
    """Outcome of an autofix-aware commit retry.

    Attributes:
        result: The final GitResult-like object (returncode/stdout/stderr).
        retried: Whether a re-stage + retry was actually performed.
        auto_fixed: Files the commit hook modified, captured *before*
            re-staging (the subsequent ``git add`` would empty the diff).
            Empty when no auto-fix occurred.
    """

    result: _GitResultLike
    retried: bool
    auto_fixed: list[str]

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

Re-stage + retry cmd once when a commit hook auto-fixed files.

Detection is on the combined stdout+stderr of first_result: when the canonical "files were modified" marker is present, the modified files are captured (git diff --name-only before re-staging), the spec files are re-staged via the subdir-aware resolver, and cmd is retried once. Otherwise first_result is returned unchanged.

Source code in packages/axm-git/src/axm_git/core/commit_spec.py
Python
def attempt_commit_with_autofix_retry(
    cmd: list[str],
    files: list[str],
    git_root: Path,
    first_result: _GitResultLike,
    *,
    working_dir: Path | None = None,
) -> AutofixRetry:
    """Re-stage + retry *cmd* once when a commit hook auto-fixed files.

    Detection is on the combined stdout+stderr of *first_result*: when the
    canonical ``"files were modified"`` marker is present, the modified
    files are captured (``git diff --name-only`` *before* re-staging), the
    spec *files* are re-staged via the subdir-aware resolver, and *cmd* is
    retried once.  Otherwise *first_result* is returned unchanged.
    """
    if first_result.returncode == 0:
        return AutofixRetry(result=first_result, retried=False, auto_fixed=[])

    output = first_result.stdout + first_result.stderr
    if AUTOFIX_MARKER not in output:
        return AutofixRetry(result=first_result, retried=False, auto_fixed=[])

    logger.warning("Commit hook auto-fixed files, re-staging and retrying")
    diff = run_git(["diff", "--name-only"], git_root)
    auto_fixed = [f for f in diff.stdout.strip().splitlines() if f.strip()]

    restage_err = stage_spec_files(files, git_root, working_dir=working_dir)
    if restage_err:
        failed = cast(
            "_GitResultLike",
            SimpleNamespace(returncode=1, stdout="", stderr=restage_err),
        )
        return AutofixRetry(result=failed, retried=True, auto_fixed=auto_fixed)

    head_before = _head_sha(git_root)
    retried = run_git(cmd, git_root)
    reconciled = _reconcile_with_repo_state(retried, git_root, head_before)
    return AutofixRetry(result=reconciled, retried=True, auto_fixed=auto_fixed)

validate_commit_spec(spec)

Validate a commit_spec dict (pure; stricter merged contract).

Requires a non-empty message AND a non-empty files list — the stricter of the two prior per-surface validators. Returns (spec, error_message) where spec is None when an error is set; each surface wraps the error string in its own result type.

Source code in packages/axm-git/src/axm_git/core/commit_spec.py
Python
def validate_commit_spec(
    spec: dict[str, object] | None,
) -> tuple[dict[str, object] | None, str | None]:
    """Validate a ``commit_spec`` dict (pure; stricter merged contract).

    Requires a non-empty ``message`` AND a non-empty ``files`` list — the
    stricter of the two prior per-surface validators.  Returns
    ``(spec, error_message)`` where *spec* is ``None`` when an error is set;
    each surface wraps the error string in its own result type.
    """
    if not spec:
        return None, "from_outputs=True but no commit_spec in context"
    if not isinstance(spec, dict):
        return None, "commit_spec must be a dict"
    missing = _REQUIRED_SPEC_KEYS - set(spec)
    if missing:
        return None, (
            f"commit_spec missing {', '.join(repr(k) for k in sorted(missing))}"
        )
    if not spec.get("files"):
        return None, "empty files list"
    if not spec.get("message"):
        return None, "empty message"
    return spec, None

phase_commit

Retrieve commit hashes for AXM protocol phases.

get_phase_commit(working_dir, phase_name, *, message_format='[axm] {phase}')

Retrieve the commit hash associated with an AXM phase.

Searches git log for commits whose message matches the format used by legacy phase integrations.

Parameters:

Name Type Description Default
working_dir Path

Repository root path.

required
phase_name str

Phase name to search for.

required
message_format str

Message pattern used by the phase integration. (default "[axm] {phase}").

'[axm] {phase}'

Returns:

Type Description
str | None

Short commit hash if found, None otherwise.

Source code in packages/axm-git/src/axm_git/core/phase_commit.py
Python
def get_phase_commit(
    working_dir: Path,
    phase_name: str,
    *,
    message_format: str = "[axm] {phase}",
) -> str | None:
    """Retrieve the commit hash associated with an AXM phase.

    Searches git log for commits whose message matches the format
    used by legacy phase integrations.

    Args:
        working_dir: Repository root path.
        phase_name: Phase name to search for.
        message_format: Message pattern used by the phase integration.
            (default ``"[axm] {phase}"``).

    Returns:
        Short commit hash if found, ``None`` otherwise.
    """
    if not (working_dir / ".git").exists():
        return None

    needle = message_format.format(phase=phase_name)
    result = run_git(
        ["log", "--oneline", "--grep", needle, "--format=%h", "-1"],
        working_dir,
    )
    sha = result.stdout.strip()
    return sha if sha else None

pr_recovery

Shared recovery for an already-existing GitHub pull request.

When gh pr create fails because a PR already exists for the branch, :class:~axm_git.tools.pr.GitPRTool recovers it via gh pr view. This module factors that recovery into a result-agnostic helper.

PRRecovery dataclass

Normalized result of recovering an existing pull request.

On success error is None and url/number are populated. On failure error carries the reason and url/number are empty.

Source code in packages/axm-git/src/axm_git/core/pr_recovery.py
Python
@dataclass(frozen=True)
class PRRecovery:
    """Normalized result of recovering an existing pull request.

    On success ``error`` is ``None`` and ``url``/``number`` are populated.
    On failure ``error`` carries the reason and ``url``/``number`` are empty.
    """

    url: str = ""
    number: str = ""
    already_existed: bool = False
    error: str | None = None

    @property
    def ok(self) -> bool:
        """Whether recovery succeeded."""
        return self.error is None
ok property

Whether recovery succeeded.

is_already_exists(stderr)

Return True when stderr signals an existing PR (case-insensitive).

Source code in packages/axm-git/src/axm_git/core/pr_recovery.py
Python
def is_already_exists(stderr: str) -> bool:
    """Return ``True`` when *stderr* signals an existing PR (case-insensitive)."""
    return "already exists" in stderr.lower()

recover_existing_pr(working_dir)

Resolve the existing PR via gh pr view after an 'already exists' error.

Parameters:

Name Type Description Default
working_dir Path

Repository working directory.

required

Returns:

Name Type Description
A PRRecovery

class:PRRecovery with url/number/already_existed on

PRRecovery

success, or with error set when the PR could not be retrieved.

Source code in packages/axm-git/src/axm_git/core/pr_recovery.py
Python
def recover_existing_pr(working_dir: Path) -> PRRecovery:
    """Resolve the existing PR via ``gh pr view`` after an 'already exists' error.

    Args:
        working_dir: Repository working directory.

    Returns:
        A :class:`PRRecovery` with ``url``/``number``/``already_existed`` on
        success, or with ``error`` set when the PR could not be retrieved.
    """
    view = run_gh(["pr", "view", "--json", "url,number"], working_dir)
    if view.returncode != 0:
        return PRRecovery(
            error=f"PR already exists but could not retrieve it: {view.stderr}"
        )
    try:
        data = json.loads(view.stdout)
        return PRRecovery(
            url=data["url"],
            number=str(data["number"]),
            already_existed=True,
        )
    except (json.JSONDecodeError, KeyError) as exc:
        return PRRecovery(error=f"PR already exists but could not parse it: {exc}")

gh_auth

classify_gh_auth(available, returncode)

Classify the GitHub CLI authentication state from observable process state.

Source code in packages/axm-git/src/axm_git/core/gh_auth.py
Python
def classify_gh_auth(available: bool, returncode: int | None) -> str:
    """Classify the GitHub CLI authentication state from observable process state."""
    if not available:
        return "not_installed"
    if returncode == 0:
        return "logged_in"
    return "logged_out"

gh_auth_state()

Return the local GitHub CLI authentication state.

Source code in packages/axm-git/src/axm_git/core/gh_auth.py
Python
def gh_auth_state() -> str:
    """Return the local GitHub CLI authentication state."""
    if gh_available():
        return classify_gh_auth(available=True, returncode=0)

    try:
        result = run_gh(["auth", "status"], Path.cwd())
    except FileNotFoundError:
        return classify_gh_auth(available=False, returncode=None)

    return classify_gh_auth(available=True, returncode=result.returncode)

credentials

GhAuthDependency

Bases: AuthDependencySpec

GitHub CLI authentication dependency with catalog metadata.

Source code in packages/axm-git/src/axm_git/credentials.py
Python
class GhAuthDependency(AuthDependencySpec):  # type: ignore[explicit-any]
    """GitHub CLI authentication dependency with catalog metadata."""

    package: str
    status_command: str
    login_command: str

    def __init__(
        self,
        *,
        name: str,
        source: object,
        package: str,
        status_command: str,
        login_command: str,
    ) -> None:
        super().__init__(
            name=name,
            source=source,
            package=package,
            status_command=status_command,
            login_command=login_command,
        )

gh_credentials()

Declare the GitHub CLI session consumed by axm-git.

Source code in packages/axm-git/src/axm_git/credentials.py
Python
def gh_credentials() -> list[CredentialGroup]:
    """Declare the GitHub CLI session consumed by axm-git."""
    dependency = GhAuthDependency(
        name="gh",
        source=_GhAuthSource(),
        package="axm-git",
        status_command="gh auth status",
        login_command="gh auth login",
    )
    group = CredentialGroup(
        id="gh",
        package="axm-git",
        title="GitHub CLI",
        specs=(),
        auth_dependencies=(dependency,),
    )
    return [group]