a9091312
Add whoami command and identity aliasing
a73x 2026-03-21 16:28
Implements user identity management: a `whoami` command showing name, email, signing key, and aliases; plus `identity alias/unalias/list` subcommands for linking multiple email addresses to a single identity. Aliases are stored in `.git/collab/identity-aliases`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/cli.rs b/src/cli.rs index 9c1a270..816398e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -61,6 +61,13 @@ pub enum Commands { /// Manage trusted keys #[command(subcommand)] Key(KeyCmd), /// Show current user identity Whoami, /// Manage identity aliases #[command(subcommand)] Identity(IdentityCmd), } #[derive(Subcommand)] @@ -286,3 +293,19 @@ pub enum PatchCmd { id: String, }, } #[derive(Subcommand)] pub enum IdentityCmd { /// Link another email to your current identity Alias { /// Email address to add as alias email: String, }, /// Remove an email alias Unalias { /// Email address to remove email: String, }, /// Show current identity and aliases List, } diff --git a/src/identity.rs b/src/identity.rs index 1e40fc3..40f1980 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -1,6 +1,12 @@ use std::fs; use std::path::PathBuf; use base64::Engine; use git2::Repository; use crate::error::Error; use crate::event::Author; use crate::signing; pub fn get_author(repo: &Repository) -> Result<Author, git2::Error> { let config = repo.config()?; @@ -12,3 +18,175 @@ pub fn get_author(repo: &Repository) -> Result<Author, git2::Error> { pub fn author_signature(author: &Author) -> Result<git2::Signature<'static>, git2::Error> { git2::Signature::now(&author.name, &author.email) } /// Path to the identity-aliases file inside the repo's collab directory. fn aliases_path(repo: &Repository) -> PathBuf { repo.path().join("collab").join("identity-aliases") } /// Load the list of email aliases from `.git/collab/identity-aliases`. /// Returns an empty vec if the file doesn't exist. pub fn load_aliases(repo: &Repository) -> Result<Vec<String>, Error> { let path = aliases_path(repo); if !path.exists() { return Ok(Vec::new()); } let contents = fs::read_to_string(&path)?; let aliases: Vec<String> = contents .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect(); Ok(aliases) } /// Save the list of email aliases to `.git/collab/identity-aliases`. fn save_aliases(repo: &Repository, aliases: &[String]) -> Result<(), Error> { let path = aliases_path(repo); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let contents = aliases.join("\n"); let contents = if contents.is_empty() { contents } else { format!("{}\n", contents) }; fs::write(&path, contents)?; Ok(()) } /// Add an email alias for the current identity. Returns true if added, false if already present. pub fn add_alias(repo: &Repository, email: &str) -> Result<bool, Error> { let author = get_author(repo)?; if email == author.email { return Err(Error::Cmd(format!( "'{}' is already your primary email; cannot add as alias", email ))); } let mut aliases = load_aliases(repo)?; if aliases.iter().any(|a| a == email) { return Ok(false); } aliases.push(email.to_string()); save_aliases(repo, &aliases)?; Ok(true) } /// Remove an email alias. Returns an error if the alias is not found. pub fn remove_alias(repo: &Repository, email: &str) -> Result<(), Error> { let mut aliases = load_aliases(repo)?; let before = aliases.len(); aliases.retain(|a| a != email); if aliases.len() == before { return Err(Error::Cmd(format!( "alias '{}' not found", email ))); } save_aliases(repo, &aliases)?; Ok(()) } /// Display full identity information: name, email, signing key, aliases. pub fn whoami(repo: &Repository) -> Result<String, Error> { let author = get_author(repo)?; let mut lines = Vec::new(); lines.push(format!("Name: {}", author.name)); lines.push(format!("Email: {}", author.email)); // Signing key match signing::signing_key_dir() { Ok(config_dir) => match signing::load_verifying_key(&config_dir) { Ok(vk) => { let pubkey_b64 = base64::engine::general_purpose::STANDARD.encode(vk.to_bytes()); lines.push(format!("Signing key: {}", pubkey_b64)); } Err(_) => { lines.push("Signing key: (not configured)".to_string()); } }, Err(_) => { lines.push("Signing key: (not configured)".to_string()); } } // Aliases let aliases = load_aliases(repo)?; if aliases.is_empty() { lines.push("Aliases: (none)".to_string()); } else { lines.push("Aliases:".to_string()); for alias in &aliases { lines.push(format!(" {}", alias)); } } Ok(lines.join("\n")) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn test_repo(name: &str, email: &str) -> (TempDir, Repository) { let dir = TempDir::new().unwrap(); let repo = Repository::init(dir.path()).unwrap(); { let mut config = repo.config().unwrap(); config.set_str("user.name", name).unwrap(); config.set_str("user.email", email).unwrap(); } (dir, repo) } #[test] fn test_load_aliases_empty_when_no_file() { let (_dir, repo) = test_repo("Test", "test@example.com"); let aliases = load_aliases(&repo).unwrap(); assert!(aliases.is_empty()); } #[test] fn test_add_and_load_alias() { let (_dir, repo) = test_repo("Test", "test@example.com"); let added = add_alias(&repo, "other@example.com").unwrap(); assert!(added); let aliases = load_aliases(&repo).unwrap(); assert_eq!(aliases, vec!["other@example.com"]); } #[test] fn test_add_duplicate_alias_returns_false() { let (_dir, repo) = test_repo("Test", "test@example.com"); add_alias(&repo, "other@example.com").unwrap(); let added = add_alias(&repo, "other@example.com").unwrap(); assert!(!added); } #[test] fn test_add_primary_email_errors() { let (_dir, repo) = test_repo("Test", "test@example.com"); let result = add_alias(&repo, "test@example.com"); assert!(result.is_err()); } #[test] fn test_remove_alias() { let (_dir, repo) = test_repo("Test", "test@example.com"); add_alias(&repo, "other@example.com").unwrap(); remove_alias(&repo, "other@example.com").unwrap(); let aliases = load_aliases(&repo).unwrap(); assert!(aliases.is_empty()); } #[test] fn test_remove_nonexistent_alias_errors() { let (_dir, repo) = test_repo("Test", "test@example.com"); let result = remove_alias(&repo, "nobody@example.com"); assert!(result.is_err()); } } diff --git a/src/lib.rs b/src/lib.rs index a773cd3..59ec7d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,7 @@ pub mod trust; pub mod tui; use base64::Engine; use cli::{Commands, IssueCmd, KeyCmd, PatchCmd}; use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd}; use event::ReviewVerdict; use git2::Repository; use state::{IssueStatus, PatchStatus}; @@ -351,6 +351,42 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { println!("Public key: {}", pubkey_b64); Ok(()) } Commands::Whoami => { let info = identity::whoami(repo)?; println!("{}", info); Ok(()) } Commands::Identity(cmd) => match cmd { IdentityCmd::Alias { email } => { let added = identity::add_alias(repo, &email)?; if added { println!("Alias '{}' added.", email); } else { println!("Alias '{}' already exists.", email); } Ok(()) } IdentityCmd::Unalias { email } => { identity::remove_alias(repo, &email)?; println!("Alias '{}' removed.", email); Ok(()) } IdentityCmd::List => { let author = identity::get_author(repo)?; println!("Name: {}", author.name); println!("Email: {} (primary)", author.email); let aliases = identity::load_aliases(repo)?; if aliases.is_empty() { println!("Aliases: (none)"); } else { println!("Aliases:"); for alias in &aliases { println!(" {}", alias); } } Ok(()) } }, Commands::Key(cmd) => match cmd { KeyCmd::Add { pubkey, diff --git a/tests/identity_test.rs b/tests/identity_test.rs new file mode 100644 index 0000000..d3367a9 --- /dev/null +++ b/tests/identity_test.rs @@ -0,0 +1,137 @@ mod common; use common::TestRepo; // =========================================================================== // Whoami command // =========================================================================== #[test] fn test_whoami_shows_name_and_email() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["whoami"]); assert!(out.contains("Alice"), "should show user name"); assert!(out.contains("alice@example.com"), "should show user email"); } #[test] fn test_whoami_shows_signing_key() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["whoami"]); // Signing key is set up by TestRepo::new, so we should see a public key assert!( out.contains("Signing key:"), "should show signing key label: {}", out ); } #[test] fn test_whoami_no_aliases_initially() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["whoami"]); assert!( out.contains("Aliases: (none)"), "should show no aliases: {}", out ); } // =========================================================================== // Identity alias add // =========================================================================== #[test] fn test_identity_alias_add() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["identity", "alias", "alice@work.com"]); assert!(out.contains("alice@work.com"), "should confirm alias added"); } #[test] fn test_identity_alias_shows_in_whoami() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.run_ok(&["identity", "alias", "alice@work.com"]); let out = repo.run_ok(&["whoami"]); assert!( out.contains("alice@work.com"), "whoami should show alias: {}", out ); } #[test] fn test_identity_alias_add_multiple() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.run_ok(&["identity", "alias", "alice@work.com"]); repo.run_ok(&["identity", "alias", "alice@personal.org"]); let out = repo.run_ok(&["identity", "list"]); assert!(out.contains("alice@work.com"), "should list first alias"); assert!(out.contains("alice@personal.org"), "should list second alias"); } #[test] fn test_identity_alias_duplicate_rejected() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.run_ok(&["identity", "alias", "alice@work.com"]); let out = repo.run_ok(&["identity", "alias", "alice@work.com"]); assert!( out.contains("already"), "should indicate alias already exists: {}", out ); } #[test] fn test_identity_alias_cannot_add_primary_email() { let repo = TestRepo::new("Alice", "alice@example.com"); let err = repo.run_err(&["identity", "alias", "alice@example.com"]); assert!( err.contains("primary email"), "should reject aliasing primary email: {}", err ); } // =========================================================================== // Identity list // =========================================================================== #[test] fn test_identity_list_shows_primary_and_aliases() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.run_ok(&["identity", "alias", "alice@work.com"]); let out = repo.run_ok(&["identity", "list"]); assert!(out.contains("alice@example.com"), "should show primary email"); assert!(out.contains("alice@work.com"), "should show alias"); } // =========================================================================== // Identity alias remove // =========================================================================== #[test] fn test_identity_alias_remove() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.run_ok(&["identity", "alias", "alice@work.com"]); let out = repo.run_ok(&["identity", "unalias", "alice@work.com"]); assert!(out.contains("alice@work.com"), "should confirm removal"); let list_out = repo.run_ok(&["identity", "list"]); assert!( !list_out.contains("alice@work.com"), "alias should be gone: {}", list_out ); } #[test] fn test_identity_alias_remove_nonexistent_errors() { let repo = TestRepo::new("Alice", "alice@example.com"); let err = repo.run_err(&["identity", "unalias", "nobody@example.com"]); assert!( err.contains("not found"), "should error on nonexistent alias: {}", err ); }