use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
const SEP: char = '\x1f';
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolCommit {
pub hash: String,
pub short: String,
pub date: String,
pub committed_at: i64,
pub author: String,
pub subject: String,
pub in_head: bool,
pub branches: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pr: Option<PrRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrRef {
pub number: u64,
pub url: String,
pub title: String,
pub state: String,
}
#[derive(Debug, Clone, Copy)]
pub struct HistoryOpts {
pub prs: bool,
pub limit: usize,
}
impl Default for HistoryOpts {
fn default() -> Self {
Self {
prs: false,
limit: 50,
}
}
}
pub fn symbol_history(
root: &Path,
file: &str,
span: Option<(usize, usize)>,
name: &str,
opts: &HistoryOpts,
) -> Vec<SymbolCommit> {
let mut raw = Vec::new();
if let Some((start, end)) = span {
raw.extend(run_log(root, &span_args(start, end, file), opts.limit));
}
raw.extend(run_log(root, &pickaxe_args(name, file), opts.limit));
raw.sort_by_key(|c| std::cmp::Reverse(c.committed_at));
raw.dedup_by(|a, b| a.hash == b.hash);
raw.into_iter()
.map(|r| {
let in_head = is_ancestor(root, &r.hash);
let branches = if in_head {
Vec::new()
} else {
branches_containing(root, &r.hash)
};
let pr = (opts.prs && !in_head)
.then(|| branches.iter().find_map(|b| open_pr_for_branch(root, b)))
.flatten();
SymbolCommit {
hash: r.hash,
short: r.short,
date: r.date,
committed_at: r.committed_at,
author: r.author,
subject: r.subject,
in_head,
branches,
pr,
}
})
.collect()
}
struct RawCommit {
hash: String,
short: String,
committed_at: i64,
date: String,
author: String,
subject: String,
}
fn pretty() -> String {
format!("--pretty=format:%H{SEP}%h{SEP}%ct{SEP}%ad{SEP}%an{SEP}%s")
}
fn span_args(start: usize, end: usize, file: &str) -> Vec<String> {
vec![
"log".into(),
"-s".into(),
"--date=short".into(),
pretty(),
format!("-L{start},{end}:{file}"),
]
}
fn pickaxe_args(name: &str, file: &str) -> Vec<String> {
vec![
"log".into(),
"--all".into(),
"-s".into(),
"--date=short".into(),
pretty(),
format!("-S{name}"),
"--".into(),
file.into(),
]
}
fn run_log(root: &Path, args: &[String], limit: usize) -> Vec<RawCommit> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.arg("log")
.arg(format!("-n{limit}"))
.args(&args[1..])
.output();
let Ok(out) = out else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
parse_log(&String::from_utf8_lossy(&out.stdout))
}
fn parse_log(text: &str) -> Vec<RawCommit> {
text.lines()
.filter_map(|line| {
let mut f = line.split(SEP);
Some(RawCommit {
hash: f.next()?.trim().to_string(),
short: f.next()?.to_string(),
committed_at: f.next()?.trim().parse().ok()?,
date: f.next()?.to_string(),
author: f.next()?.to_string(),
subject: f.next().unwrap_or("").to_string(),
})
})
.filter(|c| !c.hash.is_empty())
.collect()
}
fn is_ancestor(root: &Path, commit: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(root)
.args(["merge-base", "--is-ancestor", commit, "HEAD"])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn branches_containing(root: &Path, commit: &str) -> Vec<String> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args([
"branch",
"--all",
"--contains",
commit,
"--format=%(refname)",
])
.output();
let Ok(out) = out else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let mut seen = std::collections::HashSet::new();
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter_map(branch_from_refname)
.filter(|b| !b.ends_with("HEAD")) .filter(|b| seen.insert(b.clone())) .collect()
}
fn branch_from_refname(refname: &str) -> Option<String> {
if let Some(local) = refname.strip_prefix("refs/heads/") {
return Some(local.to_string());
}
let rest = refname.strip_prefix("refs/remotes/")?;
rest.split_once('/').map(|(_, b)| b.to_string())
}
fn open_pr_for_branch(root: &Path, branch: &str) -> Option<PrRef> {
let out = Command::new("gh")
.current_dir(root)
.args([
"pr",
"list",
"--head",
branch,
"--state",
"open",
"--json",
"number,url,title,state",
"--limit",
"1",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let arr: Vec<PrRef> = serde_json::from_slice(&out.stdout).ok()?;
arr.into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
use assert2::check;
#[test]
fn parse_log_reads_unit_separated_rows() {
let text = format!(
"abc123{SEP}abc{SEP}1700000000{SEP}2023-11-14{SEP}Ada{SEP}fix items_segments\n\
def456{SEP}def{SEP}1699999999{SEP}2023-11-13{SEP}Bob{SEP}unrelated"
);
let got = parse_log(&text);
check!(got.len() == 2);
check!(got[0].hash == "abc123");
check!(got[0].committed_at == 1_700_000_000);
check!(got[0].subject == "fix items_segments");
check!(got[1].author == "Bob");
}
#[test]
fn branch_from_refname_keeps_slashes() {
check!(branch_from_refname("refs/heads/fix/use-square") == Some("fix/use-square".into()));
check!(
branch_from_refname("refs/remotes/origin/fix/use-square")
== Some("fix/use-square".into())
);
check!(branch_from_refname("refs/heads/main") == Some("main".into()));
check!(branch_from_refname("refs/tags/v1") == None);
}
#[test]
fn span_and_pickaxe_args_shape() {
check!(span_args(10, 20, "src/a.rs").last().unwrap() == "-L10,20:src/a.rs");
let pk = pickaxe_args("square", "src/a.rs");
check!(pk.contains(&"--all".to_string()));
check!(pk.contains(&"-Ssquare".to_string()));
check!(pk.last().unwrap() == "src/a.rs");
}
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "user.name=test", "-c", "user.email=test@example.com"])
.args(args)
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "git {args:?} failed");
}
#[test]
fn symbol_history_flags_off_head_branch_commit() {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("gt-history-{nanos}"));
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-b", "main", "-q"]);
std::fs::write(dir.join("app.py"), "def square(x):\n return x * x\n").unwrap();
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "add square"]);
git(&dir, &["checkout", "-q", "-b", "fix/use-square"]);
std::fs::write(
dir.join("app.py"),
"def square(x):\n return x * x\n\nprint(square(3))\n",
)
.unwrap();
git(&dir, &["commit", "-q", "-am", "call square"]);
git(&dir, &["checkout", "-q", "main"]);
let commits = symbol_history(
&dir,
"app.py",
Some((1, 2)),
"square",
&HistoryOpts::default(),
);
let fix = commits.iter().find(|c| c.subject == "call square");
check!(fix.is_some(), "the off-branch fix commit should be found");
let fix = fix.unwrap();
check!(fix.in_head == false);
check!(fix.branches.iter().any(|b| b.contains("fix/use-square")));
let base = commits.iter().find(|c| c.subject == "add square");
check!(base.is_some());
check!(base.unwrap().in_head == true);
std::fs::remove_dir_all(&dir).ok();
}
}