fix(git): replace git diff --cached with git status --porcelain in get_status

repo.is_dirty() and repo.index.diff("HEAD") internally call git diff --cached,
which fails with exit 129 (unknown option) in some environments.

Introduce _parse_status_porcelain() using git status --porcelain,
a self-contained command that avoids --cached entirely.

All 25 git route tests continue to pass.
This commit is contained in:
2026-05-08 09:55:10 +03:00
parent 064ffea330
commit 505864438e

View File

@@ -1192,12 +1192,62 @@ class GitService:
raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}")
# [/DEF:pull_changes:Function]
# [DEF:_parse_status_porcelain:Function]
# @COMPLEXITY: 2
# @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.
# @RELATION: CALLED_BY -> [GitService.get_status]
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"):
# Do NOT strip the line — porcelain v1 format "X Y PATH" uses
# leading space (X=' ') to indicate "unmodified in index".
# Stripping would shift column alignment.
if not line:
continue
# Untracked: "?? path"
if line.startswith("??"):
untracked.append(line[2:].strip())
continue
# Ignored: "!! path" (skip)
if line.startswith("!!"):
continue
# Normal entry: "XY path" or "XY orig -> dest" for renames
if len(line) < 3:
continue
x = line[0] # index (staged) status
y = line[1] # work-tree status
rest = line[3:] # strip "XY "
# For renames/copies: "R orig -> dest" — use the destination
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
# [/DEF:_parse_status_porcelain:Function]
# [DEF:get_status:Function]
# @PURPOSE: Get current repository status (dirty files, untracked, etc.)
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a dictionary representing the Git status.
# @RETURN: dict
# @RELATION: CALLS -> [GitService.get_repo]
# @RELATION: CALLS -> [GitService._parse_status_porcelain]
def get_status(self, dashboard_id: int) -> dict:
with belief_scope("GitService.get_status"):
repo = self.get_repo(dashboard_id)
@@ -1237,10 +1287,11 @@ class GitService:
ahead_count = 0
behind_count = 0
is_dirty = repo.is_dirty(untracked_files=True)
untracked_files = repo.untracked_files
modified_files = [item.a_path for item in repo.index.diff(None)]
staged_files = [item.a_path for item in repo.index.diff("HEAD")] if has_commits else []
# Use git status --porcelain to gather file state safely.
# Avoids repo.is_dirty() and repo.index.diff("HEAD") which internally
# call git diff --cached -- a flag unsupported in some Git environments.
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: