"""Git diff scanning: changed files, dirs, and per-file added-line ranges.""" from __future__ import annotations import re import subprocess from pathlib import PurePosixPath _HCL_SUFFIXES = (".tf", ".tf.json", ".hcl") _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") 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): """Yield (path, block_text) for each file section in a unified diff.""" current_path: str | None = None buf: list[str] = [] for line in diff_text.splitlines(): if line.startswith("diff --git "): if current_path is not None: yield current_path, "\n".join(buf) current_path = None buf = [] elif line.startswith("+++ b/"): current_path = line[len("+++ b/"):] if current_path is not None: buf.append(line) if current_path is not None: yield current_path, "\n".join(buf) def changed_files(repo: str, base: str, head: str) -> list[str]: out: list[str] = [] for path, _ in _iter_file_blocks(_run_git_diff(repo, base, head)): if path.endswith(_HCL_SUFFIXES): out.append(path) return out def is_terragrunt_file(path: str) -> bool: return PurePosixPath(path).name == "terragrunt.hcl" def changed_dirs(repo: str, base: str, head: str) -> set[str]: return {str(PurePosixPath(p).parent) for p in changed_files(repo, base, head)} def changed_line_ranges( repo: str, base: str, head: str, path: str, ) -> list[tuple[int, int]]: """Return inclusive (start, end) line ranges of *added* lines in `path`.""" ranges: list[tuple[int, int]] = [] for fpath, block in _iter_file_blocks(_run_git_diff(repo, base, head)): if fpath != path: continue cur: int | None = None run_start: int | None = None run_end: int | None = None for line in block.splitlines(): m = _HUNK_RE.match(line) if m: if run_start is not None: ranges.append((run_start, run_end)) # type: ignore[arg-type] run_start = run_end = None cur = int(m.group(1)) continue if cur is None: continue if line.startswith("+") and not line.startswith("+++"): if run_start is None: run_start = cur run_end = cur cur += 1 elif line.startswith("-") and not line.startswith("---"): continue else: if run_start is not None: ranges.append((run_start, run_end)) # type: ignore[arg-type] run_start = run_end = None cur += 1 if run_start is not None: ranges.append((run_start, run_end)) # type: ignore[arg-type] return ranges