diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index 7594423d..36fe3d4b 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -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: