Files
obsidian_utils/dailySummary/utils/git_tracker.py
T
fegger 4b7ddcb1c1 Add daily coding summary tool for Obsidian
Integrates with Zed editor history and Git commits to track daily
coding activity. Writes idempotent `## Coding Work` sections into
Obsidian daily notes with project breakdowns and commit stats.
2026-05-26 14:52:09 +02:00

226 lines
6.4 KiB
Python

"""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