jacquardSnapshot

← snapshot

12897 bytes
//! Importing a local git repository, and guessing at its provenance.
//!
//! Git records an author, a committer, and a message. None of those is
//! provenance in jacquard's sense — they say who *ran the command*, not whose
//! hands made the work. But some of it is real evidence, and the difference
//! between evidence and assumption is exactly what this module refuses to
//! blur:
//!
//! - A `Co-Authored-By:` trailer naming a model is **evidence** of mixed hands. Somebody
//!   wrote that line down on purpose.
//! - An author or committer that git itself marks as a bot is **evidence** of agent work.
//! - Everything else is a person **by assumption**, and says so.
//!
//! One trap worth naming: `GitHub <noreply@github.com>` as *committer* is what
//! a squash-merge through the web UI looks like. It is merge plumbing, not a
//! machine that wrote code, and reading it as agent provenance would mislabel
//! most of an ordinary repository. Only the author field, and explicit
//! trailers, are treated as evidence.
//!
//! What is imported is a bounded slice: the most recent commits, carrying only
//! the files those commits touched. A milestone-1 snapshot holds its entire
//! tree, so mirroring real history would mean holding every version of every
//! file in memory at once. The manifest states the bounds rather than
//! implying completeness.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;

use jac_core::Provenance;

/// Files larger than this are not read in review, and would dominate the
/// store if imported.
const MAX_BLOB: usize = 256 * 1024;

/// Paths that must never be imported even when git tracks them. A repository
/// that committed a credential by mistake should not have it copied into a
/// second place by this tool.
const SECRET_HINTS: [&str; 10] = [
    ".env",
    ".pem",
    ".p12",
    ".pfx",
    "id_rsa",
    "id_ed25519",
    ".keystore",
    "credentials",
    "secrets.y",
    ".netrc",
];

/// How one commit's provenance was decided.
#[derive(Debug, Clone)]
pub struct Inferred {
    /// The label this commit was given.
    pub provenance: Provenance,
    /// The rule that fired, in words a reader can argue with.
    pub because: String,
    /// True when the label is a working assumption rather than evidence.
    pub assumed: bool,
}

/// One commit, as git described it.
#[derive(Debug, Clone)]
pub struct GitCommit {
    /// The git object name, kept so an import can be traced back.
    pub sha: String,
    /// Who git says wrote it.
    pub author_name: String,
    /// Their email, which is where bot markers usually live.
    pub author_email: String,
    /// Who git says committed it — often merge plumbing.
    pub committer_name: String,
    /// The committer's email.
    pub committer_email: String,
    /// Epoch milliseconds.
    pub at: i64,
    /// First line of the message.
    pub subject: String,
    /// The rest, where co-author trailers live.
    pub body: String,
}

/// A file at one commit.
#[derive(Debug, Clone)]
pub struct GitFile {
    /// Repo-relative path.
    pub path: String,
    /// The bytes at this commit.
    pub content: Vec<u8>,
}

/// Everything one imported commit contributes.
#[derive(Debug, Clone)]
pub struct ImportedCommit {
    /// What git said.
    pub commit: GitCommit,
    /// What we concluded from it, and why.
    pub inferred: Inferred,
    /// The window's files at this commit.
    pub files: Vec<GitFile>,
}

/// Why an import could not proceed.
#[derive(Debug)]
pub struct ImportError(
    /// Why the import could not proceed, in the reader's terms.
    pub String,
);

impl core::fmt::Display for ImportError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl std::error::Error for ImportError {}

type Result<T> = core::result::Result<T, ImportError>;

