163 lines
7.5 KiB
Python
163 lines
7.5 KiB
Python
# #region GitServiceStatusMixin [C:4] [TYPE Module] [SEMANTICS git, status, diff, log, history, lock]
|
|
# @LAYER Infrastructure
|
|
# @BRIEF Status, diff, and commit history operations for GitService (all concurrent-safe via per-dashboard locks).
|
|
# @RELATION CALLED_BY -> [GitService]
|
|
|
|
from datetime import datetime
|
|
|
|
from src.core.logger import belief_scope, logger
|
|
|
|
|
|
# #region GitServiceStatusMixin [C:3] [TYPE Class]
|
|
# @BRIEF Mixin providing repository status, diff, and commit history for GitService.
|
|
class GitServiceStatusMixin:
|
|
# region _parse_status_porcelain [TYPE Function]
|
|
# @PURPOSE: Parse git status --porcelain output into staged, modified, and untracked file lists.
|
|
# @PRE `repo` is an open GitPython Repo instance.
|
|
# @POST Returns (staged, modified, untracked) tuple of file path lists.
|
|
# @RATIONALE Avoids repo.is_dirty() / repo.index.diff("HEAD") which internally
|
|
# call git diff --cached, a flag unsupported in some Git environments (exit 129).
|
|
# Using git status --porcelain is self-contained and avoids the --cached flag entirely.
|
|
def _parse_status_porcelain(self, repo) -> tuple[list[str], list[str], list[str]]:
|
|
with belief_scope("GitService._parse_status_porcelain"):
|
|
staged: list[str] = []
|
|
modified: list[str] = []
|
|
untracked: list[str] = []
|
|
try:
|
|
output = repo.git.status("--porcelain")
|
|
except Exception:
|
|
logger.warning("[_parse_status_porcelain][Coherence:Failed] git status --porcelain failed")
|
|
return staged, modified, untracked
|
|
for line in output.split("\n"):
|
|
if not line:
|
|
continue
|
|
if line.startswith("??"):
|
|
untracked.append(line[2:].strip())
|
|
continue
|
|
if line.startswith("!!"):
|
|
continue
|
|
if len(line) < 3:
|
|
continue
|
|
x = line[0]
|
|
y = line[1]
|
|
rest = line[3:]
|
|
path = rest.split(" -> ")[-1].strip() if " -> " in rest else rest.strip()
|
|
if x != " " and x != "?":
|
|
staged.append(path)
|
|
if y != " " and y != "?":
|
|
if path not in modified:
|
|
modified.append(path)
|
|
return staged, modified, untracked
|
|
# endregion _parse_status_porcelain
|
|
|
|
# region get_status [C:4] [TYPE Function] [SEMANTICS git,status,lock]
|
|
# @PURPOSE: Get current repository status (concurrent-safe).
|
|
# @PRE Repository for dashboard_id exists.
|
|
# @POST Returns a dictionary representing the Git status.
|
|
# @RETURN dict
|
|
def get_status(self, dashboard_id: int) -> dict:
|
|
with self._locked(dashboard_id):
|
|
with belief_scope("GitService.get_status"):
|
|
repo = self.get_repo(dashboard_id)
|
|
has_commits = False
|
|
try:
|
|
repo.head.commit
|
|
has_commits = True
|
|
except (ValueError, Exception):
|
|
has_commits = False
|
|
current_branch = repo.active_branch.name
|
|
tracking_branch = None
|
|
has_upstream = False
|
|
ahead_count = 0
|
|
behind_count = 0
|
|
try:
|
|
tracking_branch = repo.active_branch.tracking_branch()
|
|
has_upstream = tracking_branch is not None
|
|
except Exception:
|
|
tracking_branch = None
|
|
has_upstream = False
|
|
if has_upstream and tracking_branch is not None:
|
|
try:
|
|
ahead_count = sum(1 for _ in repo.iter_commits(f"{tracking_branch.name}..{current_branch}"))
|
|
behind_count = sum(1 for _ in repo.iter_commits(f"{current_branch}..{tracking_branch.name}"))
|
|
except Exception:
|
|
ahead_count = 0
|
|
behind_count = 0
|
|
staged_files, modified_files, untracked_files = self._parse_status_porcelain(repo)
|
|
is_dirty = bool(staged_files or modified_files or untracked_files)
|
|
is_diverged = ahead_count > 0 and behind_count > 0
|
|
if is_diverged:
|
|
sync_state = "DIVERGED"
|
|
elif behind_count > 0:
|
|
sync_state = "BEHIND_REMOTE"
|
|
elif ahead_count > 0:
|
|
sync_state = "AHEAD_REMOTE"
|
|
elif is_dirty or modified_files or staged_files or untracked_files:
|
|
sync_state = "CHANGES"
|
|
else:
|
|
sync_state = "SYNCED"
|
|
return {
|
|
"is_dirty": is_dirty,
|
|
"untracked_files": untracked_files,
|
|
"modified_files": modified_files,
|
|
"staged_files": staged_files,
|
|
"current_branch": current_branch,
|
|
"upstream_branch": tracking_branch.name if tracking_branch is not None else None,
|
|
"has_upstream": has_upstream,
|
|
"ahead_count": ahead_count,
|
|
"behind_count": behind_count,
|
|
"is_diverged": is_diverged,
|
|
"sync_state": sync_state,
|
|
}
|
|
# endregion get_status
|
|
|
|
# region get_diff [C:4] [TYPE Function] [SEMANTICS git,diff,lock]
|
|
# @PURPOSE: Generate diff for a file or the whole repository (concurrent-safe).
|
|
# @PARAM file_path (str) - Optional specific file.
|
|
# @PARAM staged (bool) - Whether to show staged changes.
|
|
# @PRE Repository for dashboard_id exists.
|
|
# @POST Returns the diff text as a string.
|
|
# @RETURN str
|
|
def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
|
|
with self._locked(dashboard_id):
|
|
with belief_scope("GitService.get_diff"):
|
|
repo = self.get_repo(dashboard_id)
|
|
diff_args = []
|
|
if staged:
|
|
diff_args.append("--staged")
|
|
if file_path:
|
|
return repo.git.diff(*diff_args, "--", file_path)
|
|
return repo.git.diff(*diff_args)
|
|
# endregion get_diff
|
|
|
|
# region get_commit_history [C:4] [TYPE Function] [SEMANTICS git,history,lock]
|
|
# @PURPOSE: Retrieve commit history for a repository (concurrent-safe).
|
|
# @PARAM limit (int) - Max number of commits to return.
|
|
# @PRE Repository for dashboard_id exists.
|
|
# @POST Returns a list of dictionaries for each commit in history.
|
|
# @RETURN List[dict]
|
|
def get_commit_history(self, dashboard_id: int, limit: int = 50) -> list[dict]:
|
|
with self._locked(dashboard_id):
|
|
with belief_scope("GitService.get_commit_history"):
|
|
repo = self.get_repo(dashboard_id)
|
|
commits = []
|
|
try:
|
|
if not repo.heads and not repo.remotes:
|
|
return []
|
|
for commit in repo.iter_commits(max_count=limit):
|
|
commits.append({
|
|
"hash": commit.hexsha,
|
|
"author": commit.author.name,
|
|
"email": commit.author.email,
|
|
"timestamp": datetime.fromtimestamp(commit.committed_date),
|
|
"message": commit.message.strip(),
|
|
"files_changed": list(commit.stats.files.keys())
|
|
})
|
|
except Exception as e:
|
|
logger.explore(f"Could not retrieve commit history for dashboard {dashboard_id}: {e}", extra={"src": "get_commit_history"})
|
|
return []
|
|
return commits
|
|
# endregion get_commit_history
|
|
# #endregion GitServiceStatusMixin
|
|
# #endregion GitServiceStatusMixin
|