From a4ff594a0d2ecd7e0aeb3eb13fc817e8d31d5a3c Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:43:04 -0400 Subject: [PATCH 1/2] Support .mailmap for resolving author and committer identities --- config.schema.json | 5 + docs/src/configurations/config-file-format.md | 10 ++ src/config.rs | 6 + src/git.rs | 37 ++++-- src/lib.rs | 3 +- tests/graph.rs | 2 +- tests/mailmap.rs | 125 ++++++++++++++++++ 7 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 tests/mailmap.rs diff --git a/config.schema.json b/config.schema.json index ba1068a2..586ada61 100644 --- a/config.schema.json +++ b/config.schema.json @@ -59,6 +59,11 @@ "head" ], "default": "latest" + }, + "mailmap": { + "type": "boolean", + "description": "Whether to resolve author and committer identities through the repository's .mailmap file.", + "default": true } }, "additionalProperties": false diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 59ff6b94..42b68c33 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -9,6 +9,7 @@ order = "chrono" graph_width = "auto" graph_style = "rounded" initial_selection = "latest" +mailmap = true [core.search] ignore_case = false @@ -170,6 +171,15 @@ The initial selection of commit when starting the application. The value specified in the command line argument takes precedence. +### `core.option.mailmap` + +Whether to resolve author and committer identities through the repository's [`.mailmap`](https://git-scm.com/docs/gitmailmap) file. + +- type: `boolean` +- default: `true` + +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..1fc67371 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,6 +127,8 @@ pub struct CoreOptionConfig { pub graph_width: Option, pub graph_style: Option, pub initial_selection: Option, + #[default = true] + pub mailmap: bool, } #[optional(derives = [Deserialize])] @@ -428,6 +430,7 @@ mod tests { graph_width: None, graph_style: None, initial_selection: None, + mailmap: true, }, search: CoreSearchConfig { ignore_case: false, @@ -513,6 +516,7 @@ mod tests { graph_width = "single" graph_style = "angular" initial_selection = "head" + mailmap = false [core.search] ignore_case = true fuzzy = true @@ -555,6 +559,7 @@ mod tests { graph_width: Some(GraphWidthType::Single), graph_style: Some(GraphStyle::Angular), initial_selection: Some(InitialSelection::Head), + mailmap: false, }, search: CoreSearchConfig { ignore_case: true, @@ -664,6 +669,7 @@ mod tests { graph_width: None, graph_style: None, initial_selection: None, + mailmap: true, }, search: CoreSearchConfig { ignore_case: 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..b6a20e6f 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.option.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(" ")); + } +} From 7c44efd96fe268dfd62d1161601df2ac626f82e9 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:46:50 -0400 Subject: [PATCH 2/2] Move mailmap under core.git and default it to false --- config.schema.json | 11 +++++++++-- docs/src/configurations/config-file-format.md | 8 +++++--- src/config.rs | 19 ++++++++++++++----- src/lib.rs | 2 +- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/config.schema.json b/config.schema.json index 586ada61..69a6549d 100644 --- a/config.schema.json +++ b/config.schema.json @@ -59,11 +59,18 @@ "head" ], "default": "latest" - }, + } + }, + "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": true + "default": false } }, "additionalProperties": false diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 42b68c33..844dfa6f 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -9,7 +9,9 @@ order = "chrono" graph_width = "auto" graph_style = "rounded" initial_selection = "latest" -mailmap = true + +[core.git] +mailmap = false [core.search] ignore_case = false @@ -171,12 +173,12 @@ The initial selection of commit when starting the application. The value specified in the command line argument takes precedence. -### `core.option.mailmap` +### `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: `true` +- 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. diff --git a/src/config.rs b/src/config.rs index 1fc67371..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] @@ -127,7 +130,12 @@ pub struct CoreOptionConfig { pub graph_width: Option, pub graph_style: Option, pub initial_selection: Option, - #[default = true] +} + +#[optional(derives = [Deserialize])] +#[derive(Debug, Clone, PartialEq, Eq, SmartDefault)] +pub struct CoreGitConfig { + #[default = false] pub mailmap: bool, } @@ -430,8 +438,8 @@ mod tests { graph_width: None, graph_style: None, initial_selection: None, - mailmap: true, }, + git: CoreGitConfig { mailmap: false }, search: CoreSearchConfig { ignore_case: false, fuzzy: false, @@ -516,7 +524,8 @@ mod tests { graph_width = "single" graph_style = "angular" initial_selection = "head" - mailmap = false + [core.git] + mailmap = true [core.search] ignore_case = true fuzzy = true @@ -559,8 +568,8 @@ mod tests { graph_width: Some(GraphWidthType::Single), graph_style: Some(GraphStyle::Angular), initial_selection: Some(InitialSelection::Head), - mailmap: false, }, + git: CoreGitConfig { mailmap: true }, search: CoreSearchConfig { ignore_case: true, fuzzy: true, @@ -669,8 +678,8 @@ mod tests { graph_width: None, graph_style: None, initial_selection: None, - mailmap: true, }, + git: CoreGitConfig { mailmap: false }, search: CoreSearchConfig { ignore_case: false, fuzzy: false, diff --git a/src/lib.rs b/src/lib.rs index b6a20e6f..57f762c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,7 +147,7 @@ pub fn run() -> Result<()> { .initial_selection .or(core_config.option.initial_selection) .into(); - let mailmap = core_config.option.mailmap; + let mailmap = core_config.git.mailmap; let graph_color_set = color::GraphColorSet::new(&graph_config.color);