"""Scan git repositories for today's commits.""" import subprocess from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import List, Optional @dataclass class CommitInfo: hash: str message: str author: str timestamp: int files_changed: int insertions: int deletions: int @dataclass class RepoSummary: name: str path: Path commits: List[CommitInfo] total_insertions: int total_deletions: int total_files: int def _run_git(args: List[str], cwd: Path, check: bool = True) -> str: """Run a git command and return stdout.""" result = subprocess.run( ["git"] + args, cwd=cwd, capture_output=True, text=True, check=check, ) if result.returncode != 0 and check: raise subprocess.CalledProcessError( result.returncode, ["git"] + args, output=result.stdout, stderr=result.stderr, ) return result.stdout def _parse_git_date(date_str: str) -> int: """Parse a git date string into Unix timestamp. Handles both ISO ('2026-05-26 14:30:00 +0200') and RFC ('Tue, 26 May 2026 14:30:00 +0200') formats. """ date_str = date_str.strip() # Try ISO format first for fmt in ("%Y-%m-%d %H:%M:%S %z", "%a, %d %b %Y %H:%M:%S %z"): try: dt = datetime.strptime(date_str, fmt) return int(dt.timestamp()) except ValueError: continue return 0 def _get_commits_for_date( repo_path: Path, start_ts: int, end_ts: int ) -> List[CommitInfo]: """Get commits in the given timestamp range.""" # Use --since and --until with ISO format which git understands reliably since = datetime.fromtimestamp(start_ts, tz=timezone.utc).isoformat() until = datetime.fromtimestamp(end_ts, tz=timezone.utc).isoformat() log_format = "%H%x00%s%x00%an%x00%ad%x00" try: output = _run_git( [ "log", f"--since={since}", f"--until={until}", f"--format={log_format}", "--date=iso", "--no-merges", ], cwd=repo_path, check=False, ) except subprocess.CalledProcessError: return [] if not output.strip(): return [] commits = [] entries = output.strip().split("\n") for entry in entries: parts = entry.split("\x00") if len(parts) < 4: continue commit_hash, message, author, date_str = parts[0], parts[1], parts[2], parts[3] ts = _parse_git_date(date_str) # Get stats for this commit try: stat_output = _run_git( ["show", "--stat", "--format=", commit_hash], cwd=repo_path, check=False, ) except subprocess.CalledProcessError: stat_output = "" files_changed, insertions, deletions = 0, 0, 0 for line in stat_output.splitlines(): line = line.strip() if "file changed" in line or "files changed" in line: # Parse line like "3 files changed, 10 insertions(+), 2 deletions(-)" parts_stat = line.split(",") for part in parts_stat: part = part.strip() if "changed" in part: try: files_changed = int(part.split()[0]) except ValueError: pass elif "insertion" in part: try: insertions = int(part.split()[0]) except ValueError: pass elif "deletion" in part: try: deletions = int(part.split()[0]) except ValueError: pass commits.append( CommitInfo( hash=commit_hash[:8], message=message.strip(), author=author.strip(), timestamp=ts, files_changed=files_changed, insertions=insertions, deletions=deletions, ) ) return commits def _is_git_repo(path: Path) -> bool: """Check if a directory is a git repository.""" return (path / ".git").is_dir() def find_git_repos(base_dir: Path, max_depth: int = 3) -> List[Path]: """Find git repositories under base_dir up to max_depth levels.""" repos = [] base_dir = base_dir.expanduser().resolve() if not base_dir.exists(): return repos for depth in range(max_depth + 1): pattern = "/".join(["*"] * depth) if depth > 0 else "." if depth == 0: if _is_git_repo(base_dir): repos.append(base_dir) continue for candidate in base_dir.glob(pattern): if candidate.is_dir() and _is_git_repo(candidate): repos.append(candidate) return repos def fetch_git_activity( start_ts: int, end_ts: int, code_dirs: Optional[List[Path]] = None, max_depth: int = 3, ) -> List[RepoSummary]: """Scan code directories and return git activity summaries.""" if code_dirs is None: code_dirs = [Path.home() / "Code"] all_repos = [] for d in code_dirs: all_repos.extend(find_git_repos(d, max_depth=max_depth)) # Deduplicate seen = set() unique_repos = [] for r in all_repos: resolved = r.resolve() if resolved not in seen: seen.add(resolved) unique_repos.append(resolved) summaries = [] for repo_path in unique_repos: commits = _get_commits_for_date(repo_path, start_ts, end_ts) if not commits: continue total_insertions = sum(c.insertions for c in commits) total_deletions = sum(c.deletions for c in commits) total_files = sum(c.files_changed for c in commits) summaries.append( RepoSummary( name=repo_path.name, path=repo_path, commits=commits, total_insertions=total_insertions, total_deletions=total_deletions, total_files=total_files, ) ) # Sort by number of commits descending summaries.sort(key=lambda r: -len(r.commits)) return summaries