use std::collections::HashMap;
use std::process::Command;
pub struct CommitMatch {
pub short: String,
pub full: String,
pub date: String,
pub subject: String,
pub files: Vec<FileMatch>,
}
pub struct FileMatch {
pub path: String,
pub lines: Vec<(bool, String)>,
}
pub fn pickaxe(repo: &str, terms: &[String], case_insensitive: bool) -> Vec<CommitMatch> {
let mut order: Vec<String> = Vec::new();
let mut by_hash: HashMap<String, CommitMatch> = HashMap::new();
for term in terms {
let output = match run_log(repo, term, case_insensitive) {
Some(o) => o,
None => continue,
};
for commit in parse_log(&output, term, case_insensitive) {
match by_hash.get_mut(&commit.full) {
Some(existing) => merge(existing, commit),
None => {
order.push(commit.full.clone());
by_hash.insert(commit.full.clone(), commit);
}
}
}
}
order.into_iter().filter_map(|h| by_hash.remove(&h)).collect()
}
pub fn branches_for(repo: &str, hash: &str) -> Vec<String> {
Command::new("git")
.args([
"-C",
repo,
"branch",
"-a",
"--contains",
hash,
"--format=%(refname:short)",
])
.output()
.ok()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
})
.unwrap_or_default()
}
fn run_log(repo: &str, term: &str, case_insensitive: bool) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(["-C", repo, "log", "--all"]);
if case_insensitive {
cmd.arg("-i"); }
cmd.args([
"-S", term, "-F", "--date=short",
"--pretty=format:COMMIT:%h|%H|%ad|||%s",
"-p", ]);
let output = cmd.output().ok()?;
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn parse_log(output: &str, term: &str, case_insensitive: bool) -> Vec<CommitMatch> {
let needle = if case_insensitive {
term.to_lowercase()
} else {
term.to_string()
};
let mut commits: Vec<CommitMatch> = Vec::new();
let mut current: Option<CommitMatch> = None;
for line in output.lines() {
if let Some(rest) = line.strip_prefix("COMMIT:") {
if let Some(c) = current.take() {
commits.push(c);
}
let (meta, subject) = rest.split_once("|||").unwrap_or((rest, ""));
let mut parts = meta.splitn(3, '|');
current = Some(CommitMatch {
short: parts.next().unwrap_or("").to_string(),
full: parts.next().unwrap_or("").to_string(),
date: parts.next().unwrap_or("").to_string(),
subject: subject.to_string(),
files: Vec::new(),
});
} else if let Some(c) = current.as_mut() {
if let Some(path) = parse_diff_header(line) {
c.files.push(FileMatch {
path,
lines: Vec::new(),
});
} else if let Some(file) = c.files.last_mut() {
let is_add = line.starts_with('+') && !line.starts_with("+++");
let is_del = line.starts_with('-') && !line.starts_with("---");
if is_add || is_del {
let hay = if case_insensitive {
line.to_lowercase()
} else {
line.to_string()
};
if hay.contains(&needle) {
file.lines.push((is_add, line.to_string()));
}
}
}
}
}
if let Some(c) = current.take() {
commits.push(c);
}
commits
}
fn parse_diff_header(line: &str) -> Option<String> {
let rest = line.strip_prefix("diff --git ")?;
let idx = rest.rfind(" b/")?;
Some(rest[idx + 3..].to_string())
}
fn merge(into: &mut CommitMatch, from: CommitMatch) {
for file in from.files {
if let Some(existing) = into.files.iter_mut().find(|e| e.path == file.path) {
for line in file.lines {
if !existing.lines.iter().any(|l| l.1 == line.1) {
existing.lines.push(line);
}
}
} else {
into.files.push(file);
}
}
}