186 lines
6.2 KiB
Python
186 lines
6.2 KiB
Python
# =================== AIPass ====================
|
|
# Name: status_handler_gitpython.py
|
|
# Description: GitPython prototype for scoped git status (DPLAN-0140 Phase 1)
|
|
# Version: 0.1.0
|
|
# Created: 2026-04-21
|
|
# Modified: 2026-04-21
|
|
# =============================================
|
|
|
|
"""
|
|
GitPython prototype for scoped git status -- DPLAN-0140 Phase 1.
|
|
|
|
Drop-in replacement for status_handler.py that uses GitPython's ``Repo``
|
|
object instead of ``subprocess.run(["git", "status", "--porcelain"])``.
|
|
|
|
The return dict format is identical to the subprocess version::
|
|
|
|
{
|
|
"files": [{"status": str, "path": str}, ...],
|
|
"total": int,
|
|
"message": str,
|
|
}
|
|
|
|
Status codes mapped from GitPython change_type:
|
|
M modified (staged or unstaged)
|
|
A added / new in index
|
|
D deleted
|
|
R renamed
|
|
? untracked (working-tree new, not staged)
|
|
|
|
Design note (two-library split):
|
|
GitHub CLI interactions (gh pr create, gh pr list, gh pr merge) are kept
|
|
as subprocess calls because they require the gh binary's authentication
|
|
context and REST logic. GitPython covers all *local* git operations.
|
|
This split is intentional and documented in the Phase 1 investigation
|
|
report at docs.local/gitpython_investigation_2026-04-20.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from aipass.prax import logger
|
|
from aipass.drone.apps.handlers.json import json_handler
|
|
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
|
|
|
|
if TYPE_CHECKING:
|
|
from git import Repo as GitRepo
|
|
|
|
try:
|
|
import git as _git_module
|
|
|
|
_GITPYTHON_AVAILABLE = True
|
|
except ImportError as exc:
|
|
logger.info("status_handler_gitpython: GitPython not installed (%s) — handler disabled", exc)
|
|
_git_module = None # type: ignore[assignment]
|
|
_GITPYTHON_AVAILABLE = False
|
|
|
|
|
|
# Map GitPython diff change_type codes to porcelain-compatible single letters.
|
|
_STAGED_STATUS_MAP: dict[str, str] = {
|
|
"A": "A",
|
|
"D": "D",
|
|
"M": "M",
|
|
"R": "R",
|
|
"C": "C",
|
|
"T": "T",
|
|
"U": "U",
|
|
}
|
|
|
|
_UNSTAGED_STATUS_MAP: dict[str, str] = {
|
|
"D": "D",
|
|
"M": "M",
|
|
"R": "R",
|
|
"A": "A",
|
|
}
|
|
|
|
|
|
def _collect_staged(repo: "GitRepo", rel_prefix: str, rel_dir: str) -> list[dict]:
|
|
"""Return staged changes that fall under the branch directory."""
|
|
files: list[dict] = []
|
|
try:
|
|
staged_diffs = repo.head.commit.diff()
|
|
except Exception as exc: # empty repo or detached HEAD
|
|
logger.info("status_handler_gitpython: could not get staged diffs: %s", exc)
|
|
return files
|
|
|
|
for diff in staged_diffs:
|
|
path = diff.b_path or diff.a_path
|
|
if not path:
|
|
continue
|
|
if not (path.startswith(rel_prefix) or path == rel_dir):
|
|
continue
|
|
change_key = diff.change_type or ""
|
|
code = _STAGED_STATUS_MAP.get(change_key, change_key)
|
|
files.append({"status": code, "path": path})
|
|
return files
|
|
|
|
|
|
def _collect_unstaged(repo: "GitRepo", rel_prefix: str, rel_dir: str) -> list[dict]:
|
|
"""Return unstaged working-tree changes that fall under the branch directory."""
|
|
files: list[dict] = []
|
|
for diff in repo.index.diff(None):
|
|
path = diff.b_path or diff.a_path
|
|
if not path:
|
|
continue
|
|
if not (path.startswith(rel_prefix) or path == rel_dir):
|
|
continue
|
|
change_key = diff.change_type or ""
|
|
code = _UNSTAGED_STATUS_MAP.get(change_key, change_key)
|
|
files.append({"status": code, "path": path})
|
|
return files
|
|
|
|
|
|
def _collect_untracked(repo: "GitRepo", rel_prefix: str, rel_dir: str) -> list[dict]:
|
|
"""Return untracked files that fall under the branch directory."""
|
|
files: list[dict] = []
|
|
for upath in repo.untracked_files:
|
|
if upath.startswith(rel_prefix) or upath == rel_dir:
|
|
files.append({"status": "?", "path": upath})
|
|
return files
|
|
|
|
|
|
def get_branch_status(branch_dir: Path) -> dict:
|
|
"""Get git status filtered to files under branch_dir using GitPython.
|
|
|
|
This is a drop-in replacement for status_handler.get_branch_status().
|
|
The return format is identical; callers do not need to change.
|
|
|
|
Args:
|
|
branch_dir: Absolute path to the branch directory to scope output to.
|
|
|
|
Returns:
|
|
Dict with:
|
|
files -- list of {"status": str, "path": str} dicts
|
|
total -- int count of changed files
|
|
message -- human-readable summary string
|
|
"""
|
|
if not _GITPYTHON_AVAILABLE or _git_module is None:
|
|
logger.error("status_handler_gitpython: GitPython is not installed. Run: pip install gitpython")
|
|
return {
|
|
"files": [],
|
|
"total": 0,
|
|
"message": "GitPython not available -- install with: pip install gitpython",
|
|
}
|
|
|
|
repo_root = find_repo_root()
|
|
|
|
try:
|
|
repo = _git_module.Repo(str(repo_root))
|
|
except _git_module.InvalidGitRepositoryError as exc:
|
|
logger.error("status_handler_gitpython: not a git repository at %s: %s", repo_root, exc)
|
|
return {"files": [], "total": 0, "message": f"Not a git repository: {exc}"}
|
|
except _git_module.GitCommandNotFound as exc:
|
|
logger.error("status_handler_gitpython: git not found: %s", exc)
|
|
return {"files": [], "total": 0, "message": f"git not found: {exc}"}
|
|
|
|
# Compute relative scope for filtering -- identical logic to subprocess version.
|
|
try:
|
|
rel_dir = branch_dir.resolve().relative_to(repo_root.resolve())
|
|
except ValueError:
|
|
logger.warning(
|
|
"get_branch_status: branch_dir %s not relative to repo root %s, using absolute",
|
|
branch_dir,
|
|
repo_root,
|
|
)
|
|
rel_dir = branch_dir
|
|
|
|
rel_prefix = str(rel_dir) + "/"
|
|
rel_dir_str = str(rel_dir)
|
|
|
|
files: list[dict] = []
|
|
files.extend(_collect_staged(repo, rel_prefix, rel_dir_str))
|
|
files.extend(_collect_unstaged(repo, rel_prefix, rel_dir_str))
|
|
files.extend(_collect_untracked(repo, rel_prefix, rel_dir_str))
|
|
|
|
total = len(files)
|
|
message = f"{total} file(s) changed under {rel_dir}"
|
|
json_handler.log_operation(
|
|
"get_branch_status_gitpython",
|
|
{"branch_dir": str(branch_dir), "total": total},
|
|
)
|
|
logger.info(message)
|
|
|
|
return {"files": files, "total": total, "message": message}
|