#! /usr/bin/env python3 import subprocess import sqlite3 import argparse import sys import logging from pathlib import Path from typing import Dict logger = logging.getLogger(__name__) FIELDS = { "ct": "timestamp", "aN": "author", "aE": "email", "cN": "committer", "cE": "committer_email", "s": "subject", "b": "body", "N": "notes", } def read_properties() -> Dict[str, Dict[str, str]]: output = {} for field in FIELDS: # Construct the log request format_str = f"%H %{field}%x00" # Get the results repo_results = subprocess.run( ["git", "log", f"--format=format:{format_str}"], capture_output=True, text=True, ).stdout submodule_results = subprocess.run( [ "git", "submodule", "foreach", "git", "log", f"--format=format:{format_str}", ], capture_output=True, text=True, ).stdout # Parse the results all_results = repo_results + submodule_results all_results = all_results.split("\x00") for result in all_results: if " " not in result or result == "": continue commit_hash, value = result.split(" ", 1) if commit_hash.startswith("Entering"): continue if commit_hash.startswith("\n"): commit_hash = commit_hash[1:] if commit_hash not in output: output[commit_hash] = {} output[commit_hash][field] = value return output def create_table(cursor: sqlite3.Cursor) -> None: sql = "CREATE TABLE IF NOT EXISTS commits (hash TEXT PRIMARY KEY, " for field in FIELDS.values(): ty = "TEXT" if field == "timestamp": ty = "INTEGER" if field == "hash": ty = "TEXT PRIMARY KEY" sql += f"{field} {ty}, " sql = sql[:-2] + ")" logger.debug(f"Creating table with SQL: {sql}") cursor.execute(sql) def main() -> int: # Handle program arguments ap = argparse.ArgumentParser( prog="git-log-sqlite", description="Interact with the git log using SQL" ) ap.add_argument( "--dump", help="Path to a sqlite3 database file to dump contents to. DELETES EXISTING FILE", type=Path, ) ap.add_argument( "--interactive", "-i", help="Start an interactive SQL session", action="store_true", ) ap.add_argument("--query", "-q", help="Run a query and print the results") ap.add_argument("--no-header", help="Do not print the header", action="store_true") ap.add_argument("--mode", help="Set the mode for the sqlite3 command", default="table") ap.add_argument( "-v", "--verbose", help="Enable verbose logging", action="store_true" ) args = ap.parse_args() # Configure logging logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(levelname)s: %(message)s", ) # Interactive mode and query mode are mutually exclusive if args.interactive and args.query: logger.error("Interactive mode and query mode are mutually exclusive") return 1 # If the user didn't specify anything, print the help message if not (args.interactive or args.query): ap.print_help() return 1 # Read the properties commits = read_properties() logger.debug(f"Read {len(commits)} commits") # Open a connection to the database if args.dump: args.dump.parent.mkdir(parents=True, exist_ok=True) args.dump.unlink(missing_ok=True) conn = sqlite3.connect(args.dump if args.dump else ":memory:") cursor = conn.cursor() # Create a table to store the data create_table(cursor) # Insert the data into the table rows = list(commits.items()) rows.sort(key=lambda x: x[1]["ct"]) for commit_hash, data in rows: sql = "INSERT INTO commits VALUES (" + ",".join(["?"] * (len(FIELDS) + 1)) + ")" values = [commit_hash] + [data.get(field, None) for field in FIELDS.keys()] cursor.execute(sql, values) # Commit the changes conn.commit() # If just dumping, we are done if args.dump: conn.close() return 0 # Dump to a temp file import tempfile temp_file = Path(tempfile.mkstemp()[1]) temp_conn = sqlite3.connect(temp_file) temp_conn.executescript("\n".join(conn.iterdump())) temp_conn.commit() conn.close() # Build the base sqlite command sqlite_cmd = ["sqlite3", "--cmd", f".mode {args.mode}"] if not args.no_header: sqlite_cmd.append("--cmd") sqlite_cmd.append(".headers on") # If running a query, do so if args.query: subprocess.run(sqlite_cmd + [temp_file, args.query]) # If running interactively, do so if args.interactive: subprocess.run(sqlite_cmd + ["--interactive", temp_file]) # Delete the temp file temp_file.unlink() return 0 if __name__ == "__main__": sys.exit(main())