Skip to content

Tag

tag

GitTagTool — one-shot semver tag: preflight + compute + create + verify + push.

GitTagTool

Bases: AXMTool

Create a semver release tag in one call.

Performs preflight checks (clean tree, CI status), computes the next version from Conventional Commits, creates an annotated tag, verifies hatch-vcs resolution, and pushes to origin.

Registered as git_tag via axm.tools entry point.

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
class GitTagTool(AXMTool):
    """Create a semver release tag in one call.

    Performs preflight checks (clean tree, CI status), computes the
    next version from Conventional Commits, creates an annotated tag,
    verifies hatch-vcs resolution, and pushes to origin.

    Registered as ``git_tag`` via axm.tools entry point.
    """

    @property
    def name(self) -> str:
        """Tool name used for MCP registration."""
        return "git_tag"

    def execute(
        self,
        *,
        path: str = ".",
        version: str | None = None,
        **kwargs: object,
    ) -> ToolResult:
        """Create and push a semver tag.

        Args:
            path: Project root (required).
            version: Version override (optional, e.g. ``"v1.0.0"``).

        Returns:
            ToolResult with tag, version, and push status.
        """
        resolved = Path(path).resolve()
        tag_prefix = get_tag_prefix(resolved)

        try:
            # 1. Preflight: repo, clean tree, CI, commits
            result = _preflight(resolved, tag_prefix=tag_prefix)
            if isinstance(result, ToolResult):
                return result
            ci_check, current_tag, commits = result

            # 2. Compute version
            next_version, bump_type, breaking = _resolve_version(
                version, current_tag, commits, tag_prefix=tag_prefix
            )
            logger.info(
                "Tagging %s (bump=%s, breaking=%s)",
                next_version,
                bump_type,
                breaking,
            )

            # 3. Create annotated tag
            full_tag = f"{tag_prefix}{next_version}"
            tag_result = run_git(["tag", "-a", full_tag, "-m", full_tag], resolved)
            if tag_result.returncode != 0:
                return ToolResult(
                    success=False,
                    error=f"Failed to create tag: {tag_result.stderr.strip()}",
                )

            # 4. Verify hatch-vcs (best-effort)
            resolved_version = None
            pkg_name = detect_package_name(resolved)
            if pkg_name:
                resolved_version = verify_hatch_vcs(resolved, pkg_name)

            # 5. Push tag
            push = run_git(["push", "origin", full_tag], resolved)
        except subprocess.TimeoutExpired as exc:
            return _timeout_error_result(exc)

        return ToolResult(
            success=True,
            data={
                "tag": next_version,
                "bump": bump_type,
                "breaking": breaking,
                "resolved_version": resolved_version,
                "pushed": push.returncode == 0,
                "ci_check": ci_check,
                "commits_included": len(commits),
                "current_tag": current_tag or "none",
            },
        )
name property

Tool name used for MCP registration.

execute(*, path='.', version=None, **kwargs)

Create and push a semver tag.

Parameters:

Name Type Description Default
path str

Project root (required).

'.'
version str | None

Version override (optional, e.g. "v1.0.0").

None

Returns:

Type Description
ToolResult

