"""Projects service — git info for managed projects.""" import logging import os import subprocess from typing import Any import config logger = logging.getLogger(__name__) def _run_git(cwd: str, args: list[str]) -> str: """Run a git command in the given directory. Returns stdout or empty string.""" try: result = subprocess.run( ["git"] + args, cwd=cwd, capture_output=True, text=True, check=False, timeout=5, ) if result.returncode != 0: return "" return result.stdout.strip() except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: logger.debug("git %s in %s failed: %s", args, cwd, exc) return "" def _is_git_repo(path: str) -> bool: """Check if path is a git repository.""" return os.path.isdir(os.path.join(path, ".git")) def get_project_info(proj: config.ProjectConfig) -> dict[str, Any]: """Get git info for a single project.""" path = proj["path"] name = proj["name"] if not os.path.isdir(path): return { "name": name, "path": path, "gitea_repo": proj["gitea_repo"], "status": "not-found", "branch": "", "commit_hash": "", "commit_short": "", "commit_msg": "", "commit_time": "", "commit_time_rel": "", "dirty_count": 0, "error": "directory not found", } if not _is_git_repo(path): return { "name": name, "path": path, "gitea_repo": proj["gitea_repo"], "status": "not-git-repo", "branch": "", "commit_hash": "", "commit_short": "", "commit_msg": "", "commit_time": "", "commit_time_rel": "", "dirty_count": 0, } branch = _run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]) commit_hash = _run_git(path, ["rev-parse", "HEAD"]) commit_short = commit_hash[:7] if commit_hash else "" # Format: unix_epoch|subject log_line = _run_git(path, ["log", "-1", "--format=%ct|%s"]) commit_time = "" commit_msg = "" if "|" in log_line: parts = log_line.split("|", 1) commit_time = parts[0] commit_msg = parts[1] # Dirty count status_output = _run_git(path, ["status", "--porcelain"]) dirty_count = len(status_output.splitlines()) if status_output else 0 return { "name": name, "path": path, "gitea_repo": proj["gitea_repo"], "status": "ok", "branch": branch, "commit_hash": commit_hash, "commit_short": commit_short, "commit_msg": commit_msg, "commit_time": commit_time, "commit_time_rel": "", # computed in template via |rel filter "dirty_count": dirty_count, } def get_all_projects() -> list[dict[str, Any]]: """Get info for all managed projects.""" results: list[dict[str, Any]] = [] for proj in config.MANAGED_PROJECTS: try: results.append(get_project_info(proj)) except Exception as exc: logger.exception("get_project_info failed for %s", proj["name"]) results.append({ "name": proj["name"], "path": proj["path"], "gitea_repo": proj["gitea_repo"], "status": "error", "error": str(exc), }) return results def get_project_detail(name: str) -> dict[str, Any]: """Get detailed info for a specific project.""" proj = None for p in config.MANAGED_PROJECTS: if p["name"] == name: proj = p break if proj is None: return {"error": "project not found"} info = get_project_info(proj) path = proj["path"] if info.get("status") != "ok": return info # Recent commits (last 10) log_output = _run_git(path, ["log", "-10", "--oneline"]) recent_commits = log_output.splitlines() if log_output else [] # Diff stat HEAD~1..HEAD diff_stat = "" commit_count_str = _run_git(path, ["rev-list", "--count", "HEAD"]) try: commit_count = int(commit_count_str) if commit_count_str else 0 except ValueError: commit_count = 0 if commit_count >= 2: diff_stat = _run_git(path, ["diff", "--stat", "HEAD~1..HEAD"]) # git status --porcelain status_porcelain = _run_git(path, ["status", "--porcelain"]) info["recent_commits"] = recent_commits info["diff_stat"] = diff_stat info["status_porcelain"] = status_porcelain return info