//! 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
}