Skip to content

Index

core

Core logic — subprocess runners and semver computation.

GitIdentity

Bases: BaseModel

A git author identity.

Source code in packages/axm-git/src/axm_git/core/identity.py
Python
class GitIdentity(BaseModel):  # type: ignore[explicit-any]  # pydantic BaseModel exposes Any in its API
    """A git author identity."""

    name: str
    email: str

GitProfileConfig

Bases: BaseModel

Full git-profiles.toml configuration.

Source code in packages/axm-git/src/axm_git/core/identity.py
Python
class GitProfileConfig(BaseModel):  # type: ignore[explicit-any]  # pydantic BaseModel exposes Any in its API
    """Full git-profiles.toml configuration."""

    default: GitIdentity
    profiles: dict[str, GitIdentity] = {}
    schedule: Schedule = Schedule()
    workspace_paths: list[Path] = []
    timezone: str = "Europe/Paris"

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)

author_args(identity)

Build --author arguments for a git command.

Source code in packages/axm-git/src/axm_git/core/identity.py
Python
def author_args(identity: GitIdentity | None) -> list[str]:
    """Build ``--author`` arguments for a git command."""
    if identity is None:
        return []
    return ["--author", f"{identity.name} <{identity.email}>"]

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

load_config(config_path=None)

Load and validate git-profiles configuration.

With an explicit config_path, parse that exact TOML file (unchanged legacy form). With config_path=None (the default), resolve from the axm_config single store [git] section, falling back to the legacy ~/axm/git-profiles.toml (with a migration WARNING) only while the store has no [git] section. Returns None when no config is resolvable anywhere.

Source code in packages/axm-git/src/axm_git/core/identity.py
Python
def load_config(config_path: Path | None = None) -> GitProfileConfig | None:
    """Load and validate git-profiles configuration.

    With an explicit *config_path*, parse that exact TOML file (unchanged
    legacy form). With ``config_path=None`` (the default), resolve from the
    ``axm_config`` single store ``[git]`` section, falling back to the legacy
    ``~/axm/git-profiles.toml`` (with a migration ``WARNING``) only while the
    store has no ``[git]`` section. Returns ``None`` when no config is
    resolvable anywhere.
    """
    if config_path is not None:
        return _load_from_file(config_path)
    from_store = _load_from_store()
    if from_store is not None:
        return from_store
    legacy_path = _legacy_config_path()
    legacy = _load_from_file(legacy_path)
    if legacy is not None:
        logger.warning(
            "Loaded git-profiles from legacy %s — migrate to the axm_config "
            "[git] section (see `axm-config`); the legacy file is transitional",
            legacy_path,
        )
    return legacy

resolve_identity(workspace_path, *, now=None, profile_override=None, config_path=None)

Resolve the git identity for the given workspace.

Returns None when no config is available or an unknown profile is requested via profile_override. An unknown profile_override (a typo, or a request against an empty profile set) emits a WARNING naming the requested profile and the available ones before falling back to None — observability, not a hard failure.

Source code in packages/axm-git/src/axm_git/core/identity.py
Python
def resolve_identity(
    workspace_path: Path,
    *,
    now: datetime | None = None,
    profile_override: str | None = None,
    config_path: Path | None = None,
) -> GitIdentity | None:
    """Resolve the git identity for the given workspace.

    Returns ``None`` when no config is available or an unknown profile
    is requested via *profile_override*. An unknown *profile_override*
    (a typo, or a request against an empty profile set) emits a
    ``WARNING`` naming the requested profile and the available ones
    before falling back to ``None`` — observability, not a hard failure.
    """
    config = load_config(config_path)
    if config is None:
        return None

    override = resolve_by_override(config, profile_override)
    if profile_override is not None:
        return override

    tz = ZoneInfo(config.timezone)
    if now is None:
        effective_now = datetime.now(tz=tz)
    elif now.tzinfo is None:
        effective_now = now
    else:
        effective_now = now.astimezone(tz)
    return resolve_by_schedule(config, workspace_path, effective_now) or config.default

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