a73x

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
    );
}