diff --git a/config.schema.json b/config.schema.json index ba1068a2..69a6549d 100644 --- a/config.schema.json +++ b/config.schema.json @@ -63,6 +63,18 @@ }, "additionalProperties": false }, + "git": { + "type": "object", + "description": "Git integration settings", + "properties": { + "mailmap": { + "type": "boolean", + "description": "Whether to resolve author and committer identities through the repository's .mailmap file.", + "default": false + } + }, + "additionalProperties": false + }, "search": { "type": "object", "description": "Default search settings", diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 59ff6b94..844dfa6f 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -10,6 +10,9 @@ graph_width = "auto" graph_style = "rounded" initial_selection = "latest" +[core.git] +mailmap = false + [core.search] ignore_case = false fuzzy = false @@ -170,6 +173,15 @@ The initial selection of commit when starting the application. The value specified in the command line argument takes precedence. +### `core.git.mailmap` + +Whether to resolve author and committer identities through the repository's [`.mailmap`](https://git-scm.com/docs/gitmailmap) file. + +- type: `boolean` +- default: `false` + +When enabled, names and emails are displayed as mapped by `.mailmap`, in the same way as `git log` and `git shortlog`. Repositories without a `.mailmap` file are unaffected. + ### `graph.row_image_width` The width mode for each graph row image. diff --git a/src/config.rs b/src/config.rs index 5e6a4716..b0eb868b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -110,6 +110,9 @@ pub struct CoreConfig { pub option: CoreOptionConfig, #[garde(skip)] #[nested] + pub git: CoreGitConfig, + #[garde(skip)] + #[nested] pub search: CoreSearchConfig, #[garde(dive)] #[nested] @@ -129,6 +132,13 @@ pub struct CoreOptionConfig { pub initial_selection: Option, } +#[optional(derives = [Deserialize])] +#[derive(Debug, Clone, PartialEq, Eq, SmartDefault)] +pub struct CoreGitConfig { + #[default = false] + pub mailmap: bool, +} + #[optional(derives = [Deserialize])] #[derive(Debug, Clone, PartialEq, Eq, SmartDefault)] pub struct CoreSearchConfig { @@ -429,6 +439,7 @@ mod tests { graph_style: None, initial_selection: None, }, + git: CoreGitConfig { mailmap: false }, search: CoreSearchConfig { ignore_case: false, fuzzy: false, @@ -513,6 +524,8 @@ mod tests { graph_width = "single" graph_style = "angular" initial_selection = "head" + [core.git] + mailmap = true [core.search] ignore_case = true fuzzy = true @@ -556,6 +569,7 @@ mod tests { graph_style: Some(GraphStyle::Angular), initial_selection: Some(InitialSelection::Head), }, + git: CoreGitConfig { mailmap: true }, search: CoreSearchConfig { ignore_case: true, fuzzy: true, @@ -665,6 +679,7 @@ mod tests { graph_style: None, initial_selection: None, }, + git: CoreGitConfig { mailmap: false }, search: CoreSearchConfig { ignore_case: false, fuzzy: false, diff --git a/src/git.rs b/src/git.rs index 16deb283..d83d98bb 100644 --- a/src/git.rs +++ b/src/git.rs @@ -125,13 +125,18 @@ pub struct Repository { } impl Repository { - pub fn load(path: &Path, sort: SortCommit, max_count: Option) -> Result { + pub fn load( + path: &Path, + sort: SortCommit, + max_count: Option, + mailmap: bool, + ) -> Result { check_git_repository(path)?; let (mut ref_map, head) = load_refs(path); - let stashes = load_all_stashes(path); - let commits = load_all_commits(path, sort, &head, &stashes, max_count); + let stashes = load_all_stashes(path, mailmap); + let commits = load_all_commits(path, sort, &head, &stashes, max_count, mailmap); if commits.is_empty() { return Err("no commits in the repository".into()); } @@ -261,6 +266,7 @@ fn load_all_commits( head: &Head, stashes: &[Commit], max_count: Option, + mailmap: bool, ) -> Vec { let mut cmd = Command::new("git"); cmd.arg("log"); @@ -269,7 +275,7 @@ fn load_all_commits( SortCommit::Chronological => "--date-order", SortCommit::Topological => "--topo-order", }) - .arg(format!("--pretty={}", load_commits_format())) + .arg(format!("--pretty={}", load_commits_format(mailmap))) .arg("--date=iso-strict") .arg("-z"); // use NUL as a delimiter @@ -330,11 +336,11 @@ fn load_all_commits( commits } -fn load_all_stashes(path: &Path) -> Vec { +fn load_all_stashes(path: &Path, mailmap: bool) -> Vec { let mut cmd = Command::new("git") .arg("stash") .arg("list") - .arg(format!("--pretty={}", load_commits_format())) + .arg(format!("--pretty={}", load_commits_format(mailmap))) .arg("--date=iso-strict") .arg("-z") // use NUL as a delimiter .current_dir(path) @@ -380,11 +386,20 @@ fn load_all_stashes(path: &Path) -> Vec { commits } -fn load_commits_format() -> String { - [ - "%H", "%an", "%ae", "%ad", "%cn", "%ce", "%cd", "%s", "%b", "%P", - ] - .join("%x1f") // use Unit Separator as a delimiter +fn load_commits_format(mailmap: bool) -> String { + // The uppercase name/email placeholders (`%aN`, `%aE`, `%cN`, `%cE`) resolve + // identities through the repository's .mailmap, while the lowercase variants + // use the raw values recorded in each commit. + let format = if mailmap { + [ + "%H", "%aN", "%aE", "%ad", "%cN", "%cE", "%cd", "%s", "%b", "%P", + ] + } else { + [ + "%H", "%an", "%ae", "%ad", "%cn", "%ce", "%cd", "%s", "%b", "%P", + ] + }; + format.join("%x1f") // use Unit Separator as a delimiter } fn parse_iso_date(s: &str) -> DateTime { diff --git a/src/lib.rs b/src/lib.rs index 0ec111af..57f762c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,6 +147,7 @@ pub fn run() -> Result<()> { .initial_selection .or(core_config.option.initial_selection) .into(); + let mailmap = core_config.git.mailmap; let graph_color_set = color::GraphColorSet::new(&graph_config.color); @@ -163,7 +164,7 @@ pub fn run() -> Result<()> { let mut terminal = None; let ret = loop { - let repository = git::Repository::load(Path::new("."), order, max_count)?; + let repository = git::Repository::load(Path::new("."), order, max_count, mailmap)?; let graph = graph::calc_graph(&repository); diff --git a/tests/graph.rs b/tests/graph.rs index 8269f76f..04021cf2 100644 --- a/tests/graph.rs +++ b/tests/graph.rs @@ -1392,7 +1392,7 @@ fn generate_and_output_graph_image>(path: P, option: &GenerateGra let graph_color_config = config::GraphColorConfig::default(); let graph_color_set = color::GraphColorSet::new(&graph_color_config); let cell_width_type = graph::CellWidthType::Double; - let repository = git::Repository::load(path.as_ref(), option.sort, max_count).unwrap(); + let repository = git::Repository::load(path.as_ref(), option.sort, max_count, true).unwrap(); let graph = graph::calc_graph(&repository); let image_params = graph::ImageParams::new(&graph_color_set, cell_width_type); let drawing_pixels = graph::DrawingPixels::new(&image_params); diff --git a/tests/mailmap.rs b/tests/mailmap.rs new file mode 100644 index 00000000..eb1f6221 --- /dev/null +++ b/tests/mailmap.rs @@ -0,0 +1,125 @@ +use std::{fs, path::Path, process::Command}; + +use serie::git::{self, Repository}; + +type TestResult = Result<(), Box>; + +// The identity actually recorded in the commits. +const RAW_AUTHOR_NAME: &str = "Old Author"; +const RAW_AUTHOR_EMAIL: &str = "old-author@example.com"; +const RAW_COMMITTER_NAME: &str = "Old Committer"; +const RAW_COMMITTER_EMAIL: &str = "old-committer@example.com"; + +// The canonical identity declared in .mailmap. +const MAPPED_AUTHOR_NAME: &str = "New Author"; +const MAPPED_AUTHOR_EMAIL: &str = "new-author@example.com"; +const MAPPED_COMMITTER_NAME: &str = "New Committer"; +const MAPPED_COMMITTER_EMAIL: &str = "new-committer@example.com"; + +#[test] +fn mailmap_enabled_rewrites_author_and_committer() -> TestResult { + let dir = tempfile::tempdir()?; + let repo_path = dir.path(); + let git = TestGit::new(repo_path); + + git.init(); + git.commit("commit"); + write_mailmap(repo_path); + + let repository = Repository::load(repo_path, git::SortCommit::Chronological, None, true)?; + let commits = repository.all_commits(); + let commit = commits.first().unwrap(); + + assert_eq!(commit.author_name, MAPPED_AUTHOR_NAME); + assert_eq!(commit.author_email, MAPPED_AUTHOR_EMAIL); + assert_eq!(commit.committer_name, MAPPED_COMMITTER_NAME); + assert_eq!(commit.committer_email, MAPPED_COMMITTER_EMAIL); + + Ok(()) +} + +#[test] +fn mailmap_disabled_keeps_raw_identity() -> TestResult { + let dir = tempfile::tempdir()?; + let repo_path = dir.path(); + let git = TestGit::new(repo_path); + + git.init(); + git.commit("commit"); + write_mailmap(repo_path); + + let repository = Repository::load(repo_path, git::SortCommit::Chronological, None, false)?; + let commits = repository.all_commits(); + let commit = commits.first().unwrap(); + + assert_eq!(commit.author_name, RAW_AUTHOR_NAME); + assert_eq!(commit.author_email, RAW_AUTHOR_EMAIL); + assert_eq!(commit.committer_name, RAW_COMMITTER_NAME); + assert_eq!(commit.committer_email, RAW_COMMITTER_EMAIL); + + Ok(()) +} + +#[test] +fn mailmap_enabled_without_mailmap_file_is_a_no_op() -> TestResult { + let dir = tempfile::tempdir()?; + let repo_path = dir.path(); + let git = TestGit::new(repo_path); + + git.init(); + git.commit("commit"); + + let repository = Repository::load(repo_path, git::SortCommit::Chronological, None, true)?; + let commits = repository.all_commits(); + let commit = commits.first().unwrap(); + + assert_eq!(commit.author_name, RAW_AUTHOR_NAME); + assert_eq!(commit.author_email, RAW_AUTHOR_EMAIL); + assert_eq!(commit.committer_name, RAW_COMMITTER_NAME); + assert_eq!(commit.committer_email, RAW_COMMITTER_EMAIL); + + Ok(()) +} + +fn write_mailmap(repo_path: &Path) { + let content = format!( + "{MAPPED_AUTHOR_NAME} <{MAPPED_AUTHOR_EMAIL}> {RAW_AUTHOR_NAME} <{RAW_AUTHOR_EMAIL}>\n\ + {MAPPED_COMMITTER_NAME} <{MAPPED_COMMITTER_EMAIL}> {RAW_COMMITTER_NAME} <{RAW_COMMITTER_EMAIL}>\n" + ); + fs::write(repo_path.join(".mailmap"), content).unwrap(); +} + +struct TestGit<'a> { + path: &'a Path, +} + +impl TestGit<'_> { + fn new(path: &Path) -> TestGit<'_> { + TestGit { path } + } + + fn init(&self) { + self.run(&["init", "-b", "master"]); + } + + fn commit(&self, message: &str) { + self.run(&["commit", "--allow-empty", "-m", message]); + } + + fn run(&self, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(self.path) + .env("GIT_AUTHOR_NAME", RAW_AUTHOR_NAME) + .env("GIT_AUTHOR_EMAIL", RAW_AUTHOR_EMAIL) + .env("GIT_AUTHOR_DATE", "2024-01-01T01:02:03+00:00") + .env("GIT_COMMITTER_NAME", RAW_COMMITTER_NAME) + .env("GIT_COMMITTER_EMAIL", RAW_COMMITTER_EMAIL) + .env("GIT_COMMITTER_DATE", "2024-01-01T01:02:03+00:00") + .env("GIT_CONFIG_NOSYSTEM", "true") + .env("HOME", "/dev/null") + .status() + .unwrap_or_else(|_| panic!("failed to execute git {}", args.join(" "))); + assert!(status.success(), "git {} failed", args.join(" ")); + } +}