Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions docs/src/configurations/config-file-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ graph_width = "auto"
graph_style = "rounded"
initial_selection = "latest"

[core.git]
mailmap = false

[core.search]
ignore_case = false
fuzzy = false
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -129,6 +132,13 @@ pub struct CoreOptionConfig {
pub initial_selection: Option<InitialSelection>,
}

#[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 {
Expand Down Expand Up @@ -429,6 +439,7 @@ mod tests {
graph_style: None,
initial_selection: None,
},
git: CoreGitConfig { mailmap: false },
search: CoreSearchConfig {
ignore_case: false,
fuzzy: false,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -665,6 +679,7 @@ mod tests {
graph_style: None,
initial_selection: None,
},
git: CoreGitConfig { mailmap: false },
search: CoreSearchConfig {
ignore_case: false,
fuzzy: false,
Expand Down
37 changes: 26 additions & 11 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,18 @@ pub struct Repository {
}

impl Repository {
pub fn load(path: &Path, sort: SortCommit, max_count: Option<usize>) -> Result<Self> {
pub fn load(
path: &Path,
sort: SortCommit,
max_count: Option<usize>,
mailmap: bool,
) -> Result<Self> {
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());
}
Expand Down Expand Up @@ -261,6 +266,7 @@ fn load_all_commits(
head: &Head,
stashes: &[Commit],
max_count: Option<usize>,
mailmap: bool,
) -> Vec<Commit> {
let mut cmd = Command::new("git");
cmd.arg("log");
Expand All @@ -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

Expand Down Expand Up @@ -330,11 +336,11 @@ fn load_all_commits(
commits
}

fn load_all_stashes(path: &Path) -> Vec<Commit> {
fn load_all_stashes(path: &Path, mailmap: bool) -> Vec<Commit> {
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)
Expand Down Expand Up @@ -380,11 +386,20 @@ fn load_all_stashes(path: &Path) -> Vec<Commit> {
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<FixedOffset> {
Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion tests/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1392,7 +1392,7 @@ fn generate_and_output_graph_image<P: AsRef<Path>>(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);
Expand Down
125 changes: 125 additions & 0 deletions tests/mailmap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use std::{fs, path::Path, process::Command};

use serie::git::{self, Repository};

type TestResult = Result<(), Box<dyn std::error::Error>>;

// 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(" "));
}
}