use std::io::{self, BufRead, Write};
use clap::{Args, Subcommand};
use rusqlite::params;
use tga::core::config::Config;
use tga::core::db::Database;
#[derive(Args, Debug)]
#[command(
about = "List, merge, or manage developer identity aliases.",
long_about = "Manage the identity resolution table that maps commit author metadata\n\
(name + email combinations) to a single canonical identity per engineer.\n\n\
Merging two identities reassigns all commits from the source to the destination\n\
and deletes the source row. Subsequent pipeline stages and reports will see only\n\
the consolidated identity.\n\n\
tga automatically resolves identity during collection using fuzzy matching.\n\
Use these subcommands for manual corrections and audits.",
after_help = "EXAMPLES:\n\
# List all canonical identities in the database\n\
tga aliases list\n\n\
# Merge a work email into the canonical personal-account identity\n\
tga aliases merge old@contractor.com alice@company.com\n\n\
# Merge without the confirmation prompt\n\
tga aliases merge old@example.com alice@example.com --yes\n\n\
TIPS:\n\
- Run `tga report --author alice@company.com` to verify the merge result.\n\
- Use the canonical email from `tga aliases list` as the --author value."
)]
pub struct AliasesArgs {
#[command(subcommand)]
pub subcommand: AliasesSubcommand,
}
#[derive(Subcommand, Debug)]
pub enum AliasesSubcommand {
List,
Merge {
src: String,
dst: String,
#[arg(long, default_value_t = false)]
yes: bool,
},
}
pub fn run(_config: Config, db: &mut Database, args: AliasesArgs) -> anyhow::Result<()> {
match args.subcommand {
AliasesSubcommand::List => list(db),
AliasesSubcommand::Merge { src, dst, yes } => {
merge(db, &src, &dst, yes, &mut io::stdin().lock())
}
}
}
fn list(db: &Database) -> anyhow::Result<()> {
let conn = db.connection();
let mut stmt = conn.prepare(
"SELECT canonical_name, canonical_email, aliases FROM authors \
ORDER BY canonical_email",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
let mut count = 0usize;
println!(
"{:<32} {:<48} aliases",
"canonical_name", "canonical_email"
);
println!("{}", "-".repeat(96));
for r in rows {
let (name, email, aliases) = r?;
let parsed: Vec<String> = serde_json::from_str(&aliases).unwrap_or_default();
let alias_str = if parsed.is_empty() {
"-".to_string()
} else {
parsed.join(", ")
};
println!("{:<32} {:<48} {}", name, email, alias_str);
count += 1;
}
if count == 0 {
println!("(no authors found — run `tga collect` first)");
}
Ok(())
}
fn merge<R: BufRead>(
db: &mut Database,
src_email: &str,
dst_email: &str,
skip_confirm: bool,
reader: &mut R,
) -> anyhow::Result<()> {
if src_email == dst_email {
anyhow::bail!("source and destination emails are identical: {src_email}");
}
let (src_id, src_name, src_aliases_json) = lookup_author(db, src_email)?
.ok_or_else(|| anyhow::anyhow!("source identity not found: {src_email}"))?;
let (dst_id, _dst_name, dst_aliases_json) = lookup_author(db, dst_email)?
.ok_or_else(|| anyhow::anyhow!("destination identity not found: {dst_email}"))?;
if !skip_confirm {
print!(
"Merge {src_name} <{src_email}> (id={src_id}) into <{dst_email}> (id={dst_id})? [y/N] "
);
io::stdout().flush()?;
let mut line = String::new();
reader.read_line(&mut line)?;
if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") {
println!("Aborted.");
return Ok(());
}
}
let mut src_aliases: Vec<String> = serde_json::from_str(&src_aliases_json).unwrap_or_default();
let mut dst_aliases: Vec<String> = serde_json::from_str(&dst_aliases_json).unwrap_or_default();
dst_aliases.append(&mut src_aliases);
dst_aliases.push(src_email.to_string());
dst_aliases.sort();
dst_aliases.dedup();
let merged_aliases = serde_json::to_string(&dst_aliases)?;
let conn = db.connection_mut();
let tx = conn.transaction()?;
let n_commits = tx.execute(
"UPDATE commits SET author_id = ?1 WHERE author_id = ?2",
params![dst_id, src_id],
)?;
tx.execute(
"UPDATE authors SET aliases = ?1 WHERE id = ?2",
params![merged_aliases, dst_id],
)?;
tx.execute("DELETE FROM authors WHERE id = ?1", params![src_id])?;
tx.commit()?;
println!(
"Merged {src_email} → {dst_email} (reassigned {n_commits} commits, source author row deleted)"
);
Ok(())
}
fn lookup_author(db: &Database, email: &str) -> anyhow::Result<Option<(i64, String, String)>> {
let conn = db.connection();
let mut stmt =
conn.prepare("SELECT id, canonical_name, aliases FROM authors WHERE canonical_email = ?1")?;
let mut rows = stmt.query(params![email])?;
if let Some(row) = rows.next()? {
Ok(Some((row.get(0)?, row.get(1)?, row.get(2)?)))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn insert_author(db: &Database, name: &str, email: &str) -> i64 {
db.connection()
.execute(
"INSERT INTO authors (canonical_name, canonical_email, aliases) VALUES (?1, ?2, '[]')",
params![name, email],
)
.expect("insert");
db.connection().last_insert_rowid()
}
fn insert_commit(db: &Database, sha: &str, author_id: i64) {
db.connection()
.execute(
"INSERT INTO commits (sha, author_id, author_name, author_email, timestamp, \
message, repository) VALUES (?1, ?2, 'n', 'e', '2024-01-01T00:00:00Z', 'm', 'r')",
params![sha, author_id],
)
.expect("insert commit");
}
#[test]
fn merge_moves_commits_and_deletes_source() {
let mut db = Database::open_in_memory().expect("open");
let src = insert_author(&db, "Alice", "old@example.com");
let dst = insert_author(&db, "Alice", "new@example.com");
insert_commit(&db, "sha1", src);
insert_commit(&db, "sha2", src);
insert_commit(&db, "sha3", dst);
let mut input: &[u8] = b"y\n";
merge(
&mut db,
"old@example.com",
"new@example.com",
false,
&mut input,
)
.expect("merge ok");
let src_exists: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM authors WHERE canonical_email = 'old@example.com'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(src_exists, 0);
let n: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM commits WHERE author_id = ?1",
params![dst],
|r| r.get(0),
)
.expect("count");
assert_eq!(n, 3);
}
#[test]
fn merge_rejects_identical_emails() {
let mut db = Database::open_in_memory().expect("open");
insert_author(&db, "A", "a@example.com");
let mut input: &[u8] = b"y\n";
let err = merge(&mut db, "a@example.com", "a@example.com", true, &mut input).unwrap_err();
assert!(err.to_string().contains("identical"));
}
#[test]
fn merge_errors_when_source_missing() {
let mut db = Database::open_in_memory().expect("open");
insert_author(&db, "B", "b@example.com");
let mut input: &[u8] = b"y\n";
let err = merge(
&mut db,
"missing@example.com",
"b@example.com",
true,
&mut input,
)
.unwrap_err();
assert!(err.to_string().contains("source identity not found"));
}
}