import argparse import difflib import fnmatch import io import subprocess from pathlib import Path from typing import Iterable, List, Optional, Tuple def run_git(repo: Path, args: List[str]) -> Tuple[int, str, str]: if not (repo / ".git").exists(): return 1, "", f"Not a git repo: {repo}" try: proc = subprocess.run( ["git", "-C", str(repo), *args], check=False, text=True, capture_output=True, ) return proc.returncode, proc.stdout, proc.stderr except FileNotFoundError: return 127, "", "git not found on PATH" def detect_branch(repo: Path) -> str: rc, out, err = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]) return out.strip() if rc == 0 and out.strip() else "main" def safe_pull(repo: Path, branch: Optional[str] = None, remote: Optional[str] = None) -> bool: # noqa: E501 rc, out, err = run_git(repo, ["remote"]) if rc != 0: print(f"[git remote] {repo}: {err.strip() or out.strip()}") return False remotes = {r.strip() for r in out.splitlines()} chosen_remote = remote or ("upstream" if "upstream" in remotes else "origin") # noqa: E501 if branch is None: rc, out, err = run_git(repo, ["remote", "show", chosen_remote]) if rc == 0: head = next((ln.split(":")[1].strip() for ln in out.splitlines() if "HEAD branch:" in ln), None) # noqa: E501 target_branch = head or "main" else: target_branch = "main" else: target_branch = branch rc, out, err = run_git(repo, ["fetch", chosen_remote, target_branch]) if rc != 0: print(f"[git fetch] {repo}: {err.strip() or out.strip()}") return False rc, out, err = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]) current = out.strip() if rc == 0 else "" if current != target_branch: rc, out, err = run_git(repo, ["checkout", target_branch]) if rc != 0: print(f"[git checkout] {repo}: {err.strip() or out.strip()}") return False rc, out, err = run_git(repo, ["merge", "--ff-only", f"{chosen_remote}/{target_branch}"]) # noqa: E501 if rc != 0: print(f"[git merge --ff-only] {repo}: {err.strip() or out.strip()}") return False return True def read_lines(p: Path, normalize_newlines: bool = True) -> Optional[List[str]]: # noqa: E501 try: with open(p, "rb") as f: data = f.read() text = data.decode("utf-8", errors="replace") if normalize_newlines: text = text.replace("\r\n", "\n").replace("\r", "\n") if text and not text.endswith("\n"): text += "\n" return text.splitlines(keepends=True) except FileNotFoundError: return None except OSError: return None def filter_imports(lines: List[str]) -> List[str]: out = [] for line in lines: s = line.lstrip() if s.startswith("import ") or s.startswith("from "): continue out.append(line) return out def should_filter_imports(path: Path) -> bool: return path.suffix == ".py" def match_dir(root: Path, pattern: str) -> List[Path]: candidates = [] if any(ch in pattern for ch in "*?[]"): for p in root.rglob("*"): if p.is_dir() and fnmatch.fnmatch(p.as_posix(), f"*/{pattern}".lstrip("/")): # noqa: E501 candidates.append(p) else: p = root / pattern if p.is_dir(): candidates.append(p) else: name = Path(pattern).name for d in root.rglob(name): if d.is_dir(): candidates.append(d) return [p for p in candidates if "__pycache__" not in p.parts] def iter_files(root: Path, glob: Optional[str]) -> Iterable[Path]: if glob: yield from (p for p in root.rglob(glob) if p.is_file()) else: yield from (p for p in root.rglob("*") if p.is_file()) def comparable_relpaths(a_root: Path, b_root: Path, glob: Optional[str]) -> Tuple[set, set]: # noqa: E501 a = {p.relative_to(a_root).as_posix() for p in iter_files(a_root, glob)} b = {p.relative_to(b_root).as_posix() for p in iter_files(b_root, glob)} return a, b def unified_diff_report(a_path: Path, b_path: Path, rel: str, repo1: str, repo2: str, # noqa: E501 ignore_imports: bool, out: io.TextIOBase) -> bool: a = read_lines(a_path) or [] b = read_lines(b_path) or [] if ignore_imports and should_filter_imports(a_path) and should_filter_imports(b_path): # noqa: E501 a = filter_imports(a) b = filter_imports(b) diff = list(difflib.unified_diff( a, b, fromfile=f"a/{rel} ({repo1})", tofile=f"b/{rel} ({repo2})", n=3, lineterm="" )) if diff: print(f"Differences found in: {rel}", file=out) for line in diff: print(line, file=out) print("", file=out) return True return False def main(): ap = argparse.ArgumentParser(description="Compare directories across two git repos.") # noqa: E501 ap.add_argument("repo1") ap.add_argument("repo2") ap.add_argument("directory_pattern", help="Exact path, name, or glob (e.g. 'src' or '*/pkg').") # noqa: E501 ap.add_argument("mode", choices=["diff", "missing", "identical"]) ap.add_argument("--branch1", help="Branch to pull in repo1 (default: current).") # noqa: E501 ap.add_argument("--branch2", help="Branch to pull in repo2 (default: current).") # noqa: E501 ap.add_argument("--glob", help="Limit to files matching this glob (e.g. '**/*.py').") # noqa: E501 ap.add_argument("--output", default="diff_report.txt") args = ap.parse_args() repo1 = Path(args.repo1).resolve() repo2 = Path(args.repo2).resolve() if not (repo1.is_dir() and repo2.is_dir()): raise SystemExit("Both repo paths must exist and be directories.") b1 = args.branch1 or detect_branch(repo1) b2 = args.branch2 or detect_branch(repo2) print(f"Updating repos: {repo1.name}@{b1}, {repo2.name}@{b2}") # if not (safe_pull(repo1, b1) and safe_pull(repo2, b2)): # raise SystemExit("Failed to update one or both repos.") dirs1 = match_dir(repo1, args.directory_pattern) dirs2 = match_dir(repo2, args.directory_pattern) if not dirs1 or not dirs2: raise SystemExit(f"Pattern '{args.directory_pattern}' not found in one or both repos.") # noqa: E501 dir1, dir2 = dirs1[0], dirs2[0] print(f"Comparing: {dir1} ⇄ {dir2}") rels1, rels2 = comparable_relpaths(dir1, dir2, '**/*.py') with open(args.output, "w", encoding="utf-8") as out: if args.mode == "missing": only1 = sorted(rels1 - rels2) only2 = sorted(rels2 - rels1) if not only1 and not only2: print("No missing files detected (both directions).", file=out) else: if only1: for r in only1: print(f"[MISSING] only in {repo1.name}: {r}", file=out) print(f"\n{len(only1)} file(s) present only in {repo1.name}.", file=out) # noqa: E501 if only2: for r in only2: print(f"[MISSING] only in {repo2.name}: {r}", file=out) print(f"\n{len(only2)} file(s) present only in {repo2.name}.", file=out) # noqa: E501 elif args.mode == "identical": commons = sorted(rels1 & rels2) diffs = 0 for rel in commons: a = dir1 / rel b = dir2 / rel la = read_lines(a) or [] lb = read_lines(b) or [] if should_filter_imports(a) and should_filter_imports(b): la = filter_imports(la) lb = filter_imports(lb) if la != lb: diffs += 1 print(f"--- NOT IDENTICAL (ignoring imports): {rel}", file=out) # noqa: E501 for line in difflib.unified_diff( la, lb, fromfile=f"a/{rel} ({repo1.name})", tofile=f"b/{rel} ({repo2.name})", n=3, lineterm="" ): print(line, file=out) print("", file=out) if diffs == 0: print("All common files are identical (Python imports ignored).", file=out) # noqa: E501 else: all_rels = sorted(rels1 | rels2) for rel in all_rels: a = dir1 / rel b = dir2 / rel if not a.exists() and b.exists(): lines = read_lines(b) or [] print(f"--- File from {repo1.name} (not found)", file=out) print(f"+++ File from {repo2.name}: {b}", file=out) for line in lines: print(f"+ {line.rstrip()}", file=out) print("", file=out) continue if a.exists() and not b.exists(): lines = read_lines(a) or [] print(f"--- File from {repo1.name}: {a}", file=out) print(f"+++ File from {repo2.name} (not found)", file=out) for line in lines: print(f"- {line.rstrip()}", file=out) print("", file=out) continue unified_diff_report( a, b, rel, repo1.name, repo2.name, ignore_imports=False, out=out ) print(f"Comparison complete. Results written to '{args.output}'") if __name__ == "__main__": main()