fn git(repo: &Path, args: &[&str]) -> Result<String> {
    let out = Command::new("git")
        .arg("-C")
        .arg(repo)
        .args(args)
        .output()
        .map_err(|e| ImportError(format!("could not run git: {e}")))?;
    if !out.status.success() {
        return Err(ImportError(format!(
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

fn git_bytes(repo: &Path, args: &[&str]) -> Result<Vec<u8>> {
    let out = Command::new("git")
        .arg("-C")
        .arg(repo)
        .args(args)
        .output()
        .map_err(|e| ImportError(format!("could not run git: {e}")))?;
    if !out.status.success() {
        return Err(ImportError("git could not read that object".to_owned()));
    }
    Ok(out.stdout)
}

/// True when this looks like a credential regardless of what git thinks.
fn looks_secret(path: &str) -> bool {
    let lower = path.to_lowercase();
    SECRET_HINTS.iter().any(|hint| lower.contains(hint))
}

/// Text only: a snapshot full of binaries is a snapshot nobody can review.
fn is_text(bytes: &[u8]) -> bool {
    !bytes.contains(&0)
}

/// The inference rules, in one place so they can be read and disputed.
#[must_use]
pub fn infer(commit: &GitCommit) -> Inferred {
    let whole = format!("{} {}", commit.subject, commit.body).to_lowercase();

    // Evidence: somebody wrote down that a machine had a hand in this.
    if whole.contains("co-authored-by:") || whole.contains("co-Authored-By:") {
        for marker in [
            "noreply@anthropic.com",
            "claude",
            "copilot",
            "chatgpt",
            "gpt-",
            "[bot]",
            "aider",
            "cursor",
            "devin",
        ] {
            if whole.contains(marker) {
                return Inferred {
                    provenance: Provenance::Mixed,
                    because: format!("the commit message co-authors `{marker}`"),
                    assumed: false,
                };
            }
        }
    }

    // Evidence: git names the *author* as a bot. The committer is deliberately
    // not consulted — `GitHub <noreply@github.com>` there is a squash-merge,
    // not a machine that wrote anything.
    let author = format!("{} {}", commit.author_name, commit.author_email).to_lowercase();
    if author.contains("[bot]") || author.contains("bot@") || author.ends_with("-bot") {
        return Inferred {
            provenance: Provenance::Agent,
            because: format!("git names the author `{}` as a bot", commit.author_name),
            assumed: false,
        };
    }

    Inferred {
        provenance: Provenance::Human,
        because: "assumed: git records who ran the command, not whose hands made the work"
            .to_owned(),
        assumed: true,
    }
}

/// Reads the most recent `limit` commits, newest first.
///
/// # Errors
/// When `git log` cannot run or the path is not a repository.
pub fn read_commits(repo: &Path, limit: usize) -> Result<Vec<GitCommit>> {
    // Unit separator between fields, record separator between commits: commit
    // bodies contain newlines, so line-splitting would corrupt them.
    let format = "--format=%H%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%at%x1f%s%x1f%b%x1e";
    let raw = git(repo, &["log", &format!("-{limit}"), format, "HEAD"])?;
    let mut out = Vec::new();
    for record in raw.split('\u{1e}') {
        let record = record.trim_start_matches('\n');
        if record.trim().is_empty() {
            continue;
        }
        let f: Vec<&str> = record.split('\u{1f}').collect();
        if f.len() < 8 {
            continue;
        }
        out.push(GitCommit {
            sha: f[0].to_owned(),
            author_name: f[1].to_owned(),
            author_email: f[2].to_owned(),
            committer_name: f[3].to_owned(),
            committer_email: f[4].to_owned(),
            at: f[5].parse::<i64>().unwrap_or(0) * 1000,
            subject: f[6].to_owned(),
            body: f[7].to_owned(),
        });
    }
    Ok(out)
}

/// Paths touched anywhere in the window, so every snapshot spans the same set
/// and the diffs between them are real.
fn window_paths(repo: &Path, commits: &[GitCommit], cap: usize) -> Vec<String> {
    let mut seen = BTreeSet::new();
    for commit in commits {
        let raw = git(
            repo,
            &[
                "diff-tree",
                "--no-commit-id",
                "--name-only",
                "-r",
                // Without `--root` a root commit diffs against nothing and
                // reports no files, so a repository with a single initial
                // commit imported as empty.
                "--root",
                "--diff-filter=d",
                &commit.sha,
            ],
        )
        .unwrap_or_default();
        for line in raw.lines() {
            let path = line.trim();
            if path.is_empty() || looks_secret(path) {
                continue;
            }
            seen.insert(path.to_owned());
            if seen.len() >= cap {
                return seen.into_iter().collect();
            }
        }
    }
    seen.into_iter().collect()
}

/// The bounds an import ran under, so a reader knows what is missing.
#[derive(Debug, Clone, Copy)]
pub struct Bounds {
    /// How many commits back from HEAD to read.
    pub commits: usize,
    /// Ceiling on distinct paths carried across the window.
    pub paths: usize,
}

/// Imports a bounded slice of a repository, oldest commit first.
///
/// # Errors
/// When the repository has no commits, nothing in the window is importable,
/// or every file in it is binary or oversized.
pub fn import(repo: &Path, bounds: &Bounds) -> Result<Vec<ImportedCommit>> {
    let mut commits = read_commits(repo, bounds.commits)?;
    if commits.is_empty() {
        return Err(ImportError("that repository has no commits".to_owned()));
    }
    // Oldest first, so the snapshots build a history rather than unwind one.
    commits.reverse();

    let paths = window_paths(repo, &commits, bounds.paths);
    if paths.is_empty() {
        return Err(ImportError(
            "nothing importable changed in that window".to_owned(),
        ));
    }

    // Most files do not change between neighbouring commits, so the same
    // object appears again and again across the window. Fetching each one once
    // turns thousands of `cat-file` calls into a few hundred.
    let mut cache: BTreeMap<String, Option<Vec<u8>>> = BTreeMap::new();

    let mut out = Vec::new();
    for commit in commits {
        // One `ls-tree` per commit tells us which of the window's paths exist
        // there and at what blob, without a process per file.
        let listing = git(
            repo,
            &[
                "ls-tree",
                "-r",
                "-z",
                "--format=%(objectname) %(path)",
                &commit.sha,
            ],
        )
        .unwrap_or_default();

        let wanted: BTreeSet<&str> = paths.iter().map(String::as_str).collect();
        let mut blobs: BTreeMap<String, String> = BTreeMap::new();
        for entry in listing.split('\0') {
            let entry = entry.trim();
            let Some((oid, path)) = entry.split_once(' ') else {
                continue;
            };
            if wanted.contains(path) {
                blobs.insert(path.to_owned(), oid.to_owned());
            }
        }

        let mut files = Vec::new();
        for (path, oid) in blobs {
            let entry = cache.entry(oid.clone()).or_insert_with(|| {
                let bytes = git_bytes(repo, &["cat-file", "blob", &oid]).ok()?;
                // Unreadable, binary, or oversized is cached as a miss so the
                // same object is not attempted once per commit.
                (bytes.len() <= MAX_BLOB && is_text(&bytes)).then_some(bytes)
            });
            if let Some(bytes) = entry {
                files.push(GitFile {
                    path,
                    content: bytes.clone(),
                });
            }
        }
        if files.is_empty() {
            continue;
        }

        let inferred = infer(&commit);
        out.push(ImportedCommit {
            commit,
            inferred,
            files,
        });
    }

    if out.is_empty() {
        return Err(ImportError(
            "every commit in that window was binary or oversized".to_owned(),
        ));
    }
    Ok(out)
}

/// Every directory under `root` that is a git repository.
#[must_use]
pub fn discover(root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let Ok(entries) = std::fs::read_dir(root) else {
        return out;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.join(".git").exists() {
            out.push(path);
        }
    }
    out.sort();
    out
}