use crate::error::{Result, ToriiError};
use git2::{Commit, Oid, Reference, Repository, Signature, Sort, Time};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub name: String,
pub email: String,
}
impl Identity {
pub fn parse_full(s: &str) -> Result<Self> {
let s = s.trim();
let open = s.rfind('<').ok_or_else(|| {
ToriiError::InvalidConfig(format!(
"identity must be in 'Name <email>' form, got: {s:?}"
))
})?;
let close = s.rfind('>').ok_or_else(|| {
ToriiError::InvalidConfig(format!(
"identity must be in 'Name <email>' form, got: {s:?}"
))
})?;
if close < open {
return Err(ToriiError::InvalidConfig(format!(
"malformed identity: {s:?}"
)));
}
let name = s[..open].trim().trim_end_matches(',').trim().to_string();
let email = s[open + 1..close].trim().to_string();
if name.is_empty() || email.is_empty() {
return Err(ToriiError::InvalidConfig(format!(
"identity needs non-empty name and email: {s:?}"
)));
}
Ok(Self { name, email })
}
}
#[derive(Debug, Clone)]
pub enum OldMatcher {
Full { name: String, email: String },
EmailOnly(String),
NameOnly(String),
}
impl OldMatcher {
pub fn parse_loose(s: &str) -> Result<Self> {
let s = s.trim();
if s.is_empty() {
return Err(ToriiError::InvalidConfig("--old cannot be empty".into()));
}
if s.contains('<') && s.contains('>') {
let id = Identity::parse_full(s)?;
Ok(OldMatcher::Full {
name: id.name,
email: id.email,
})
} else if s.contains('@') {
Ok(OldMatcher::EmailOnly(s.to_string()))
} else {
Ok(OldMatcher::NameOnly(s.to_string()))
}
}
fn matches(&self, name: &str, email: &str) -> bool {
match self {
OldMatcher::Full { name: n, email: e } => name == n && email == e,
OldMatcher::EmailOnly(e) => email == e,
OldMatcher::NameOnly(n) => name == n,
}
}
}
#[derive(Debug, Clone)]
pub struct Rule {
pub old: OldMatcher,
pub new: Identity,
}
#[derive(Debug, Default, Clone)]
pub struct Mapping {
pub rules: Vec<Rule>,
}
impl Mapping {
pub fn single(old: OldMatcher, new: Identity) -> Self {
Self {
rules: vec![Rule { old, new }],
}
}
fn apply(&self, name: &str, email: &str) -> Option<&Identity> {
self.rules
.iter()
.find(|r| r.old.matches(name, email))
.map(|r| &r.new)
}
}
pub fn load_mailmap<P: AsRef<Path>>(path: P) -> Result<Mapping> {
let path = path.as_ref();
let raw = fs::read_to_string(path).map_err(|e| {
ToriiError::InvalidConfig(format!("read {}: {}", path.display(), e))
})?;
let mut rules = Vec::new();
for (idx, line) in raw.lines().enumerate() {
let line_no = idx + 1;
let trimmed = strip_comment(line).trim();
if trimmed.is_empty() {
continue;
}
let rule = parse_mailmap_line(trimmed).map_err(|e| {
ToriiError::InvalidConfig(format!(
"{}:{}: {}",
path.display(),
line_no,
e
))
})?;
rules.push(rule);
}
Ok(Mapping { rules })
}
fn strip_comment(line: &str) -> &str {
match line.find('#') {
Some(pos) => &line[..pos],
None => line,
}
}
fn parse_mailmap_line(s: &str) -> std::result::Result<Rule, String> {
let emails: Vec<(usize, usize)> = bracket_spans(s)?;
match emails.len() {
1 => {
let (open, close) = emails[0];
let new_name = s[..open].trim().trim_end_matches(',').trim();
let commit_email = s[open + 1..close].trim();
if new_name.is_empty() || commit_email.is_empty() {
return Err("expected 'Proper Name <commit@email>'".into());
}
Ok(Rule {
old: OldMatcher::EmailOnly(commit_email.to_string()),
new: Identity {
name: new_name.to_string(),
email: commit_email.to_string(),
},
})
}
2 => {
let (open1, close1) = emails[0];
let (open2, close2) = emails[1];
let lhs_name = s[..open1].trim().trim_end_matches(',').trim();
let proper_email = s[open1 + 1..close1].trim();
let between = s[close1 + 1..open2].trim().trim_end_matches(',').trim();
let commit_email = s[open2 + 1..close2].trim();
if proper_email.is_empty() || commit_email.is_empty() {
return Err("empty email in mailmap entry".into());
}
let old = if between.is_empty() {
OldMatcher::EmailOnly(commit_email.to_string())
} else {
OldMatcher::Full {
name: between.to_string(),
email: commit_email.to_string(),
}
};
let new_name = if !lhs_name.is_empty() {
lhs_name.to_string()
} else if !between.is_empty() {
between.to_string()
} else {
proper_email
.split('@')
.next()
.unwrap_or(proper_email)
.to_string()
};
Ok(Rule {
old,
new: Identity {
name: new_name,
email: proper_email.to_string(),
},
})
}
_ => Err(format!(
"expected one or two <email> blocks, found {}",
emails.len()
)),
}
}
fn bracket_spans(s: &str) -> std::result::Result<Vec<(usize, usize)>, String> {
let mut out = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'<' {
let close = bytes[i + 1..]
.iter()
.position(|&b| b == b'>')
.ok_or_else(|| format!("unclosed '<' at byte {i}"))?
+ i
+ 1;
out.push((i, close));
i = close + 1;
} else {
i += 1;
}
}
Ok(out)
}
#[derive(Debug, Clone, Default)]
pub struct Options {
pub since: Option<String>,
pub dry_run: bool,
pub no_snapshot: bool,
pub committer: bool,
pub allow_dirty: bool,
}
#[derive(Debug, Default)]
pub struct Stats {
pub scanned: usize,
pub matched: usize,
pub rewritten: usize,
pub refs_updated: usize,
pub tags_rewritten: usize,
pub snapshot_id: Option<String>,
}
pub fn reauthor(
repo_path: &Path,
old: OldMatcher,
new: Identity,
opts: &Options,
) -> Result<Stats> {
let mapping = Mapping::single(old, new);
rewrite(repo_path, &mapping, opts)
}
pub fn mailmap_apply(repo_path: &Path, mailmap_path: &Path, opts: &Options) -> Result<Stats> {
let mapping = load_mailmap(mailmap_path)?;
if mapping.rules.is_empty() {
return Err(ToriiError::InvalidConfig(format!(
"no usable rules in {}",
mailmap_path.display()
)));
}
rewrite(repo_path, &mapping, opts)
}
fn rewrite(repo_path: &Path, mapping: &Mapping, opts: &Options) -> Result<Stats> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
pre_flight(&repo, opts)?;
let snapshot_id = if !opts.no_snapshot && !opts.dry_run {
let mgr = crate::snapshot::SnapshotManager::new(repo_path)?;
let id = mgr.create_snapshot(Some("pre-reauthor"))?;
println!("📸 Snapshot: {} (revert with: torii snapshot restore {})", id, id);
Some(id)
} else {
None
};
let oids = collect_commits(&repo, opts.since.as_deref())?;
let mut stats = Stats {
scanned: oids.len(),
snapshot_id,
..Default::default()
};
let mut remap: HashMap<Oid, Oid> = HashMap::new();
for old_oid in &oids {
let commit = repo.find_commit(*old_oid).map_err(ToriiError::Git)?;
let new_author = remap_signature(&commit.author(), mapping);
let new_committer = if opts.committer {
remap_signature(&commit.committer(), mapping)
} else {
None
};
let mut new_parents: Vec<Commit> = Vec::with_capacity(commit.parent_count());
let mut parents_changed = false;
for parent in commit.parents() {
let pid = parent.id();
let mapped = remap.get(&pid).copied().unwrap_or(pid);
if mapped != pid {
parents_changed = true;
}
new_parents.push(repo.find_commit(mapped).map_err(ToriiError::Git)?);
}
let identity_changed = new_author.is_some() || new_committer.is_some();
if identity_changed {
stats.matched += 1;
}
if !identity_changed && !parents_changed {
remap.insert(*old_oid, *old_oid);
continue;
}
if opts.dry_run {
remap.insert(*old_oid, *old_oid); stats.rewritten += 1;
continue;
}
let author = sig_with_time(
new_author.as_ref().map(|s| s.as_str()).unwrap_or(""),
new_author.as_ref().map(|s| s.as_str()).unwrap_or(""),
commit.author().when(),
new_author.is_some().then(|| ()),
&commit.author(),
)?;
let committer = sig_with_time(
new_committer.as_ref().map(|s| s.as_str()).unwrap_or(""),
new_committer.as_ref().map(|s| s.as_str()).unwrap_or(""),
commit.committer().when(),
new_committer.is_some().then(|| ()),
&commit.committer(),
)?;
let tree = commit.tree().map_err(ToriiError::Git)?;
let msg = commit.message().unwrap_or("");
let parent_refs: Vec<&Commit> = new_parents.iter().collect();
let new_oid = repo
.commit(None, &author, &committer, msg, &tree, &parent_refs)
.map_err(ToriiError::Git)?;
remap.insert(*old_oid, new_oid);
stats.rewritten += 1;
}
if opts.dry_run {
return Ok(stats);
}
update_refs(&repo, &remap, opts, &mut stats)?;
Ok(stats)
}
fn pre_flight(repo: &Repository, opts: &Options) -> Result<()> {
if repo.state() != git2::RepositoryState::Clean {
return Err(ToriiError::InvalidConfig(format!(
"refusing to rewrite: repository has a pending operation ({:?}). \
Finish or abort it first.",
repo.state()
)));
}
if !opts.allow_dirty {
let statuses = repo
.statuses(Some(
git2::StatusOptions::new()
.include_untracked(false)
.include_ignored(false),
))
.map_err(ToriiError::Git)?;
let dirty = statuses
.iter()
.any(|s| !s.status().contains(git2::Status::IGNORED));
if dirty {
return Err(ToriiError::InvalidConfig(
"refusing to rewrite: working tree has uncommitted changes. \
Commit, stash (torii snapshot stash), or pass --allow-dirty."
.into(),
));
}
}
Ok(())
}
fn collect_commits(repo: &Repository, since: Option<&str>) -> Result<Vec<Oid>> {
let mut walk = repo.revwalk().map_err(ToriiError::Git)?;
walk.push_head().map_err(ToriiError::Git)?;
walk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)
.map_err(ToriiError::Git)?;
if let Some(rev) = since {
let obj = repo
.revparse_single(rev)
.map_err(|e| ToriiError::InvalidConfig(format!("--since {rev}: {e}")))?;
walk.hide(obj.id()).map_err(ToriiError::Git)?;
}
let mut out = Vec::new();
for r in walk {
out.push(r.map_err(ToriiError::Git)?);
}
Ok(out)
}
fn remap_signature(sig: &Signature, mapping: &Mapping) -> Option<String> {
let name = sig.name().unwrap_or("");
let email = sig.email().unwrap_or("");
mapping
.apply(name, email)
.map(|new| format!("{}|{}", new.name, new.email))
}
fn sig_with_time<'a>(
packed_name_email: &str,
_unused: &str,
when: Time,
changed: Option<()>,
original: &Signature,
) -> Result<Signature<'a>> {
if changed.is_some() {
let (name, email) = packed_name_email
.split_once('|')
.ok_or_else(|| ToriiError::InvalidConfig("internal: malformed remap".into()))?;
Signature::new(name, email, &when).map_err(ToriiError::Git)
} else {
Signature::new(
original.name().unwrap_or(""),
original.email().unwrap_or(""),
&when,
)
.map_err(ToriiError::Git)
}
}
fn update_refs(
repo: &Repository,
remap: &HashMap<Oid, Oid>,
opts: &Options,
stats: &mut Stats,
) -> Result<()> {
let refs: Vec<Reference> = repo
.references()
.map_err(ToriiError::Git)?
.filter_map(|r| r.ok())
.collect();
let mut updated_branches: HashSet<String> = HashSet::new();
for r in refs {
let name = match r.name() {
Some(n) => n.to_string(),
None => continue,
};
if name.starts_with("refs/remotes/") {
continue;
}
if name.starts_with("refs/tags/") {
handle_tag_ref(repo, &r, &name, remap, opts, stats)?;
continue;
}
if let Some(target) = r.target() {
if let Some(&new_oid) = remap.get(&target) {
if new_oid != target {
let mut r = repo
.find_reference(&name)
.map_err(ToriiError::Git)?;
r.set_target(new_oid, "torii reauthor")
.map_err(ToriiError::Git)?;
stats.refs_updated += 1;
updated_branches.insert(name);
}
}
}
}
let head = repo.head().map_err(ToriiError::Git)?;
if head.kind() == Some(git2::ReferenceType::Direct) {
if let Some(oid) = head.target() {
if let Some(&new_oid) = remap.get(&oid) {
if new_oid != oid {
repo.set_head_detached(new_oid).map_err(ToriiError::Git)?;
stats.refs_updated += 1;
}
}
}
}
Ok(())
}
fn handle_tag_ref(
repo: &Repository,
r: &Reference,
name: &str,
remap: &HashMap<Oid, Oid>,
_opts: &Options,
stats: &mut Stats,
) -> Result<()> {
let short = name.strip_prefix("refs/tags/").unwrap_or(name);
let target_oid = match r.target() {
Some(t) => t,
None => return Ok(()),
};
let obj = repo.find_object(target_oid, None).map_err(ToriiError::Git)?;
if let Some(tag) = obj.as_tag() {
let pointee = tag.target_id();
if let Some(&new_pointee) = remap.get(&pointee) {
if new_pointee == pointee {
return Ok(());
}
let new_commit = repo
.find_commit(new_pointee)
.map_err(ToriiError::Git)?;
let tagger = tag
.tagger()
.map(|t| {
Signature::new(
t.name().unwrap_or(""),
t.email().unwrap_or(""),
&t.when(),
)
})
.transpose()
.map_err(ToriiError::Git)?
.unwrap_or_else(|| {
new_commit.author().to_owned()
});
let message = tag.message().unwrap_or("");
repo.tag(
short,
new_commit.as_object(),
&tagger,
message,
true, )
.map_err(ToriiError::Git)?;
stats.tags_rewritten += 1;
}
} else {
if let Some(&new_oid) = remap.get(&target_oid) {
if new_oid != target_oid {
let mut r = repo.find_reference(name).map_err(ToriiError::Git)?;
r.set_target(new_oid, "torii reauthor")
.map_err(ToriiError::Git)?;
stats.refs_updated += 1;
}
}
}
Ok(())
}
pub fn print_summary(stats: &Stats, dry_run: bool) {
if dry_run {
println!(
"✏ Dry-run: would rewrite {} of {} commits, touch {} refs and {} annotated tags.",
stats.rewritten, stats.scanned, stats.refs_updated, stats.tags_rewritten
);
println!(" Run again without --dry-run to apply.");
return;
}
println!(
"✅ Rewrite complete: {} commits remapped ({} matched author/committer), \
{} refs updated, {} annotated tags rewritten.",
stats.rewritten, stats.matched, stats.refs_updated, stats.tags_rewritten
);
if let Some(id) = &stats.snapshot_id {
println!(" Revert: torii snapshot restore {}", id);
}
println!(" Push: torii sync --push --force (history was rewritten)");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_identity() {
let id = Identity::parse_full("Pasqual <paski@paski.dev>").unwrap();
assert_eq!(id.name, "Pasqual");
assert_eq!(id.email, "paski@paski.dev");
}
#[test]
fn rejects_partial_identity() {
assert!(Identity::parse_full("Pasqual").is_err());
assert!(Identity::parse_full("paski@paski.dev").is_err());
assert!(Identity::parse_full("Pasqual <>").is_err());
assert!(Identity::parse_full("Pasqual <").is_err());
}
#[test]
fn old_matcher_autodetect() {
match OldMatcher::parse_loose("outsider <x@y.com>").unwrap() {
OldMatcher::Full { name, email } => {
assert_eq!(name, "outsider");
assert_eq!(email, "x@y.com");
}
_ => panic!("expected Full"),
}
match OldMatcher::parse_loose("x@y.com").unwrap() {
OldMatcher::EmailOnly(e) => assert_eq!(e, "x@y.com"),
_ => panic!("expected EmailOnly"),
}
match OldMatcher::parse_loose("outsider").unwrap() {
OldMatcher::NameOnly(n) => assert_eq!(n, "outsider"),
_ => panic!("expected NameOnly"),
}
}
#[test]
fn mailmap_name_only_form() {
let r = parse_mailmap_line("Pasqual Peñalver <old@x>").unwrap();
match r.old {
OldMatcher::EmailOnly(e) => assert_eq!(e, "old@x"),
_ => panic!(),
}
assert_eq!(r.new.name, "Pasqual Peñalver");
assert_eq!(r.new.email, "old@x");
}
#[test]
fn mailmap_email_only_form() {
let r = parse_mailmap_line("<new@x> <old@x>").unwrap();
assert!(matches!(r.old, OldMatcher::EmailOnly(ref e) if e == "old@x"));
assert_eq!(r.new.email, "new@x");
}
#[test]
fn mailmap_full_rewrite() {
let r = parse_mailmap_line("New Name <new@x> Old Name <old@x>").unwrap();
match r.old {
OldMatcher::Full { name, email } => {
assert_eq!(name, "Old Name");
assert_eq!(email, "old@x");
}
_ => panic!(),
}
assert_eq!(r.new.name, "New Name");
assert_eq!(r.new.email, "new@x");
}
#[test]
fn mailmap_skips_comments_and_blanks() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
"# header\n\nProper <e@x>\n# inline\n<a@x> <b@x>\n",
)
.unwrap();
let m = load_mailmap(tmp.path()).unwrap();
assert_eq!(m.rules.len(), 2);
}
#[test]
fn matcher_apply_precedence() {
let mapping = Mapping {
rules: vec![
Rule {
old: OldMatcher::Full {
name: "A".into(),
email: "a@x".into(),
},
new: Identity {
name: "AA".into(),
email: "aa@x".into(),
},
},
Rule {
old: OldMatcher::EmailOnly("a@x".into()),
new: Identity {
name: "EE".into(),
email: "ee@x".into(),
},
},
],
};
let hit = mapping.apply("A", "a@x").unwrap();
assert_eq!(hit.name, "AA");
let hit = mapping.apply("Other", "a@x").unwrap();
assert_eq!(hit.name, "EE");
assert!(mapping.apply("X", "x@x").is_none());
}
}