use std::collections::HashMap;
use std::process::Command;
use codelore_lib::Options;
use codelore_lib::analyses::entity_ownership::run_entity_ownership;
use codelore_lib::facts::FactsDb;
use codelore_lib::repo::GixRepo;
fn git_as(path: &std::path::Path, date: &str, author: &str, email: &str, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_NAME", author)
.env("GIT_AUTHOR_EMAIL", email)
.env("GIT_COMMITTER_NAME", author)
.env("GIT_COMMITTER_EMAIL", email)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
}
fn build_two_author_rename_repo() -> tempfile::TempDir {
const ALICE: (&str, &str) = ("Alice", "alice@example.com");
const BOB: (&str, &str) = ("Bob", "bob@example.com");
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
git_as(
path,
"2026-01-01T00:00:00Z",
ALICE.0,
ALICE.1,
&["init", "-b", "main", "--quiet"],
);
std::fs::write(path.join("old.rs"), "one\ntwo\nthree\n").unwrap();
git_as(
path,
"2026-01-01T00:00:00Z",
ALICE.0,
ALICE.1,
&["add", "old.rs"],
);
git_as(
path,
"2026-01-01T00:00:00Z",
ALICE.0,
ALICE.1,
&["commit", "-m", "create old.rs", "--quiet"],
);
std::fs::write(path.join("other.rs"), "alpha\nbeta\n").unwrap();
git_as(
path,
"2026-01-02T00:00:00Z",
ALICE.0,
ALICE.1,
&["add", "other.rs"],
);
git_as(
path,
"2026-01-02T00:00:00Z",
ALICE.0,
ALICE.1,
&["commit", "-m", "create other.rs", "--quiet"],
);
std::fs::write(path.join("other.rs"), "alpha\ngamma\n").unwrap();
git_as(
path,
"2026-01-03T00:00:00Z",
BOB.0,
BOB.1,
&["commit", "-am", "edit other.rs", "--quiet"],
);
git_as(
path,
"2026-01-04T00:00:00Z",
ALICE.0,
ALICE.1,
&["mv", "old.rs", "new.rs"],
);
git_as(
path,
"2026-01-04T00:00:00Z",
ALICE.0,
ALICE.1,
&["commit", "-m", "rename old.rs to new.rs", "--quiet"],
);
std::fs::write(path.join("new.rs"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
git_as(
path,
"2026-01-05T00:00:00Z",
BOB.0,
BOB.1,
&["commit", "-am", "edit new.rs", "--quiet"],
);
dir
}
fn rows_by_entity_author(
rows: &[codelore_lib::analyses::entity_ownership::EntityOwnershipRow],
) -> HashMap<(String, String), (u64, u64)> {
let mut map = HashMap::new();
for row in rows {
let prev = map.insert(
(row.entity.clone(), row.author.clone()),
(row.added, row.deleted),
);
assert!(
prev.is_none(),
"duplicate (entity, author) row for ({}, {}); rows: {rows:?}",
row.entity,
row.author
);
}
map
}
#[test]
fn entity_ownership_without_lineage_splits_pre_and_post_rename_rows() {
let fixture = build_two_author_rename_repo();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
use_canonical_lineage: false,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_entity_ownership(&db, &opts).expect("run entity-ownership");
let by_key = rows_by_entity_author(&rows);
assert_eq!(
by_key.len(),
5,
"expected 5 distinct (entity, author) rows without lineage; got {rows:?}"
);
assert_eq!(
by_key.get(&("old.rs".to_string(), "alice@example.com".to_string())),
Some(&(3, 0)),
"old.rs/alice: {rows:?}"
);
assert_eq!(
by_key.get(&("other.rs".to_string(), "alice@example.com".to_string())),
Some(&(2, 0)),
"other.rs/alice: {rows:?}"
);
assert_eq!(
by_key.get(&("other.rs".to_string(), "bob@example.com".to_string())),
Some(&(1, 1)),
"other.rs/bob: {rows:?}"
);
assert_eq!(
by_key.get(&("new.rs".to_string(), "alice@example.com".to_string())),
Some(&(0, 0)),
"new.rs/alice (rename-only commit): {rows:?}"
);
assert_eq!(
by_key.get(&("new.rs".to_string(), "bob@example.com".to_string())),
Some(&(2, 0)),
"new.rs/bob: {rows:?}"
);
}
#[test]
fn entity_ownership_with_lineage_merges_renamed_entity_churn() {
let fixture = build_two_author_rename_repo();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
use_canonical_lineage: true,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_entity_ownership(&db, &opts).expect("run entity-ownership under lineage");
let by_key = rows_by_entity_author(&rows);
assert!(
!rows.iter().any(|r| r.entity == "old.rs"),
"old.rs must not appear under canonical lineage; got {rows:?}"
);
assert_eq!(
by_key.len(),
4,
"expected 4 distinct (entity, author) rows under lineage (old.rs/alice \
merges into new.rs/alice); got {rows:?}"
);
assert_eq!(
by_key.get(&("new.rs".to_string(), "alice@example.com".to_string())),
Some(&(3, 0)),
"new.rs/alice must aggregate the pre-rename old.rs churn: {rows:?}"
);
assert_eq!(
by_key.get(&("new.rs".to_string(), "bob@example.com".to_string())),
Some(&(2, 0)),
"new.rs/bob unaffected by the rename: {rows:?}"
);
assert_eq!(
by_key.get(&("other.rs".to_string(), "alice@example.com".to_string())),
Some(&(2, 0)),
"other.rs/alice unaffected by the rename: {rows:?}"
);
assert_eq!(
by_key.get(&("other.rs".to_string(), "bob@example.com".to_string())),
Some(&(1, 1)),
"other.rs/bob unaffected by the rename: {rows:?}"
);
}