#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # /// """Event-sourcing extractor: reconstruct OKF bundle from git + session history. Deterministic extraction: same input → byte-identical events.jsonl. Run: uv run okf_backfill_events.py [--out events.jsonl] [--branch REF ...] [--no-sessions] [--sessions-dir DIR] [--max-text 2000] [--skip-globs GLOB ...] uv run okf_backfill_events.py --show [--only ] [--max-diff-lines 300] [--per-file-lines 120] [--max-line-chars 400] [--skip-globs GLOB ...] """ from __future__ import annotations import argparse import glob as glob_module import fnmatch import json import re import subprocess import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Optional # Skip rule predicates @dataclass class SkipRule: id: str check: callable def matches_globs(paths: list[str], globs: list[str]) -> bool: """True if ALL paths match at least one glob pattern.""" if not paths: return False for path in paths: if not any(fnmatch.fnmatch(path, g) for g in globs): return False return True LOCKFILE_GLOBS = [ "*.lock", "package-lock.json", "pnpm-lock.yaml", "uv.lock", "Cargo.lock", "go.sum", "vendor/**", "node_modules/**", "dist/**", "*.min.*", ] def rule_paths_only_generated(event: dict, skip_globs: list[str]) -> bool: """Commit only touches generated/lockfile paths.""" if event["source"] != "git": return False globs = LOCKFILE_GLOBS + skip_globs paths = [f["path"] for f in event.get("files", [])] return matches_globs(paths, globs) def rule_merge_no_files(event: dict, skip_globs: list[str]) -> bool: """Merge commit with no files touched.""" if event["source"] != "git": return False return len(event.get("files", [])) == 0 def rule_session_command_noise(event: dict, skip_globs: list[str]) -> bool: """Session event that is just a slash command or too short with no outcome.""" if event["source"] != "session": return False user = event.get("user", "").strip() # Bare slash command (no space or just "/" alone) if user == "/" or (user.startswith("/") and " " not in user): return True # Local command output (starts with "<") if user.startswith("<"): return True # Too short after strip (< 20 chars) AND no useful outcome if len(user) < 20 and not event.get("outcome"): return True return False SKIP_RULES = [ SkipRule("paths-only-generated", rule_paths_only_generated), SkipRule("merge-no-files", rule_merge_no_files), SkipRule("session-command-noise", rule_session_command_noise), ] def normalize_ts(ts: str) -> float: """Convert ISO8601 timestamp to UTC epoch float.""" # Handle RFC3339 with or without timezone ts = ts.strip() # If it ends with Z, it's UTC if ts.endswith("Z"): ts = ts[:-1] + "+00:00" # Handle offset timezone offsets like +01:00 or -05:00 try: # Try ISO format parsing if "T" in ts: dt = datetime.fromisoformat(ts) else: # Date only dt = datetime.fromisoformat(ts + "T00:00:00") return dt.timestamp() except Exception: print(f"Warning: could not parse timestamp {ts!r}, using epoch 0", file=sys.stderr) return 0.0 def epoch_to_iso_z(epoch: float) -> str: """Format a UTC epoch as ISO8601 with a Z suffix, second precision.""" return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def git_log(repo_dir: Path, branches: list[str]) -> list[dict]: """Extract events from git log across one or more branches (union, first branch wins on duplicate SHAs).""" branches = _resolve_branches(repo_dir, branches) if not branches: return [] events = [] seen_shas = set() for branch in branches: for event in _git_log_one_branch(repo_dir, branch): if event["sha"] in seen_shas: continue seen_shas.add(event["sha"]) events.append(event) return events def _resolve_branches(repo_dir: Path, branches: list[str]) -> list[str]: """Resolve the branch list to query: explicit branches, else default branch from origin/HEAD, else current branch.""" if branches: return branches # Try to get default branch from origin/HEAD try: result = subprocess.run( ["git", "-C", str(repo_dir), "symbolic-ref", "refs/remotes/origin/HEAD"], capture_output=True, text=True, check=False ) if result.returncode == 0: # Typically returns "refs/remotes/origin/main" or similar default_branch = result.stdout.strip().split("/")[-1] branches = [default_branch] except Exception: pass if not branches: # Fallback to current branch try: result = subprocess.run( ["git", "-C", str(repo_dir), "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, check=True ) branches = [result.stdout.strip()] except Exception: return [] return branches def _git_log_one_branch(repo_dir: Path, branch: str) -> list[dict]: """Extract events from git log for a single branch (first-parent, reverse, numstat).""" events = [] # Get log info and numstat separately try: # First, get commit info (SHA, timestamp, author, subject, body) log_result = subprocess.run( ["git", "-C", str(repo_dir), "log", "--first-parent", "--reverse", "--format=%H%n%aI%n%an%n%s%n%b%n<<>>", branch], capture_output=True, text=True, check=True ) # Get numstat (SHA and file changes) numstat_result = subprocess.run( ["git", "-C", str(repo_dir), "log", "--first-parent", "--reverse", "--numstat", "--format=%H%n<<>>", branch], capture_output=True, text=True, check=True ) # Parse log output (maintain order) log_blocks = log_result.stdout.split("<<>>") commits_ordered = [] # List to maintain order commits_dict = {} # Dict for lookup during numstat for block in log_blocks: if not block.strip(): continue lines = block.strip().split("\n") if len(lines) < 4: continue sha = lines[0].strip() ts_str = lines[1].strip() author = lines[2].strip() subject = lines[3].strip() body = "\n".join(lines[4:]).strip() if len(lines) > 4 else "" commit_data = { "sha": sha, "ts": normalize_ts(ts_str), "author": author, "subject": subject, "body": body, "files": [], } commits_ordered.append(commit_data) commits_dict[sha] = commit_data # Parse numstat output numstat_blocks = numstat_result.stdout.split("<<>>") current_sha = None for block in numstat_blocks: lines = block.strip().split("\n") for line in lines: line = line.strip() if not line: continue # First line in block (after <<>>) is SHA if line and "\t" not in line: current_sha = line if current_sha not in commits_dict: # Shouldn't happen, but handle gracefully commits_dict[current_sha] = { "sha": current_sha, "ts": 0, "author": "", "subject": "", "body": "", "files": [], } commits_ordered.append(commits_dict[current_sha]) elif current_sha and "\t" in line: # Numstat line: additions\tdeletions\tpath parts = line.split("\t", 2) if len(parts) >= 3: try: add = int(parts[0]) if parts[0] != "-" else 0 dele = int(parts[1]) if parts[1] != "-" else 0 path = parts[2] commits_dict[current_sha]["files"].append({ "path": path, "add": add, "del": dele }) except (ValueError, IndexError): pass # Build events in the order we saw commits for c in commits_ordered: event = { "id": f"git:{c['sha']}", "source": "git", "ts": c["ts"], "sha": c["sha"], "author": c["author"], "subject": c["subject"], "body": c["body"], "files": c["files"], } events.append(event) except Exception: # Git command failed, return empty pass return events def repo_slug(repo_abs: Path) -> str: """Slugify an absolute repo path the way Claude Code names transcript dirs. Portable: also maps Windows separators and drive colons to dashes, so the slug is stable regardless of platform path syntax. """ # Claude Code maps every non-alphanumeric character to a dash # (verified: p-045_ekar_skills -> p-045-ekar-skills), not just separators. return re.sub(r"[^A-Za-z0-9-]", "-", str(repo_abs)) def sessions_from_transcripts(repo_dir: Path, sessions_dir: Optional[Path]) -> list[dict]: """Extract events from Claude session transcripts.""" events = [] if sessions_dir is None: sessions_dir = Path.home() / ".claude" / "projects" if not sessions_dir.exists(): return events # Compute repo slug and canonical path (for the cwd filter) repo_abs = repo_dir.resolve() slug = repo_slug(repo_abs) # Find session files: /*.jsonl and --*/*.jsonl session_globs = [ str(sessions_dir / slug / "*.jsonl"), str(sessions_dir / f"{slug}--*/") + "*.jsonl", ] session_files = [] for pattern in session_globs: session_files.extend(glob_module.glob(pattern)) session_files = sorted(set(session_files)) for session_file in session_files: session_path = Path(session_file) # Read raw lines, keeping the real 1-based line number of each record try: raw_lines = session_path.read_text(encoding="utf-8").splitlines() except Exception: continue records = [] # list of (lineno, dict) for lineno, line in enumerate(raw_lines, start=1): line = line.strip() if not line: continue try: rec = json.loads(line) except Exception: continue if isinstance(rec, dict): records.append((lineno, rec)) # Title: the LAST aiTitle in the file, computed once per file (not # rescanned per turn). title = None for _, rec in records: if rec.get("type") == "ai-title" and rec.get("aiTitle"): title = rec["aiTitle"] n = len(records) i = 0 while i < n: lineno, rec = records[i] # Skip non-user messages, meta, and sidechain records if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"): i += 1 continue content = (rec.get("message") or {}).get("content") if isinstance(content, str): user_text = content elif isinstance(content, list): text_blocks = [ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ] if not text_blocks: # e.g. tool_result-only user record: nothing to extract i += 1 continue user_text = "".join(text_blocks) else: i += 1 continue # cwd filter: only keep records from this repo (or a worktree under it) cwd = rec.get("cwd") or "" cwd_path = Path(cwd) if cwd else None if cwd_path is None or (cwd_path != repo_abs and repo_abs not in cwd_path.parents): i += 1 continue ts_str = rec.get("timestamp", "") branch = rec.get("gitBranch") # Find the outcome: scan forward to the next user record, taking # the LAST non-empty text block across the assistant records # in between (the turn wrap-up). outcome_text = "" j = i + 1 while j < n: _, next_rec = records[j] if next_rec.get("type") == "user": break if next_rec.get("type") == "assistant": acontent = (next_rec.get("message") or {}).get("content") if isinstance(acontent, list): for block in acontent: if isinstance(block, dict) and block.get("type") == "text" and block.get("text"): outcome_text = block["text"] j += 1 event = { "id": f"session:{session_path.name}:{lineno}", "source": "session", "ts": normalize_ts(ts_str), "user": user_text, "outcome": outcome_text if outcome_text else None, "title": title, "branch": branch, } events.append(event) i = j return events def apply_skip_rules(event: dict, skip_globs: list[str]) -> Optional[str]: """Apply skip rules and return rule_id if should be skipped, None otherwise.""" for rule in SKIP_RULES: if rule.check(event, skip_globs): return rule.id return None def merge_and_sort(git_events: list[dict], session_events: list[dict]) -> list[dict]: """Merge and sort events by (ts, source, id).""" all_events = git_events + session_events # Sort by timestamp, then source (git first), then id def sort_key(event): source_order = {"git": 0, "session": 1} return (event["ts"], source_order.get(event["source"], 999), event["id"]) all_events.sort(key=sort_key) return all_events def truncate_text(text: str, max_len: int = 2000) -> str: """Apply head+tail truncation with marker.""" if len(text) <= max_len: return text head_len = max_len * 3 // 4 tail_len = max_len - head_len head = text[:head_len] tail = text[-tail_len:] if tail_len else "" return head + "\n[...]\n" + tail def check_coverage(events_path: Path, bundle_dir: Path) -> list[str]: """Check that all live events (without skip field) are mapped in the bundle. Returns a list of unmapped event ids (empty if all are covered). Live events are found by looking for those without a 'skip' key. Coverage is determined by searching for event ids or their short shas in the bundle's .md files (sources and log). """ if not events_path.exists(): print(f"Error: {events_path} does not exist", file=sys.stderr) sys.exit(1) if not bundle_dir.exists(): print(f"Error: {bundle_dir} does not exist", file=sys.stderr) sys.exit(1) # Collect all live event ids live_ids = [] try: with open(events_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: event = json.loads(line) if "skip" not in event and "id" in event: live_ids.append(event["id"]) except json.JSONDecodeError: continue except Exception as e: print(f"Error reading events: {e}", file=sys.stderr) sys.exit(1) # Collect all text from .md files in bundle bundle_text = "" try: for md_file in bundle_dir.rglob("*.md"): try: bundle_text += md_file.read_text(encoding="utf-8") + "\n" except Exception: continue except Exception as e: print(f"Error reading bundle: {e}", file=sys.stderr) sys.exit(1) # Check coverage unmapped = [] for event_id in live_ids: if event_id.startswith("git:"): # Git events: search for full sha and any >=7-char prefix sha = event_id[4:] # Remove "git:" prefix short_sha = sha[:7] # Check if full sha or short sha appears in bundle if sha not in bundle_text and short_sha not in bundle_text: unmapped.append(event_id) elif event_id.startswith("session:"): # Session events: search for literal id (with colons) if event_id not in bundle_text: unmapped.append(event_id) else: # Unknown format, consider unmapped unmapped.append(event_id) return unmapped def split_patch(patch_text: str) -> list[dict]: """Split -p output into blocks on lines starting with 'diff --git'. Each block contains: - path: str (the b/ path from the diff --git line, or new path for renames) - header: list[str] (lines before first @@ line) - hunks: list[list[str]] (groups starting at each @@ line) """ if not patch_text: return [] lines = patch_text.split("\n") blocks = [] current_block = None for line in lines: if line.startswith("diff --git "): # Start new block: extract path from b/ side # Format: diff --git a/ b/ # ponytail: paths containing " b/" would be incorrectly split; git-quoted paths are out of scope if " b/" in line: path = line.split(" b/", 1)[1] else: path = "" if current_block is not None: blocks.append(current_block) current_block = { "path": path, "header": [line], "hunks": [], } elif current_block is not None: if line.startswith("@@"): # Start new hunk current_block["hunks"].append([line]) elif current_block["hunks"]: # Add to current hunk current_block["hunks"][-1].append(line) else: # Header line (before first hunk) current_block["header"].append(line) if current_block is not None: blocks.append(current_block) return blocks def cap_patch( blocks: list[dict], numstat_lines: list[str], *, per_file_lines: int, max_diff_lines: int, max_line_chars: int, skip_globs: list[str], only: Optional[str] = None, ) -> tuple[list[str], dict]: """Cap patches per-file and globally. Returns (patch_lines, summary_dict) where: - patch_lines: list of lines to emit (markers included, no '# patch' header) - summary_dict: {shown, total, files_shown, files_total, truncated} Capping rules: - Glob-omitted files: emit marker, consume nothing, don't count toward total - Per-file budget: max(per_file_lines, max_diff_lines - shown) under normal mode, min(max_diff_lines, max_diff_lines - shown) under --only - Header exceeds budget: emit header lines that fit, then accounting line with N = unshown header lines + all hunk lines, M = len(hunks) - Hunk doesn't fit: emit it whole if it fits remaining budget; otherwise emit [+N more lines in M more hunks] or [hunk truncated: +N more lines] - Global cap: before starting file, if shown >= max_diff_lines, emit [patches omitted for K more files (+N lines)] and stop - Line-length rule: lines longer than max_line_chars are truncated with marker """ globs = LOCKFILE_GLOBS + skip_globs patch_lines = [] shown = 0 files_shown = 0 truncated = False # Parse numstat for totals and --only validation numstat_by_path = {} for numstat_line in numstat_lines: parts = numstat_line.split("\t", 2) if len(parts) >= 3: add_str, del_str, path = parts[0], parts[1], parts[2] numstat_by_path[path] = (add_str, del_str) files_total = len(numstat_lines) # Total counts every eligible file (not glob-omitted, matching --only), including # the ones the global cap omits: truncated=true must mean shown < total. eligible = [ b for b in blocks if not matches_globs([b["path"]], globs) and (only is None or b["path"] == only) ] total = sum(len(b["header"]) + sum(len(h) for h in b["hunks"]) for b in eligible) # Validate --only path if only is not None: if only not in numstat_by_path: raise KeyError(f"Path not found in commit: {only}") # Process blocks block_idx = 0 for block_idx, block in enumerate(blocks): block_path = block["path"] # Check glob omission if matches_globs([block_path], globs): # Emit marker, consume nothing if block_path in numstat_by_path: add_str, del_str = numstat_by_path[block_path] else: add_str, del_str = "-", "-" patch_lines.append(f"[patch omitted: {block_path} (generated), {add_str} {del_str}]") continue # Check --only filter if only is not None and block_path != only: continue # Global cap check if shown >= max_diff_lines: # Count remaining non-glob blocks and their lines remaining_files = 0 remaining_lines = 0 for j in range(block_idx, len(blocks)): remaining_block = blocks[j] if matches_globs([remaining_block["path"]], globs): continue if only is not None and remaining_block["path"] != only: continue remaining_files += 1 for hunk in remaining_block["hunks"]: remaining_lines += len(hunk) remaining_lines += len(remaining_block["header"]) if remaining_files > 0: patch_lines.append(f"[patches omitted for {remaining_files} more files (+{remaining_lines} lines)]") truncated = True break # Calculate file budget if only is not None: file_budget = min(max_diff_lines, max_diff_lines - shown) else: file_budget = min(per_file_lines, max_diff_lines - shown) # Emit header lines file_lines_emitted = 0 header_lines_to_emit = [] for hline in block["header"]: if len(hline) > max_line_chars: shortened = hline[:max_line_chars] + f" [line truncated: {len(hline)} chars]" header_lines_to_emit.append(shortened) else: header_lines_to_emit.append(hline) file_lines_emitted += 1 if file_lines_emitted > file_budget: # Header alone exceeds budget header_lines_to_emit = header_lines_to_emit[: file_budget] break patch_lines.extend(header_lines_to_emit) shown += len(header_lines_to_emit) # Process hunks if file_lines_emitted <= file_budget and len(block["header"]) > 0: # Some header was emitted and didn't exceed budget remaining_budget = file_budget - file_lines_emitted else: # Either no header or header exactly filled budget remaining_budget = file_budget - len(header_lines_to_emit) hunks_emitted = 0 for hunk_idx, hunk in enumerate(block["hunks"]): hunk_size = len(hunk) if hunk_size <= remaining_budget: # Hunk fits for hline in hunk: if len(hline) > max_line_chars: shortened = hline[:max_line_chars] + f" [line truncated: {len(hline)} chars]" patch_lines.append(shortened) else: patch_lines.append(hline) shown += 1 remaining_budget -= hunk_size hunks_emitted += 1 else: # Hunk doesn't fit truncated = True if hunks_emitted == 0: # No hunk emitted yet: either emit partial hunk or full accounting if remaining_budget > 0: # Emit first remaining_budget lines of this hunk for hline in hunk[:remaining_budget]: if len(hline) > max_line_chars: shortened = hline[:max_line_chars] + f" [line truncated: {len(hline)} chars]" patch_lines.append(shortened) else: patch_lines.append(hline) shown += 1 # Emit truncation marker remaining_hunk_lines = hunk_size - remaining_budget patch_lines.append(f"[hunk truncated: +{remaining_hunk_lines} more lines]") else: # Budget is 0 or negative, emit full accounting remaining_hunk_lines = hunk_size + sum(len(h) for h in block["hunks"][hunk_idx + 1:]) remaining_hunks = len(block["hunks"]) - hunk_idx patch_lines.append(f"[+{remaining_hunk_lines} more lines in {remaining_hunks} more hunks of {block_path}]") else: # Some hunks emitted, more remain remaining_hunk_lines = hunk_size + sum(len(h) for h in block["hunks"][hunk_idx + 1:]) remaining_hunks = len(block["hunks"]) - hunk_idx patch_lines.append(f"[+{remaining_hunk_lines} more lines in {remaining_hunks} more hunks of {block_path}]") break if hunks_emitted > 0 or len(header_lines_to_emit) > 0: files_shown += 1 summary = { "shown": shown, "total": total, "files_shown": files_shown, "files_total": files_total, "truncated": truncated, } return patch_lines, summary def format_summary(summary: dict) -> str: """Format the fixed-form summary line.""" return ( f"[diff: shown={summary['shown']} total={summary['total']} " f"files_shown={summary['files_shown']} files_total={summary['files_total']} " f"truncated={'true' if summary['truncated'] else 'false'}]" ) def show_commit( repo_dir: Path, sha: str, *, only: Optional[str] = None, max_diff_lines: int = 300, per_file_lines: int = 120, max_line_chars: int = 400, skip_globs: list[str] = (), ) -> str: """Run git, emit the full capped diff text. Raises ValueError for a bad sha (exit 1) or KeyError for --only path not found (exit 2). """ # Resolve the sha first, separately from the parent check resolve_result = subprocess.run( ["git", "-C", str(repo_dir), "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"], capture_output=True, text=True, errors="replace", check=False ) if resolve_result.returncode != 0: raise ValueError(f"Bad commit: {sha}") full_sha = resolve_result.stdout.strip() # Check if this is a root commit or has a parent parent_result = subprocess.run( ["git", "-C", str(repo_dir), "rev-parse", "--verify", "--quiet", f"{full_sha}^1"], capture_output=True, text=True, errors="replace", check=False ) if parent_result.returncode == 0: # Has a parent: diff against first parent base = parent_result.stdout.strip() stat_result = subprocess.run( ["git", "-C", str(repo_dir), "diff", "--no-color", "-M", "--numstat", base, full_sha], capture_output=True, text=True, errors="replace", check=True ) patch_result = subprocess.run( ["git", "-C", str(repo_dir), "diff", "--no-color", "-M", "-p", base, full_sha], capture_output=True, text=True, errors="replace", check=True ) else: # Root commit: use git show stat_result = subprocess.run( ["git", "-C", str(repo_dir), "show", "--no-color", "-M", "--format=", "--numstat", full_sha], capture_output=True, text=True, errors="replace", check=True ) patch_result = subprocess.run( ["git", "-C", str(repo_dir), "show", "--no-color", "-M", "--format=", "-p", full_sha], capture_output=True, text=True, errors="replace", check=True ) # Parse stat numstat_lines = [ line.strip() for line in stat_result.stdout.split("\n") if line.strip() ] # Parse patch blocks = split_patch(patch_result.stdout) # Cap the patch patch_lines, summary = cap_patch( blocks, numstat_lines, per_file_lines=per_file_lines, max_diff_lines=max_diff_lines, max_line_chars=max_line_chars, skip_globs=list(skip_globs), only=only, ) # Build output lines = [] lines.append(f"# commit {full_sha}") lines.append("# stat (complete)") lines.extend(numstat_lines) lines.append("# patch") lines.extend(patch_lines) lines.append(format_summary(summary)) return "\n".join(lines) + "\n" def main(): parser = argparse.ArgumentParser( description="Event-sourcing extractor for OKF bundle reconstruction" ) # Check if this is a coverage check (--check-coverage mode) if "--check-coverage" in sys.argv: parser.add_argument("--check-coverage", nargs=2, metavar=("EVENTS_JSONL", "BUNDLE_DIR"), help="Check coverage of events in bundle") args = parser.parse_args() if args.check_coverage: events_path = Path(args.check_coverage[0]) bundle_dir = Path(args.check_coverage[1]) unmapped = check_coverage(events_path, bundle_dir) if unmapped: print(f"Unmapped events (exit 1):") for event_id in unmapped: print(f" {event_id}") sys.exit(1) else: print("All events mapped (exit 0)") sys.exit(0) # Normal extraction mode / show mode parser.add_argument("repo_dir", nargs="?", help="Repository directory") parser.add_argument("--out", default="events.jsonl", help="Output file") parser.add_argument( "--branch", action="append", default=[], help="Git branch(es) to extract" ) parser.add_argument( "--no-sessions", action="store_true", help="Skip session extraction" ) parser.add_argument( "--sessions-dir", type=Path, help="Override sessions directory" ) parser.add_argument( "--max-text", type=int, default=2000, help="Max text length per field" ) parser.add_argument( "--skip-globs", action="append", default=[], help="Additional skip globs" ) parser.add_argument( "--show", help="Show capped diff for a commit (exit 0 on success, 1 on bad sha)" ) parser.add_argument( "--only", help="Restrict patch to a single path (with --show)" ) parser.add_argument( "--max-diff-lines", type=int, default=300, help="Max diff lines total" ) parser.add_argument( "--per-file-lines", type=int, default=120, help="Max diff lines per file" ) parser.add_argument( "--max-line-chars", type=int, default=400, help="Max characters per line" ) args = parser.parse_args() if not args.repo_dir: print("Error: repo_dir is required", file=sys.stderr) sys.exit(1) repo_dir = Path(args.repo_dir).resolve() if not repo_dir.exists(): print(f"Error: {repo_dir} does not exist", file=sys.stderr) sys.exit(1) # Handle --show mode if args.show: try: output = show_commit( repo_dir, args.show, only=args.only, max_diff_lines=args.max_diff_lines, per_file_lines=args.per_file_lines, max_line_chars=args.max_line_chars, skip_globs=args.skip_globs, ) sys.stdout.write(output) sys.exit(0) except ValueError as e: # Bad sha print(str(e), file=sys.stderr) sys.exit(1) except KeyError as e: # --only path not found print(e.args[0], file=sys.stderr) sys.exit(2) # Normal extraction mode # Extract events git_events = git_log(repo_dir, args.branch) session_events = [] if args.no_sessions else sessions_from_transcripts( repo_dir, args.sessions_dir ) # Merge and sort events = merge_and_sort(git_events, session_events) # Apply skip rules and truncate skip_globs = args.skip_globs skip_counts = {} for event in events: skip_id = apply_skip_rules(event, skip_globs) if skip_id: event["skip"] = skip_id skip_counts[skip_id] = skip_counts.get(skip_id, 0) + 1 # Truncate text fields for key in ["subject", "body", "outcome", "user"]: if key in event and isinstance(event[key], str): event[key] = truncate_text(event[key], args.max_text) # Emit ts as an ISO8601 UTC string; the epoch float was only needed # for sorting. event["ts"] = epoch_to_iso_z(event["ts"]) # Write JSONL (deterministic order) out_path = Path(args.out) out_path.parent.mkdir(parents=True, exist_ok=True) with open(out_path, "w", encoding="utf-8") as f: for event in events: # Sort keys for determinism sorted_event = {k: event[k] for k in sorted(event.keys())} f.write(json.dumps(sorted_event, separators=(',', ':'), sort_keys=True) + "\n") # Report print(f"Extracted {len(events)} events") print(f" Git: {len(git_events)}") print(f" Sessions: {len(session_events)}") if skip_counts: print("Skipped:") for rule_id, count in sorted(skip_counts.items()): print(f" {rule_id}: {count}") if __name__ == "__main__": main()