"""Git diff scanning with origin-base resolution and defensive flags.""" from __future__ import annotations import re import subprocess _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") def resolve_default_branch(repo: str) -> str: try: r = subprocess.run( ["git", "-C", repo, "symbolic-ref", "refs/remotes/origin/HEAD"], capture_output=True, text=True, check=False, ) if r.returncode == 0: return r.stdout.strip().rsplit("/", 1)[-1] except FileNotFoundError: pass for candidate in ("main", "master"): r = subprocess.run( ["git", "-C", repo, "rev-parse", f"origin/{candidate}"], capture_output=True, text=True, check=False, ) if r.returncode == 0: return candidate return "main" def resolve_base_ref(repo: str, branch: str) -> str: """Return the diff base for `branch`, fetching origin first.""" try: subprocess.run( ["git", "-C", repo, "fetch", "--quiet", "--no-tags", "origin", branch], capture_output=True, text=True, check=False, timeout=60, ) except (FileNotFoundError, subprocess.TimeoutExpired): pass check = subprocess.run( ["git", "-C", repo, "rev-parse", "--verify", "--quiet", f"origin/{branch}"], capture_output=True, text=True, check=False, ) return f"origin/{branch}" if check.returncode == 0 else branch def _run_git_diff(repo: str, base: str, head: str) -> str: result = subprocess.run( [ "git", "-C", repo, "-c", "diff.noprefix=false", "-c", "color.ui=never", "diff", "--no-ext-diff", "--unified=0", f"{base}...{head}", ], capture_output=True, text=True, check=False, ) if result.returncode != 0: raise RuntimeError(f"git diff failed: {result.stderr.strip()}") return result.stdout def _iter_file_blocks(diff_text: str): current: str | None = None buf: list[str] = [] for line in diff_text.splitlines(): if line.startswith("diff --git "): if current is not None: yield current, "\n".join(buf) current = None buf = [] elif line.startswith("+++ b/"): current = line[len("+++ b/"):] if current is not None: buf.append(line) if current is not None: yield current, "\n".join(buf) def _added_ranges(block: str) -> list[tuple[int, int]]: ranges: list[tuple[int, int]] = [] cur: int | None = None start: int | None = None end: int | None = None for line in block.splitlines(): m = _HUNK_RE.match(line) if m: if start is not None: ranges.append((start, end)) # type: ignore[arg-type] start = end = None cur = int(m.group(1)) continue if cur is None: continue if line.startswith("+") and not line.startswith("+++"): if start is None: start = cur end = cur cur += 1 elif line.startswith("-") and not line.startswith("---"): continue else: if start is not None: ranges.append((start, end)) # type: ignore[arg-type] start = end = None cur += 1 if start is not None: ranges.append((start, end)) # type: ignore[arg-type] return ranges def changed_files_with_ranges( repo: str, base: str, head: str, ) -> list[tuple[str, list[tuple[int, int]]]]: """Return [(path, added_line_ranges), ...] for every changed file.""" out: list[tuple[str, list[tuple[int, int]]]] = [] for path, block in _iter_file_blocks(_run_git_diff(repo, base, head)): ranges = _added_ranges(block) if ranges: out.append((path, ranges)) return out