ToolResult with tag, version, and push status.

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def execute(
    self,
    *,
    path: str = ".",
    version: str | None = None,
    **kwargs: object,
) -> ToolResult:
    """Create and push a semver tag.

    Args:
        path: Project root (required).
        version: Version override (optional, e.g. ``"v1.0.0"``).

    Returns:
        ToolResult with tag, version, and push status.
    """
    resolved = Path(path).resolve()
    tag_prefix = get_tag_prefix(resolved)

    try:
        # 1. Preflight: repo, clean tree, CI, commits
        result = _preflight(resolved, tag_prefix=tag_prefix)
        if isinstance(result, ToolResult):
            return result
        ci_check, current_tag, commits = result

        # 2. Compute version
        next_version, bump_type, breaking = _resolve_version(
            version, current_tag, commits, tag_prefix=tag_prefix
        )
        logger.info(
            "Tagging %s (bump=%s, breaking=%s)",
            next_version,
            bump_type,
            breaking,
        )

        # 3. Create annotated tag
        full_tag = f"{tag_prefix}{next_version}"
        tag_result = run_git(["tag", "-a", full_tag, "-m", full_tag], resolved)
        if tag_result.returncode != 0:
            return ToolResult(
                success=False,
                error=f"Failed to create tag: {tag_result.stderr.strip()}",
            )

        # 4. Verify hatch-vcs (best-effort)
        resolved_version = None
        pkg_name = detect_package_name(resolved)
        if pkg_name:
            resolved_version = verify_hatch_vcs(resolved, pkg_name)

        # 5. Push tag
        push = run_git(["push", "origin", full_tag], resolved)
    except subprocess.TimeoutExpired as exc:
        return _timeout_error_result(exc)

    return ToolResult(
        success=True,
        data={
            "tag": next_version,
            "bump": bump_type,
            "breaking": breaking,
            "resolved_version": resolved_version,
            "pushed": push.returncode == 0,
            "ci_check": ci_check,
            "commits_included": len(commits),
            "current_tag": current_tag or "none",
        },
    )

check_ci(path)

Check CI status via gh. Returns one of green/red/pending/skipped/error.

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def check_ci(path: Path) -> str:
    """Check CI status via ``gh``.  Returns one of green/red/pending/skipped/error."""
    if not gh_available():
        return "skipped"
    try:
        ci = run_gh(
            [
                "run",
                "list",
                "--branch",
                "main",
                "--limit",
                "3",
                "--json",
                "status,conclusion,headSha",
            ],
            path,
        )
        if ci.returncode != 0 or not ci.stdout.strip():
            return "skipped"
        runs = json.loads(ci.stdout)
        if not runs:
            return "skipped"
        latest = runs[0]
        if latest.get("conclusion") == "success":
            return "green"
        if latest.get("status") == "in_progress":
            return "pending"
        return "red"
    except (json.JSONDecodeError, FileNotFoundError):
        return "error"

get_tag_prefix(path)

Read tag prefix from pyproject.toml tag-pattern (e.g. git/).

Returns the prefix string (e.g. "git/") or "" if none.

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def get_tag_prefix(path: Path) -> str:
    """Read tag prefix from pyproject.toml ``tag-pattern`` (e.g. ``git/``).

    Returns the prefix string (e.g. ``"git/"``) or ``""`` if none.
    """
    pyproject = path / "pyproject.toml"
    if not pyproject.exists():
        return ""
    try:
        with open(pyproject, "rb") as f:
            data = tomllib.load(f)
        pattern = (
            data.get("tool", {})
            .get("hatch", {})
            .get("version", {})
            .get("tag-pattern", "")
        )
        # Extract prefix before "v" from patterns like "git/v(?P<version>.*)"
        m = re.match(r"^(.+?)v\(", pattern)
        return m.group(1) if m else ""
    except (OSError, tomllib.TOMLDecodeError):
        return ""

verify_hatch_vcs(path, pkg_name)

Rebuild package and read resolved version (best-effort).

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def verify_hatch_vcs(path: Path, pkg_name: str) -> str | None:
    """Rebuild package and read resolved version (best-effort)."""
    try:
        sync = subprocess.run(
            ["uv", "sync", "--reinstall-package", pkg_name],
            cwd=str(path),
            capture_output=True,
            text=True,
            check=False,
            timeout=600,
        )
        if sync.returncode != 0:
            return None
        ver = subprocess.run(
            [
                "uv",
                "run",
                "python",
                "-c",
                f"from importlib.metadata import version; print(version('{pkg_name}'))",
            ],
            cwd=str(path),
            capture_output=True,
            text=True,
            check=False,
            timeout=60,
        )
        if ver.returncode == 0:
            return ver.stdout.strip()
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    return None