use std::path::Path;
use crate::redundancy::Fingerprint;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryReason {
Introduced,
RenamedFrom,
HistoryExhausted,
MaxCommitsReached,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChangeEvent {
pub commit: String,
pub short_sha: String,
pub author: String,
pub email: String,
pub date: String, pub summary: String,
pub file_at_commit: String,
}
#[derive(Debug, Clone)]
pub struct BlameOptions {
pub max_commits: usize,
pub similarity_threshold: f64,
pub max_blob_bytes: u64,
}
impl Default for BlameOptions {
fn default() -> Self {
Self {
max_commits: 500,
similarity_threshold: 0.1,
max_blob_bytes: 2 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BlameResult {
pub events: Vec<ChangeEvent>,
pub boundary_reason: BoundaryReason,
pub commits_walked: usize,
pub parse_failures: Vec<ParseFailure>,
pub skipped_large: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ParseFailure {
pub commit: String,
pub error: String,
}
pub fn log(
project_root: &Path,
file: &str,
_start_line: u32,
_end_line: u32,
language_key: &str,
target_fp: &Fingerprint,
opts: &BlameOptions,
) -> Result<BlameResult, String> {
use crate::extraction::ts_provider;
use crate::redundancy::parse_file;
if !lang_key_is_known(language_key) {
return Err(format!("unknown language key '{language_key}'"));
}
let lang = ts_provider::language(language_key);
let walk_result = walk_file_history(project_root, file, opts.max_commits)?;
let commits_walked = walk_result.total_visited;
let visits = walk_result.visits;
let hit_max = walk_result.hit_max;
let mut events: Vec<ChangeEvent> = Vec::new();
let mut parse_failures: Vec<ParseFailure> = Vec::new();
let mut skipped_large: Vec<String> = Vec::new();
let mut pending: Option<(ChangeEvent, String)> = None;
let mut found_introduction = false;
let repo = gix::open(project_root).map_err(|e| format!("gix open: {e}"))?;
for visit in &visits {
if visit.blob_size > opts.max_blob_bytes {
skipped_large.push(visit.short_sha.clone());
continue;
}
let blob = repo
.find_object(visit.blob_id)
.map_err(|e| format!("cannot read blob {}: {e}", visit.short_sha))?;
let source = if let Ok(s) = std::str::from_utf8(&blob.data) {
s.to_string()
} else {
parse_failures.push(ParseFailure {
commit: visit.short_sha.clone(),
error: "blob is not valid UTF-8".to_string(),
});
continue;
};
let Some(tree) = parse_file(&source, &lang) else {
parse_failures.push(ParseFailure {
commit: visit.short_sha.clone(),
error: "tree-sitter parse failed".to_string(),
});
continue;
};
let Some(matched) = best_match_in_tree(&source, &tree, target_fp, opts.similarity_threshold)
else {
if let Some((ev, _)) = pending.take() {
events.push(ev);
}
if let Some(renamed) = probe_rename_at_boundary(
&repo,
project_root,
visit,
target_fp,
opts,
)? {
let prior = log_from_commit(
project_root,
&renamed.prior_file,
visit.commit_id,
language_key,
target_fp,
opts,
)?;
let mut combined = prior.events;
events.reverse();
combined.extend(events);
return Ok(BlameResult {
events: combined,
boundary_reason: BoundaryReason::RenamedFrom,
commits_walked: commits_walked + prior.commits_walked,
parse_failures: {
let mut p = prior.parse_failures;
p.extend(parse_failures);
p
},
skipped_large: {
let mut s = prior.skipped_large;
s.extend(skipped_large);
s
},
});
}
found_introduction = true;
break;
};
let ev = ChangeEvent {
commit: visit.commit_id.to_string(),
short_sha: visit.short_sha.clone(),
author: visit.author.clone(),
email: visit.email.clone(),
date: visit.date_rfc3339.clone(),
summary: visit.summary.clone(),
file_at_commit: file.to_string(),
};
match pending.take() {
None => {
pending = Some((ev, matched.ast_hash));
}
Some((prev_ev, prev_hash)) => {
if prev_hash == matched.ast_hash {
pending = Some((ev, matched.ast_hash));
} else {
events.push(prev_ev);
pending = Some((ev, matched.ast_hash));
}
}
}
}
if let Some((ev, _)) = pending.take() {
events.push(ev);
}
if !found_introduction && !hit_max {
if let Some(oldest_visit) = visits.last() {
if let Some(renamed) = probe_rename_at_boundary(
&repo,
project_root,
oldest_visit,
target_fp,
opts,
)? {
let prior = log_from_commit(
project_root,
&renamed.prior_file,
oldest_visit.commit_id,
language_key,
target_fp,
opts,
)?;
let mut combined = prior.events;
events.reverse();
combined.extend(events);
return Ok(BlameResult {
events: combined,
boundary_reason: BoundaryReason::RenamedFrom,
commits_walked: commits_walked + prior.commits_walked,
parse_failures: {
let mut p = prior.parse_failures;
p.extend(parse_failures);
p
},
skipped_large: {
let mut s = prior.skipped_large;
s.extend(skipped_large);
s
},
});
}
}
}
events.reverse();
let boundary_reason = if found_introduction {
BoundaryReason::Introduced
} else if hit_max {
BoundaryReason::MaxCommitsReached
} else if !events.is_empty() {
BoundaryReason::Introduced
} else {
BoundaryReason::HistoryExhausted
};
Ok(BlameResult {
events,
boundary_reason,
commits_walked,
parse_failures,
skipped_large,
})
}
fn best_match_in_tree(
source: &str,
tree: &tree_sitter::Tree,
target: &Fingerprint,
threshold: f64,
) -> Option<Fingerprint> {
use crate::redundancy::composite_similarity;
let mut best: Option<(f64, Fingerprint)> = None;
let mut stack = vec![tree.root_node()];
while let Some(n) = stack.pop() {
if is_entity_node(n.kind()) {
let fp = crate::redundancy::compute_fingerprint(source, n);
let score = composite_similarity(target, &fp);
if score >= threshold {
let better = best.as_ref().is_none_or(|(s, _)| score > *s);
if better {
best = Some((score, fp));
}
}
}
let mut cursor = n.walk();
if cursor.goto_first_child() {
loop {
stack.push(cursor.node());
if !cursor.goto_next_sibling() {
break;
}
}
}
}
best.map(|(_, fp)| fp)
}
struct RenameMatch {
prior_file: String,
}
fn probe_rename_at_boundary(
repo: &gix::Repository,
project_root: &std::path::Path,
boundary_visit: &CommitVisit,
target_fp: &Fingerprint,
opts: &BlameOptions,
) -> Result<Option<RenameMatch>, String> {
use crate::extraction::ts_provider;
use crate::redundancy::parse_file;
let commit = repo
.find_object(boundary_visit.commit_id)
.map_err(|e| format!("cannot find boundary commit: {e}"))?
.try_into_commit()
.map_err(|e| format!("not a commit: {e}"))?;
let Some(parent_id) = commit.parent_ids().next() else {
return Ok(None);
};
let parent = repo
.find_object(parent_id.detach())
.map_err(|e| format!("cannot find parent: {e}"))?
.try_into_commit()
.map_err(|e| format!("not a parent commit: {e}"))?;
let parent_tree = parent.tree().map_err(|e| format!("parent tree: {e}"))?;
let boundary_tree = commit.tree().map_err(|e| format!("boundary tree: {e}"))?;
let mut candidates: Vec<String> = Vec::new();
parent_tree
.changes()
.map_err(|e| format!("diff init: {e}"))?
.for_each_to_obtain_tree(&boundary_tree, |change| {
use gix::object::tree::diff::Change;
match &change {
Change::Deletion { location, entry_mode, .. }
| Change::Modification { location, entry_mode, .. }
| Change::Addition { location, entry_mode, .. } => {
if !entry_mode.is_tree() {
candidates.push(location.to_string());
}
}
Change::Rewrite {
source_location,
location,
source_entry_mode,
entry_mode,
..
} => {
if !source_entry_mode.is_tree() {
candidates.push(source_location.to_string());
}
if !entry_mode.is_tree() {
candidates.push(location.to_string());
}
}
}
Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Continue(()))
})
.map_err(|e| format!("tree diff: {e}"))?;
let mut best: Option<(f64, String)> = None;
for cand in candidates {
let Some(lang_key) = ts_lang_key_from_path(&cand) else {
continue;
};
let Some(entry) = parent_tree
.lookup_entry_by_path(std::path::Path::new(&cand))
.map_err(|e| format!("lookup_entry_by_path: {e}"))?
else {
continue;
};
let blob = repo
.find_object(entry.object_id())
.map_err(|e| format!("blob lookup: {e}"))?;
if blob.data.len() as u64 > opts.max_blob_bytes {
continue;
}
let Ok(source) = std::str::from_utf8(&blob.data) else {
continue;
};
let lang = ts_provider::language(lang_key);
let Some(tree) = parse_file(source, &lang) else {
continue;
};
if let Some(fp) =
best_match_in_tree(source, &tree, target_fp, opts.similarity_threshold)
{
let score = crate::redundancy::composite_similarity(target_fp, &fp);
let better = best.as_ref().is_none_or(|(s, _)| score > *s);
if better {
best = Some((score, cand));
}
}
}
let _ = project_root;
Ok(best.map(|(_, prior_file)| RenameMatch { prior_file }))
}
fn log_from_commit(
project_root: &std::path::Path,
file: &str,
boundary_commit: gix::ObjectId,
language_key: &str,
target_fp: &Fingerprint,
opts: &BlameOptions,
) -> Result<BlameResult, String> {
let _ = boundary_commit;
log(project_root, file, 0, 0, language_key, target_fp, opts)
}
fn is_entity_node(kind: &str) -> bool {
matches!(
kind,
"function_item"
| "function_declaration"
| "function_definition"
| "method_declaration"
| "method_definition"
| "function"
| "method"
| "impl_item"
| "struct_item"
| "enum_item"
| "trait_item"
| "class_declaration"
| "class_definition"
| "interface_declaration"
| "type_alias_declaration"
)
}
#[derive(Debug, Clone)]
pub(crate) struct CommitVisit {
pub commit_id: gix::ObjectId,
pub short_sha: String,
pub author: String,
pub email: String,
pub date_rfc3339: String,
pub summary: String,
pub blob_id: gix::ObjectId,
pub blob_size: u64,
}
pub(crate) struct WalkResult {
pub visits: Vec<CommitVisit>,
pub total_visited: usize,
pub hit_max: bool,
}
pub(crate) fn walk_file_history(
project_root: &std::path::Path,
file_path: &str,
max_commits: usize,
) -> Result<WalkResult, String> {
let repo = gix::open(project_root).map_err(|e| format!("failed to open git repo: {e}"))?;
let head = repo
.head()
.map_err(|e| format!("cannot read HEAD: {e}"))?
.into_peeled_id()
.map_err(|e| format!("cannot peel HEAD: {e}"))?;
let mut visits = Vec::new();
let mut last_blob_id: Option<gix::ObjectId> = None;
let mut visited = 0_usize;
let mut current_id = head.detach();
let mut exhausted = false;
while visited < max_commits {
let commit = repo
.find_object(current_id)
.map_err(|e| format!("cannot find commit object: {e}"))?
.try_into_commit()
.map_err(|e| format!("not a commit: {e}"))?;
let tree = commit
.tree()
.map_err(|e| format!("cannot read tree for commit {current_id}: {e}"))?;
let entry = tree
.lookup_entry_by_path(std::path::Path::new(file_path))
.map_err(|e| format!("lookup_entry_by_path failed: {e}"))?;
if let Some(entry) = entry {
let blob_id = entry.object_id();
let differs = last_blob_id != Some(blob_id);
if differs {
let blob = repo
.find_object(blob_id)
.map_err(|e| format!("cannot find blob: {e}"))?;
let blob_size = blob.data.len() as u64;
let (author, email, date, summary) = commit_metadata(&commit)?;
visits.push(CommitVisit {
commit_id: current_id,
short_sha: format!("{current_id:.7}"),
author,
email,
date_rfc3339: date,
summary,
blob_id,
blob_size,
});
last_blob_id = Some(blob_id);
}
}
visited += 1;
let parent_id = commit.parent_ids().next().map(gix::Id::detach);
if let Some(pid) = parent_id {
current_id = pid;
} else {
exhausted = true;
break;
}
}
let hit_max = !exhausted && visited == max_commits;
Ok(WalkResult { visits, total_visited: visited, hit_max })
}
fn commit_metadata(
commit: &gix::Commit<'_>,
) -> Result<(String, String, String, String), String> {
let author_sig = commit
.author()
.map_err(|e| format!("cannot decode author: {e}"))?;
let author = author_sig.name.to_string();
let email = author_sig.email.to_string();
let secs = author_sig.seconds();
let date = format_rfc3339(secs);
let message = commit
.message_raw()
.map_err(|e| format!("cannot read commit message: {e}"))?;
let summary = std::str::from_utf8(message.as_ref())
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string();
Ok((author, email, date, summary))
}
fn format_rfc3339(unix_secs: i64) -> String {
let (yr, mo, dy, hr, mn, sc) = ymd_hms_from_unix(unix_secs);
format!("{yr:04}-{mo:02}-{dy:02}T{hr:02}:{mn:02}:{sc:02}Z")
}
#[allow(clippy::many_single_char_names)] fn ymd_hms_from_unix(mut ts: i64) -> (i32, u32, u32, u32, u32, u32) {
let day_sec = ts.rem_euclid(86_400);
ts = ts.div_euclid(86_400);
let hour = (day_sec / 3600) as u32;
let min = ((day_sec % 3600) / 60) as u32;
let sec = (day_sec % 60) as u32;
let z = ts + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = (y + i64::from(month <= 2)) as i32;
(year, month, day, hour, min, sec)
}
pub fn ts_lang_key_from_path(path: &str) -> Option<&'static str> {
let ext = path.rsplit('.').next().unwrap_or("");
Some(match ext {
"rs" => "rust",
"go" => "go",
"py" | "pyi" => "python",
"js" | "jsx" | "mjs" | "cjs" => "javascript",
"ts" | "mts" | "cts" => "typescript",
"tsx" => "tsx",
"java" => "java",
"kt" | "kts" => "kotlin",
"swift" => "swift",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => "cpp",
"cs" => "c_sharp",
"rb" => "ruby",
"php" => "php",
"scala" | "sc" => "scala",
"dart" => "dart",
"lua" => "lua",
"pl" | "pm" => "perl",
"sh" | "bash" => "bash",
"nix" => "nix",
"zig" => "zig",
"proto" => "protobuf",
"wgsl" => "wgsl",
"hlsl" | "fx" => "hlsl",
"metal" => "cpp",
_ => return None,
})
}
pub fn compute_target_fingerprint(
source: &str,
language_key: &str,
start_line: u32,
end_line: u32,
) -> Option<Fingerprint> {
use crate::extraction::ts_provider;
use crate::redundancy::{compute_fingerprint, find_node_at_lines, parse_file};
if !lang_key_is_known(language_key) {
return None;
}
let lang = ts_provider::language(language_key);
let tree = parse_file(source, &lang)?;
let node = find_node_at_lines(&tree, start_line, end_line)?;
Some(compute_fingerprint(source, node))
}
fn lang_key_is_known(key: &str) -> bool {
matches!(
key,
"bash" | "batch" | "c" | "c_sharp" | "clojure" | "cobol" | "cpp" | "dart"
| "dockerfile" | "elixir" | "erlang" | "fortran" | "fsharp" | "glsl"
| "go" | "gwbasic" | "haskell" | "java" | "javascript" | "julia"
| "kotlin" | "lean" | "lua" | "msbasic2" | "nix" | "objc" | "ocaml"
| "pascal" | "perl" | "php" | "powershell" | "protobuf" | "python"
| "qbasic" | "quint" | "r" | "ruby" | "rust" | "scala" | "sql"
| "swift" | "toml" | "tsx" | "typescript" | "vbnet" | "zig"
) || (cfg!(feature = "lang-wgsl") && key == "wgsl")
|| (cfg!(feature = "lang-hlsl") && key == "hlsl")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn lang_key_recognises_common_extensions() {
assert_eq!(ts_lang_key_from_path("src/foo.rs"), Some("rust"));
assert_eq!(ts_lang_key_from_path("a.tsx"), Some("tsx"));
assert_eq!(ts_lang_key_from_path("a.ts"), Some("typescript"));
assert_eq!(ts_lang_key_from_path("a.proto"), Some("protobuf"));
assert_eq!(ts_lang_key_from_path("a.cs"), Some("c_sharp"));
assert_eq!(ts_lang_key_from_path("README.md"), None);
}
#[test]
fn default_options_match_spec() {
let opts = BlameOptions::default();
assert_eq!(opts.max_commits, 500);
assert!((opts.similarity_threshold - 0.1).abs() < f64::EPSILON);
assert_eq!(opts.max_blob_bytes, 2 * 1024 * 1024);
}
#[test]
fn compute_target_fingerprint_from_rust_source() {
let source = "pub fn add(a: i32, b: i32) -> i32 { a + b }\n";
let fp = compute_target_fingerprint(source, "rust", 0, 0)
.expect("rust language must be available");
assert!(fp.body_tokens > 0);
assert!(!fp.ast_hash.is_empty());
}
#[test]
fn compute_target_fingerprint_returns_none_for_unknown_lang() {
let source = "anything";
assert!(compute_target_fingerprint(source, "no_such_lang_key", 0, 0).is_none());
}
#[test]
fn log_returns_events_when_function_body_changes() {
use std::process::Command;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let git = |args: &[&str]| {
let st = Command::new("git").current_dir(root).args(args).status().unwrap();
assert!(st.success());
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "T"]);
git(&["config", "commit.gpgsign", "false"]);
std::fs::write(root.join("foo.rs"), "pub fn add(a: i32, b: i32) -> i32 { a + b }\n").unwrap();
git(&["add", "foo.rs"]);
git(&["commit", "-q", "-m", "c1: initial"]);
std::fs::write(
root.join("foo.rs"),
"// trivial helper\npub fn add(a: i32, b: i32) -> i32 { a + b }\n",
)
.unwrap();
git(&["commit", "-q", "-am", "c2: comment"]);
std::fs::write(
root.join("foo.rs"),
"// trivial helper\npub fn add(a: i32, b: i32) -> i32 { let s = a + b; s }\n",
)
.unwrap();
git(&["commit", "-q", "-am", "c3: rebody"]);
let source = std::fs::read_to_string(root.join("foo.rs")).unwrap();
let fp = compute_target_fingerprint(&source, "rust", 1, 1).expect("fp");
let result = log(root, "foo.rs", 1, 1, "rust", &fp, &BlameOptions::default()).expect("log");
assert_eq!(result.events.len(), 2, "got: {:#?}", result.events);
assert!(result.events[0].summary.contains("c1"));
assert!(result.events[1].summary.contains("c3"));
assert_eq!(result.boundary_reason, BoundaryReason::Introduced);
}
#[test]
fn log_follows_function_renamed_across_files() {
use std::process::Command;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let git = |args: &[&str]| {
let st = Command::new("git").current_dir(root).args(args).status().unwrap();
assert!(st.success());
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "T"]);
git(&["config", "commit.gpgsign", "false"]);
std::fs::write(root.join("a.rs"), "pub fn helper() -> i32 { 42 }\n").unwrap();
git(&["add", "a.rs"]);
git(&["commit", "-q", "-m", "c1: born in a.rs"]);
std::fs::remove_file(root.join("a.rs")).unwrap();
std::fs::write(root.join("b.rs"), "pub fn helper() -> i32 { 42 }\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "c2: moved to b.rs"]);
std::fs::write(root.join("b.rs"), "pub fn helper() -> i32 { 43 }\n").unwrap();
git(&["commit", "-q", "-am", "c3: edit in b.rs"]);
let source = std::fs::read_to_string(root.join("b.rs")).unwrap();
let fp = compute_target_fingerprint(&source, "rust", 0, 0).expect("fp");
let result = log(root, "b.rs", 0, 0, "rust", &fp, &BlameOptions::default()).expect("log");
assert!(result.events.iter().any(|e| e.file_at_commit == "a.rs"),
"expected an event from a.rs in {:#?}", result.events);
assert_eq!(result.boundary_reason, BoundaryReason::RenamedFrom);
}
#[test]
fn max_commits_boundary_classifies_correctly() {
use std::process::Command;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let git = |args: &[&str]| {
Command::new("git").current_dir(root).args(args).status().unwrap();
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "T"]);
git(&["config", "commit.gpgsign", "false"]);
for i in 0..6 {
std::fs::write(
root.join("foo.rs"),
format!("pub fn add() -> i32 {{ {i} }}\n"),
)
.unwrap();
if i == 0 {
git(&["add", "foo.rs"]);
}
git(&["commit", "-q", "-am", &format!("c{i}")]);
}
let opts = BlameOptions {
max_commits: 3,
..BlameOptions::default()
};
let source = std::fs::read_to_string(root.join("foo.rs")).unwrap();
let fp = compute_target_fingerprint(&source, "rust", 0, 0).expect("fp");
let result = log(root, "foo.rs", 0, 0, "rust", &fp, &opts).expect("log");
assert_eq!(result.commits_walked, 3);
assert_eq!(result.boundary_reason, BoundaryReason::MaxCommitsReached);
}
#[test]
fn blob_size_cap_records_skipped() {
use std::process::Command;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let git = |args: &[&str]| {
Command::new("git").current_dir(root).args(args).status().unwrap();
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "T"]);
git(&["config", "commit.gpgsign", "false"]);
let huge = format!(
"// {}\npub fn tiny() -> i32 {{ 1 }}\n",
"x".repeat(3 * 1024 * 1024)
);
std::fs::write(root.join("foo.rs"), &huge).unwrap();
git(&["add", "foo.rs"]);
git(&["commit", "-q", "-m", "huge"]);
let opts = BlameOptions {
max_blob_bytes: 1024, ..BlameOptions::default()
};
let small_src = "pub fn tiny() -> i32 { 1 }\n";
let fp = compute_target_fingerprint(small_src, "rust", 0, 0).expect("fp");
let result = log(root, "foo.rs", 0, 0, "rust", &fp, &opts).expect("log");
assert_eq!(result.skipped_large.len(), 1);
}
#[test]
fn walk_history_yields_commits_in_reverse_chrono_order() {
use std::process::Command;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let run = |args: &[&str]| {
let st = Command::new("git").current_dir(root).args(args).status().unwrap();
assert!(st.success(), "git {args:?} failed");
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "T"]);
run(&["config", "commit.gpgsign", "false"]);
std::fs::write(root.join("foo.rs"), "fn a() {}\n").unwrap();
run(&["add", "foo.rs"]);
run(&["commit", "-q", "-m", "c1"]);
std::fs::write(root.join("foo.rs"), "fn a() { let _ = 1; }\n").unwrap();
run(&["commit", "-q", "-am", "c2"]);
std::fs::write(root.join("foo.rs"), "fn a() { let _ = 2; }\n").unwrap();
run(&["commit", "-q", "-am", "c3"]);
let walk = walk_file_history(root, "foo.rs", 10).expect("walk");
let commits = &walk.visits;
assert_eq!(commits.len(), 3, "expected 3 commits, got {commits:?}");
assert!(commits[0].summary.contains("c3"));
assert!(commits[2].summary.contains("c1"));
}
}