diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c05d90c5..d8eff703a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * New command `jj git remote set-url` that sets the url of a git remote. +* Support for [`.mailmap`](https://git-scm.com/docs/gitmailmap) files has + been added. + ### Fixed bugs * `jj git push` now ignores immutable commits when checking whether a diff --git a/Cargo.lock b/Cargo.lock index 0677eb222e..03baedfd61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -977,6 +977,7 @@ dependencies = [ "gix-index", "gix-lock", "gix-macros", + "gix-mailmap", "gix-object", "gix-odb", "gix-pack", @@ -1080,9 +1081,9 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "367ee9093b0c2b04fd04c5c7c8b6a1082713534eab537597ae343663a518fa99" +checksum = "9eed6931f21491ee0aeb922751bd7ec97b4b2fe8fbfedcb678e2a2dce5f3b8c0" dependencies = [ "bstr", "itoa", @@ -1234,6 +1235,18 @@ dependencies = [ "syn", ] +[[package]] +name = "gix-mailmap" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30d52feec20210e102ef0dd53b7e3265a6ac8ccd492b2d67f0e357d7a8d8439" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "thiserror", +] + [[package]] name = "gix-object" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 182d48c880..9425d4f4bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ futures = "0.3.30" git2 = "0.18.3" gix = { version = "0.63.0", default-features = false, features = [ "index", + "mailmap", "max-performance-safe", ] } glob = "0.3.1" diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index a72e89e9ff..50e770eb55 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -41,6 +41,7 @@ use jj_lib::git_backend::GitBackend; use jj_lib::gitignore::{GitIgnoreError, GitIgnoreFile}; use jj_lib::hex_util::to_reverse_hex; use jj_lib::id_prefix::IdPrefixContext; +use jj_lib::mailmap::get_wc_commit_mailmap; use jj_lib::matchers::Matcher; use jj_lib::merge::MergedTreeValue; use jj_lib::merged_tree::MergedTree; @@ -991,11 +992,16 @@ impl WorkspaceCommandHelper { path_converter: &self.path_converter, workspace_id: self.workspace_id(), }; + let mailmap = Rc::new(get_wc_commit_mailmap( + self.repo().as_ref(), + self.workspace_id(), + )); RevsetParseContext::new( &self.revset_aliases_map, self.settings.user_email(), &self.revset_extensions, Some(workspace_context), + mailmap, ) } diff --git a/cli/src/commands/git/push.rs b/cli/src/commands/git/push.rs index e2bc696fa3..ce530a8295 100644 --- a/cli/src/commands/git/push.rs +++ b/cli/src/commands/git/push.rs @@ -289,14 +289,14 @@ pub fn cmd_git_push( if commit.description().is_empty() && !args.allow_empty_description { reasons.push("it has no description"); } - if commit.author().name.is_empty() - || commit.author().name == UserSettings::USER_NAME_PLACEHOLDER - || commit.author().email.is_empty() - || commit.author().email == UserSettings::USER_EMAIL_PLACEHOLDER - || commit.committer().name.is_empty() - || commit.committer().name == UserSettings::USER_NAME_PLACEHOLDER - || commit.committer().email.is_empty() - || commit.committer().email == UserSettings::USER_EMAIL_PLACEHOLDER + if commit.author_raw().name.is_empty() + || commit.author_raw().name == UserSettings::USER_NAME_PLACEHOLDER + || commit.author_raw().email.is_empty() + || commit.author_raw().email == UserSettings::USER_EMAIL_PLACEHOLDER + || commit.committer_raw().name.is_empty() + || commit.committer_raw().name == UserSettings::USER_NAME_PLACEHOLDER + || commit.committer_raw().email.is_empty() + || commit.committer_raw().email == UserSettings::USER_EMAIL_PLACEHOLDER { reasons.push("it has no author and/or committer set"); } diff --git a/cli/src/commit_templater.rs b/cli/src/commit_templater.rs index cfae822f35..48ff8c38aa 100644 --- a/cli/src/commit_templater.rs +++ b/cli/src/commit_templater.rs @@ -25,6 +25,7 @@ use jj_lib::extensions_map::ExtensionsMap; use jj_lib::git; use jj_lib::hex_util::to_reverse_hex; use jj_lib::id_prefix::IdPrefixContext; +use jj_lib::mailmap::{get_wc_commit_mailmap, Mailmap}; use jj_lib::object_id::ObjectId as _; use jj_lib::op_store::{RefTarget, RemoteRef, WorkspaceId}; use jj_lib::repo::Repo; @@ -61,6 +62,7 @@ pub struct CommitTemplateLanguage<'repo> { build_fn_table: CommitTemplateBuildFnTable<'repo>, keyword_cache: CommitKeywordCache<'repo>, cache_extensions: ExtensionsMap, + mailmap: Rc, } impl<'repo> CommitTemplateLanguage<'repo> { @@ -83,6 +85,8 @@ impl<'repo> CommitTemplateLanguage<'repo> { .build_cache_extensions(&mut cache_extensions); } + let mailmap = Rc::new(get_wc_commit_mailmap(repo, workspace_id)); + CommitTemplateLanguage { repo, workspace_id: workspace_id.clone(), @@ -91,6 +95,7 @@ impl<'repo> CommitTemplateLanguage<'repo> { build_fn_table, keyword_cache: CommitKeywordCache::default(), cache_extensions, + mailmap, } } } @@ -468,26 +473,43 @@ fn builtin_commit_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, Comm Ok(L::wrap_commit_list(out_property)) }, ); + map.insert("author", |language, _build_ctx, self_property, function| { + function.expect_no_arguments()?; + let mailmap = language.mailmap.clone(); + let out_property = self_property.map(move |commit| commit.author(&mailmap)); + Ok(L::wrap_signature(out_property)) + }); map.insert( - "author", + "author_raw", |_language, _build_ctx, self_property, function| { function.expect_no_arguments()?; - let out_property = self_property.map(|commit| commit.author().clone()); + let out_property = self_property.map(|commit| commit.author_raw().clone()); Ok(L::wrap_signature(out_property)) }, ); map.insert( "committer", + |language, _build_ctx, self_property, function| { + function.expect_no_arguments()?; + let mailmap = language.mailmap.clone(); + let out_property = self_property.map(move |commit| commit.committer(&mailmap)); + Ok(L::wrap_signature(out_property)) + }, + ); + map.insert( + "committer_raw", |_language, _build_ctx, self_property, function| { function.expect_no_arguments()?; - let out_property = self_property.map(|commit| commit.committer().clone()); + let out_property = self_property.map(|commit| commit.committer_raw().clone()); Ok(L::wrap_signature(out_property)) }, ); map.insert("mine", |language, _build_ctx, self_property, function| { function.expect_no_arguments()?; + let mailmap = language.mailmap.clone(); let user_email = language.revset_parse_context.user_email().to_owned(); - let out_property = self_property.map(move |commit| commit.author().email == user_email); + let out_property = + self_property.map(move |commit| commit.author(&mailmap).email == user_email); Ok(L::wrap_boolean(out_property)) }); map.insert( diff --git a/cli/tests/common/mod.rs b/cli/tests/common/mod.rs index 72f4eb5a51..1bb47bef99 100644 --- a/cli/tests/common/mod.rs +++ b/cli/tests/common/mod.rs @@ -160,7 +160,7 @@ impl TestEnvironment { cmd } - fn get_ok(&self, mut cmd: assert_cmd::Command) -> (String, String) { + pub(crate) fn get_ok(&self, mut cmd: assert_cmd::Command) -> (String, String) { let assert = cmd.assert().success(); let stdout = self.normalize_output(&get_stdout_string(&assert)); let stderr = self.normalize_output(&get_stderr_string(&assert)); diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index c143db0975..86d487c2dd 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -43,6 +43,7 @@ mod test_immutable_commits; mod test_init_command; mod test_interdiff_command; mod test_log_command; +mod test_mailmap; mod test_move_command; mod test_new_command; mod test_next_prev_commands; diff --git a/cli/tests/test_mailmap.rs b/cli/tests/test_mailmap.rs new file mode 100644 index 0000000000..96a19755e2 --- /dev/null +++ b/cli/tests/test_mailmap.rs @@ -0,0 +1,162 @@ +// Copyright 2023 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::common::TestEnvironment; + +#[test] +fn test_mailmap() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + let mut mailmap = String::new(); + let mailmap_path = repo_path.join(".mailmap"); + let mut append_mailmap = move |extra| { + mailmap.push_str(extra); + std::fs::write(&mailmap_path, &mailmap).unwrap() + }; + + let jj_cmd_ok_as = |name: &str, email: &str, args: &[&str]| { + let mut cmd = test_env.jj_cmd(&repo_path, args); + cmd.env("JJ_USER", name); + cmd.env("JJ_EMAIL", email); + test_env.get_ok(cmd) + }; + + append_mailmap("# test comment\n"); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author"]); + insta::assert_snapshot!(stdout, @r###" + @ Test User + ◉ + "###); + + // Map an email address without any name change. + jj_cmd_ok_as("Test Üser", "test.user@example.com", &["new"]); + append_mailmap(" \n"); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author"]); + insta::assert_snapshot!(stdout, @r###" + @ Test Üser + ◉ Test User + ◉ + "###); + + // Map an email address to a new name. + jj_cmd_ok_as("West User", "xest.user@example.com", &["new"]); + append_mailmap("Fest User \n"); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author"]); + insta::assert_snapshot!(stdout, @r###" + @ Fest User + ◉ Test Üser + ◉ Test User + ◉ + "###); + + // Map an email address to a new name and email address. + jj_cmd_ok_as("Pest User", "pest.user@example.com", &["new"]); + append_mailmap("Best User \n"); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author"]); + insta::assert_snapshot!(stdout, @r###" + @ Best User + ◉ Fest User + ◉ Test Üser + ◉ Test User + ◉ + "###); + + // Map an ambiguous email address using names for disambiguation. + jj_cmd_ok_as("Rest User", "user@test", &["new"]); + jj_cmd_ok_as("Vest User", "user@test", &["new"]); + append_mailmap( + &[ + "Jest User Rest User \n", + "Zest User Vest User \n", + ] + .concat(), + ); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author"]); + insta::assert_snapshot!(stdout, @r###" + @ Zest User + ◉ Jest User + ◉ Best User + ◉ Fest User + ◉ Test Üser + ◉ Test User + ◉ + "###); + + // The `.mailmap` file in the current workspace’s @ commit should be used. + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author", "--at-operation=@-"]); + insta::assert_snapshot!(stdout, @r###" + @ Vest User + ◉ Rest User + ◉ Best User + ◉ Fest User + ◉ Test Üser + ◉ Test User + ◉ + "###); + + // The `author(pattern)` revset function should find mapped committers. + let stdout = + test_env.jj_cmd_success(&repo_path, &["log", "-T", "author", "-r", "author(best)"]); + insta::assert_snapshot!(stdout, @r###" + ◉ Best User + │ + ~ + "###); + + // The `author(pattern)` revset function should only search the mapped form. + // This matches Git’s behaviour and avoids some tricky implementation questions. + let stdout = + test_env.jj_cmd_success(&repo_path, &["log", "-T", "author", "-r", "author(pest)"]); + insta::assert_snapshot!(stdout, @r###" + "###); + + // The `author_raw(pattern)` revset function should search the unmapped + // commit data. + let stdout = test_env.jj_cmd_success( + &repo_path, + &["log", "-T", "author", "-r", "author_raw(\"user@test\")"], + ); + insta::assert_snapshot!(stdout, @r###" + @ Zest User + ◉ Jest User + │ + ~ + "###); + + // `mine()` should only search the mapped author; this may be confusing in this + // case, but matches the semantics of it expanding to `author(‹user.email›)`. + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author", "-r", "mine()"]); + insta::assert_snapshot!(stdout, @r###" + "###); + + // `mine()` should find commits that map to the current `user.email`. + let (stdout, _stderr) = jj_cmd_ok_as( + "Tëst Üser", + "test.user@example.net", + &["log", "-T", "author", "-r", "mine()"], + ); + insta::assert_snapshot!(stdout, @r###" + ◉ Test Üser + ◉ Test User + │ + ~ + "###); +} diff --git a/cli/tests/test_revset_output.rs b/cli/tests/test_revset_output.rs index df723efbb2..7d6d8b1867 100644 --- a/cli/tests/test_revset_output.rs +++ b/cli/tests/test_revset_output.rs @@ -290,7 +290,7 @@ fn test_function_name_hint() { | ^-----^ | = Function "author_" doesn't exist - Hint: Did you mean "author", "my_author"? + Hint: Did you mean "author", "author_raw", "my_author"? "###); insta::assert_snapshot!(evaluate_err("my_branches"), @r###" diff --git a/docs/revsets.md b/docs/revsets.md index b7676ec561..e1a5327bd8 100644 --- a/docs/revsets.md +++ b/docs/revsets.md @@ -251,11 +251,17 @@ revsets (expressions) as arguments. * `author(pattern)`: Commits with the author's name or email matching the given [string pattern](#string-patterns). +* `author_raw(pattern)`: Like `author(pattern)`, but ignoring any mappings in + the [`.mailmap` file](https://git-scm.com/docs/gitmailmap). + * `mine()`: Commits where the author's email matches the email of the current user. -* `committer(pattern)`: Commits with the committer's name or email matching the -given [string pattern](#string-patterns). +* `committer(pattern)`: Commits with the committer's name or email matching the + given [string pattern](#string-patterns). + +* `committer_raw(pattern)`: Like `committer(pattern)`, but ignoring any + mappings in the [`.mailmap` file](https://git-scm.com/docs/gitmailmap). * `empty()`: Commits modifying no files. This also includes `merges()` without user modifications and `root()`. diff --git a/docs/templates.md b/docs/templates.md index 6317925514..5b4e441538 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -73,7 +73,11 @@ This type cannot be printed. The following methods are defined. * `commit_id() -> CommitId` * `parents() -> List` * `author() -> Signature` +* `author_raw() -> Signature`: Like `author()`, but ignoring any mappings in + the [`.mailmap` file](https://git-scm.com/docs/gitmailmap). * `committer() -> Signature` +* `committer_raw() -> Signature`: Like `committer()`, but ignoring any mappings + in the [`.mailmap` file](https://git-scm.com/docs/gitmailmap). * `mine() -> Boolean`: Commits where the author's email matches the email of the current user. * `working_copies() -> String`: For multi-workspace repository, indicate diff --git a/lib/src/commit.rs b/lib/src/commit.rs index 78d9934437..1bcd5d68a7 100644 --- a/lib/src/commit.rs +++ b/lib/src/commit.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use itertools::Itertools; use crate::backend::{self, BackendResult, ChangeId, CommitId, MergedTreeId, Signature}; +use crate::mailmap::Mailmap; use crate::merged_tree::MergedTree; use crate::repo::Repo; use crate::rewrite::merge_commit_trees; @@ -145,14 +146,22 @@ impl Commit { &self.data.description } - pub fn author(&self) -> &Signature { + pub fn author_raw(&self) -> &Signature { &self.data.author } - pub fn committer(&self) -> &Signature { + pub fn author(&self, mailmap: &Mailmap) -> Signature { + mailmap.resolve(&self.data.author) + } + + pub fn committer_raw(&self) -> &Signature { &self.data.committer } + pub fn committer(&self, mailmap: &Mailmap) -> Signature { + mailmap.resolve(&self.data.committer) + } + /// A commit is discardable if it has no change from its parent, and an /// empty description. pub fn is_discardable(&self, repo: &dyn Repo) -> BackendResult { @@ -193,8 +202,8 @@ pub(crate) struct CommitByCommitterTimestamp(pub Commit); impl Ord for CommitByCommitterTimestamp { fn cmp(&self, other: &Self) -> Ordering { - let self_timestamp = &self.0.committer().timestamp.timestamp; - let other_timestamp = &other.0.committer().timestamp.timestamp; + let self_timestamp = &self.0.committer_raw().timestamp.timestamp; + let other_timestamp = &other.0.committer_raw().timestamp.timestamp; self_timestamp .cmp(other_timestamp) .then_with(|| self.0.cmp(&other.0)) // to comply with Eq diff --git a/lib/src/default_index/revset_engine.rs b/lib/src/default_index/revset_engine.rs index 9afbbdd9bc..dda236f8be 100644 --- a/lib/src/default_index/revset_engine.rs +++ b/lib/src/default_index/revset_engine.rs @@ -965,7 +965,7 @@ impl<'index> EvaluationContext<'index> { let entry = self.index.entry_by_pos(pos); let commit = self.store.get_commit(&entry.commit_id()).unwrap(); Reverse(Item { - timestamp: commit.committer().timestamp.timestamp, + timestamp: commit.committer_raw().timestamp.timestamp, pos: entry.position(), }) }; @@ -1049,24 +1049,45 @@ fn build_predicate_fn( pattern.matches(commit.description()) }) } - RevsetFilterPredicate::Author(pattern) => { + RevsetFilterPredicate::Author(pattern, mailmap) => { let pattern = pattern.clone(); + let mailmap = mailmap.clone(); // TODO: Make these functions that take a needle to search for accept some // syntax for specifying whether it's a regex and whether it's // case-sensitive. box_pure_predicate_fn(move |index, pos| { let entry = index.entry_by_pos(pos); let commit = store.get_commit(&entry.commit_id()).unwrap(); - pattern.matches(&commit.author().name) || pattern.matches(&commit.author().email) + pattern.matches(&commit.author(&mailmap).name) + || pattern.matches(&commit.author(&mailmap).email) }) } - RevsetFilterPredicate::Committer(pattern) => { + RevsetFilterPredicate::AuthorRaw(pattern) => { let pattern = pattern.clone(); box_pure_predicate_fn(move |index, pos| { let entry = index.entry_by_pos(pos); let commit = store.get_commit(&entry.commit_id()).unwrap(); - pattern.matches(&commit.committer().name) - || pattern.matches(&commit.committer().email) + pattern.matches(&commit.author_raw().name) + || pattern.matches(&commit.author_raw().email) + }) + } + RevsetFilterPredicate::Committer(pattern, mailmap) => { + let pattern = pattern.clone(); + let mailmap = mailmap.clone(); + box_pure_predicate_fn(move |index, pos| { + let entry = index.entry_by_pos(pos); + let commit = store.get_commit(&entry.commit_id()).unwrap(); + pattern.matches(&commit.committer(&mailmap).name) + || pattern.matches(&commit.committer(&mailmap).email) + }) + } + RevsetFilterPredicate::CommitterRaw(pattern) => { + let pattern = pattern.clone(); + box_pure_predicate_fn(move |index, pos| { + let entry = index.entry_by_pos(pos); + let commit = store.get_commit(&entry.commit_id()).unwrap(); + pattern.matches(&commit.committer_raw().name) + || pattern.matches(&commit.committer_raw().email) }) } RevsetFilterPredicate::File(expr) => { diff --git a/lib/src/git_backend.rs b/lib/src/git_backend.rs index 3428897f84..2b484babd0 100644 --- a/lib/src/git_backend.rs +++ b/lib/src/git_backend.rs @@ -524,7 +524,7 @@ fn commit_from_git_without_root_parent( const EMPTY_STRING_PLACEHOLDER: &str = "JJ_EMPTY_STRING"; -fn signature_from_git(signature: gix::actor::SignatureRef) -> Signature { +pub(crate) fn signature_from_git(signature: gix::actor::SignatureRef) -> Signature { let name = signature.name; let name = if name != EMPTY_STRING_PLACEHOLDER { String::from_utf8_lossy(name).into_owned() @@ -549,7 +549,7 @@ fn signature_from_git(signature: gix::actor::SignatureRef) -> Signature { } } -fn signature_to_git(signature: &Signature) -> gix::actor::SignatureRef<'_> { +pub(crate) fn signature_to_git(signature: &Signature) -> gix::actor::SignatureRef<'_> { // git does not support empty names or emails let name = if !signature.name.is_empty() { &signature.name diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 149956ad2b..160b9fb4db 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -57,6 +57,7 @@ pub mod index; pub mod local_backend; pub mod local_working_copy; pub mod lock; +pub mod mailmap; pub mod matchers; pub mod merge; pub mod merged_tree; diff --git a/lib/src/mailmap.rs b/lib/src/mailmap.rs new file mode 100644 index 0000000000..f97b109fbf --- /dev/null +++ b/lib/src/mailmap.rs @@ -0,0 +1,93 @@ +// Copyright 2021 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Support for `.mailmap` files. + +use std::fmt::Debug; +use std::io::{self, Read}; + +use pollster::FutureExt; + +use crate::backend::Signature; +use crate::conflicts::{materialize_tree_value, MaterializedTreeValue}; +use crate::git_backend::{signature_from_git, signature_to_git}; +use crate::op_store::WorkspaceId; +use crate::repo::Repo; +use crate::repo_path::RepoPath; + +/// Models a `.mailmap` file, mapping email addresses and names to +/// canonical ones. +/// +/// The syntax and semantics are as described in +/// [`gitmailmap(5)`](https://git-scm.com/docs/gitmailmap). +#[derive(Clone, Default)] +pub struct Mailmap(gix::mailmap::Snapshot); + +impl Debug for Mailmap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Mailmap").field(&self.0.entries()).finish() + } +} + +impl PartialEq for Mailmap { + fn eq(&self, other: &Self) -> bool { + self.0.entries() == other.0.entries() + } +} + +impl Eq for Mailmap {} + +impl Mailmap { + /// Parses a `.mailmap` file, ignoring parse errors. + pub fn from_bytes(bytes: &[u8]) -> Self { + Self(gix::mailmap::Snapshot::from_bytes(bytes)) + } + + /// Reads and parses a `.mailmap` file from a reader, ignoring parse errors. + pub fn from_reader(reader: &mut R) -> io::Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + Ok(Self::from_bytes(&bytes)) + } + + /// Returns the canonical signature corresponding to `signature`. The + /// timestamp will not be modified. + pub fn resolve(&self, signature: &Signature) -> Signature { + self.0 + .try_resolve(signature_to_git(signature)) + .map(|resolved| signature_from_git(resolved.to_ref())) + .unwrap_or_else(|| signature.clone()) + } +} + +/// Reads and parses the `.mailmap` file from the working copy commit of the +/// specified workspace. +pub fn get_wc_commit_mailmap(repo: &dyn Repo, workspace_id: &WorkspaceId) -> Mailmap { + let inner = || { + let commit_id = repo.view().get_wc_commit_id(workspace_id)?; + let commit = repo.store().get_commit(commit_id).ok()?; + let tree = commit.tree().ok()?; + let path = RepoPath::from_internal_string(".mailmap"); + let value = tree.path_value(path).ok()?; + // We ignore symbolic links, as per `gitmailmap(5)`. + let materialized = materialize_tree_value(repo.store(), path, value) + .block_on() + .ok()?; + let MaterializedTreeValue::File { mut reader, .. } = materialized else { + return None; + }; + Mailmap::from_reader(&mut reader).ok() + }; + inner().unwrap_or_default() +} diff --git a/lib/src/revset.rs b/lib/src/revset.rs index ddf5dc397e..963eca3ad6 100644 --- a/lib/src/revset.rs +++ b/lib/src/revset.rs @@ -33,6 +33,7 @@ use crate::fileset::{FilePattern, FilesetExpression}; use crate::graph::GraphEdge; use crate::hex_util::to_forward_hex; use crate::id_prefix::IdPrefixContext; +use crate::mailmap::Mailmap; use crate::object_id::{HexPrefix, PrefixResolution}; use crate::op_store::WorkspaceId; use crate::repo::Repo; @@ -141,9 +142,13 @@ pub enum RevsetFilterPredicate { /// Commits with description containing the needle. Description(StringPattern), /// Commits with author's name or email containing the needle. - Author(StringPattern), + Author(StringPattern, Rc), + /// Commits with author's unmapped name or email containing the needle. + AuthorRaw(StringPattern), /// Commits with committer's name or email containing the needle. - Committer(StringPattern), + Committer(StringPattern, Rc), + /// Commits with committer's unmapped name or email containing the needle. + CommitterRaw(StringPattern), /// Commits modifying the paths specified by the fileset. File(FilesetExpression), /// Commits with conflicts @@ -691,26 +696,43 @@ static BUILTIN_FUNCTION_MAP: Lazy> = Lazy: RevsetFilterPredicate::Description(pattern), )) }); - map.insert("author", |function, _context| { + map.insert("author", |function, context| { let [arg] = function.expect_exact_arguments()?; let pattern = expect_string_pattern(arg)?; Ok(RevsetExpression::filter(RevsetFilterPredicate::Author( pattern, + context.mailmap.clone(), + ))) + }); + map.insert("author_raw", |function, _context| { + let [arg] = function.expect_exact_arguments()?; + let pattern = expect_string_pattern(arg)?; + Ok(RevsetExpression::filter(RevsetFilterPredicate::AuthorRaw( + pattern, ))) }); map.insert("mine", |function, context| { function.expect_no_arguments()?; Ok(RevsetExpression::filter(RevsetFilterPredicate::Author( StringPattern::Exact(context.user_email.to_owned()), + context.mailmap.clone(), ))) }); - map.insert("committer", |function, _context| { + map.insert("committer", |function, context| { let [arg] = function.expect_exact_arguments()?; let pattern = expect_string_pattern(arg)?; Ok(RevsetExpression::filter(RevsetFilterPredicate::Committer( pattern, + context.mailmap.clone(), ))) }); + map.insert("committer_raw", |function, _context| { + let [arg] = function.expect_exact_arguments()?; + let pattern = expect_string_pattern(arg)?; + Ok(RevsetExpression::filter( + RevsetFilterPredicate::CommitterRaw(pattern), + )) + }); map.insert("empty", |function, _context| { function.expect_no_arguments()?; Ok(RevsetExpression::is_empty()) @@ -1992,6 +2014,7 @@ pub struct RevsetParseContext<'a> { user_email: String, extensions: &'a RevsetExtensions, workspace: Option>, + mailmap: Rc, } impl<'a> RevsetParseContext<'a> { @@ -2000,12 +2023,14 @@ impl<'a> RevsetParseContext<'a> { user_email: String, extensions: &'a RevsetExtensions, workspace: Option>, + mailmap: Rc, ) -> Self { Self { aliases_map, user_email, extensions, workspace, + mailmap, } } @@ -2063,6 +2088,7 @@ mod tests { "test.user@example.com".to_string(), &extensions, None, + Default::default(), ); // Map error to comparable object super::parse(revset_str, &context).map_err(|e| e.kind) @@ -2092,6 +2118,7 @@ mod tests { "test.user@example.com".to_string(), &extensions, Some(workspace_ctx), + Default::default(), ); // Map error to comparable object super::parse(revset_str, &context).map_err(|e| e.kind) @@ -2117,6 +2144,7 @@ mod tests { "test.user@example.com".to_string(), &extensions, None, + Default::default(), ); // Map error to comparable object super::parse_with_modifier(revset_str, &context).map_err(|e| e.kind) @@ -2296,6 +2324,7 @@ mod tests { parse(r#"author("foo@")"#), Ok(RevsetExpression::filter(RevsetFilterPredicate::Author( StringPattern::Substring("foo@".to_string()), + Default::default(), ))) ); // Parse a single symbol @@ -2456,7 +2485,8 @@ mod tests { assert_eq!( parse("mine()"), Ok(RevsetExpression::filter(RevsetFilterPredicate::Author( - StringPattern::Exact("test.user@example.com".to_string()) + StringPattern::Exact("test.user@example.com".to_string()), + Default::default(), ))) ); assert_eq!( @@ -3047,6 +3077,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), ) @@ -3068,6 +3101,9 @@ mod tests { Substring( "bar", ), + Mailmap( + [], + ), ), ), ) @@ -3089,6 +3125,9 @@ mod tests { Substring( "bar", ), + Mailmap( + [], + ), ), ), CommitRef( @@ -3117,6 +3156,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), ) @@ -3131,6 +3173,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ) "###); @@ -3163,6 +3208,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), ) @@ -3175,6 +3223,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), Filter( @@ -3182,6 +3233,9 @@ mod tests { Substring( "bar", ), + Mailmap( + [], + ), ), ), ) @@ -3209,6 +3263,9 @@ mod tests { Substring( "baz", ), + Mailmap( + [], + ), ), ), ) @@ -3227,6 +3284,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), ), @@ -3235,6 +3295,9 @@ mod tests { Substring( "baz", ), + Mailmap( + [], + ), ), ), ) @@ -3253,6 +3316,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), ), @@ -3276,6 +3342,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), Filter( @@ -3293,6 +3362,9 @@ mod tests { Substring( "baz", ), + Mailmap( + [], + ), ), ), ) @@ -3352,6 +3424,9 @@ mod tests { Substring( "baz", ), + Mailmap( + [], + ), ), ), ) @@ -3372,6 +3447,9 @@ mod tests { Substring( "baz", ), + Mailmap( + [], + ), ), ), generation: 1..2, @@ -3413,6 +3491,9 @@ mod tests { Substring( "baz", ), + Mailmap( + [], + ), ), ), ), @@ -3459,6 +3540,9 @@ mod tests { Substring( "A", ), + Mailmap( + [], + ), ), ), ), @@ -3467,6 +3551,9 @@ mod tests { Substring( "B", ), + Mailmap( + [], + ), ), ), ), @@ -3475,6 +3562,9 @@ mod tests { Substring( "C", ), + Mailmap( + [], + ), ), ), ) @@ -3516,6 +3606,9 @@ mod tests { Substring( "A", ), + Mailmap( + [], + ), ), ), ), @@ -3524,6 +3617,9 @@ mod tests { Substring( "B", ), + Mailmap( + [], + ), ), ), ), @@ -3532,6 +3628,9 @@ mod tests { Substring( "C", ), + Mailmap( + [], + ), ), ), ) @@ -3561,6 +3660,9 @@ mod tests { Substring( "baz", ), + Mailmap( + [], + ), ), ), ) @@ -3584,6 +3686,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), CommitRef( @@ -3617,6 +3722,9 @@ mod tests { Substring( "bar", ), + Mailmap( + [], + ), ), ), ), @@ -3657,6 +3765,9 @@ mod tests { Substring( "foo", ), + Mailmap( + [], + ), ), ), ), @@ -3708,6 +3819,9 @@ mod tests { Substring( "A", ), + Mailmap( + [], + ), ), ), CommitRef( @@ -3725,6 +3839,9 @@ mod tests { Substring( "B", ), + Mailmap( + [], + ), ), ), CommitRef( @@ -3742,6 +3859,9 @@ mod tests { Substring( "C", ), + Mailmap( + [], + ), ), ), CommitRef( diff --git a/lib/tests/test_commit_builder.rs b/lib/tests/test_commit_builder.rs index 7ac52c69b1..7b79303494 100644 --- a/lib/tests/test_commit_builder.rs +++ b/lib/tests/test_commit_builder.rs @@ -82,8 +82,8 @@ fn test_initial(backend: TestRepoBackend) { assert_eq!(parents, vec![store.root_commit()]); assert!(commit.predecessors().next().is_none()); assert_eq!(commit.description(), "description"); - assert_eq!(commit.author(), &author_signature); - assert_eq!(commit.committer(), &committer_signature); + assert_eq!(commit.author_raw(), &author_signature); + assert_eq!(commit.committer_raw(), &committer_signature); assert_eq!( store .root_commit() @@ -158,14 +158,14 @@ fn test_rewrite(backend: TestRepoBackend) { assert_eq!(parents, vec![store.root_commit()]); let predecessors: Vec<_> = rewritten_commit.predecessors().try_collect().unwrap(); assert_eq!(predecessors, vec![initial_commit.clone()]); - assert_eq!(rewritten_commit.author().name, settings.user_name()); - assert_eq!(rewritten_commit.author().email, settings.user_email()); + assert_eq!(rewritten_commit.author_raw().name, settings.user_name()); + assert_eq!(rewritten_commit.author_raw().email, settings.user_email()); assert_eq!( - rewritten_commit.committer().name, + rewritten_commit.committer_raw().name, rewrite_settings.user_name() ); assert_eq!( - rewritten_commit.committer().email, + rewritten_commit.committer_raw().email, rewrite_settings.user_email() ); assert_eq!( @@ -214,10 +214,10 @@ fn test_rewrite_update_missing_user(backend: TestRepoBackend) { ) .write() .unwrap(); - assert_eq!(initial_commit.author().name, ""); - assert_eq!(initial_commit.author().email, ""); - assert_eq!(initial_commit.committer().name, ""); - assert_eq!(initial_commit.committer().email, ""); + assert_eq!(initial_commit.author_raw().name, ""); + assert_eq!(initial_commit.author_raw().email, ""); + assert_eq!(initial_commit.committer_raw().name, ""); + assert_eq!(initial_commit.committer_raw().email, ""); let config = config::Config::builder() .set_override("user.name", "Configured User") @@ -233,14 +233,14 @@ fn test_rewrite_update_missing_user(backend: TestRepoBackend) { .write() .unwrap(); - assert_eq!(rewritten_commit.author().name, "Configured User"); + assert_eq!(rewritten_commit.author_raw().name, "Configured User"); assert_eq!( - rewritten_commit.author().email, + rewritten_commit.author_raw().email, "configured.user@example.com" ); - assert_eq!(rewritten_commit.committer().name, "Configured User"); + assert_eq!(rewritten_commit.committer_raw().name, "Configured User"); assert_eq!( - rewritten_commit.committer().email, + rewritten_commit.committer_raw().email, "configured.user@example.com" ); } diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index 3f8a98a8cb..6594dd0c48 100644 --- a/lib/tests/test_git.rs +++ b/lib/tests/test_git.rs @@ -3121,8 +3121,8 @@ fn test_rewrite_imported_commit() { imported_commit.parent_ids().to_vec(), imported_commit.tree_id().clone(), ) - .set_author(imported_commit.author().clone()) - .set_committer(imported_commit.committer().clone()) + .set_author(imported_commit.author_raw().clone()) + .set_committer(imported_commit.committer_raw().clone()) .set_description(imported_commit.description()) .write() .unwrap(); @@ -3132,8 +3132,8 @@ fn test_rewrite_imported_commit() { // commit should be adjusted to create new commit. assert_ne!(imported_commit.id(), authored_commit.id()); assert_ne!( - imported_commit.committer().timestamp, - authored_commit.committer().timestamp, + imported_commit.committer_raw().timestamp, + authored_commit.committer_raw().timestamp, ); // The index should be consistent with the store. diff --git a/lib/tests/test_init.rs b/lib/tests/test_init.rs index 23ca072215..ee4aa018a1 100644 --- a/lib/tests/test_init.rs +++ b/lib/tests/test_init.rs @@ -150,10 +150,10 @@ fn test_init_no_config_set(backend: TestRepoBackend) { .get_wc_commit_id(&WorkspaceId::default()) .unwrap(); let wc_commit = repo.store().get_commit(wc_commit_id).unwrap(); - assert_eq!(wc_commit.author().name, "".to_string()); - assert_eq!(wc_commit.author().email, "".to_string()); - assert_eq!(wc_commit.committer().name, "".to_string()); - assert_eq!(wc_commit.committer().email, "".to_string()); + assert_eq!(wc_commit.author_raw().name, "".to_string()); + assert_eq!(wc_commit.author_raw().email, "".to_string()); + assert_eq!(wc_commit.committer_raw().name, "".to_string()); + assert_eq!(wc_commit.committer_raw().email, "".to_string()); } #[test_case(TestRepoBackend::Local ; "local backend")] @@ -175,8 +175,8 @@ fn test_init_checkout(backend: TestRepoBackend) { ); assert!(wc_commit.predecessors().next().is_none()); assert_eq!(wc_commit.description(), ""); - assert_eq!(wc_commit.author().name, settings.user_name()); - assert_eq!(wc_commit.author().email, settings.user_email()); - assert_eq!(wc_commit.committer().name, settings.user_name()); - assert_eq!(wc_commit.committer().email, settings.user_email()); + assert_eq!(wc_commit.author_raw().name, settings.user_name()); + assert_eq!(wc_commit.author_raw().email, settings.user_email()); + assert_eq!(wc_commit.committer_raw().name, settings.user_name()); + assert_eq!(wc_commit.committer_raw().email, settings.user_email()); } diff --git a/lib/tests/test_revset.rs b/lib/tests/test_revset.rs index 8341837ff1..97301d2812 100644 --- a/lib/tests/test_revset.rs +++ b/lib/tests/test_revset.rs @@ -13,6 +13,7 @@ // limitations under the License. use std::path::Path; +use std::rc::Rc; use assert_matches::assert_matches; use itertools::Itertools; @@ -22,6 +23,7 @@ use jj_lib::fileset::FilesetExpression; use jj_lib::git; use jj_lib::git_backend::GitBackend; use jj_lib::graph::{GraphEdge, ReverseGraphIterator}; +use jj_lib::mailmap::{get_wc_commit_mailmap, Mailmap}; use jj_lib::object_id::ObjectId; use jj_lib::op_store::{RefTarget, RemoteRef, RemoteRefState, WorkspaceId}; use jj_lib::repo::Repo; @@ -45,7 +47,13 @@ fn resolve_symbol_with_extensions( symbol: &str, ) -> Result, RevsetResolutionError> { let aliases_map = RevsetAliasesMap::default(); - let context = RevsetParseContext::new(&aliases_map, String::new(), extensions, None); + let context = RevsetParseContext::new( + &aliases_map, + String::new(), + extensions, + None, + Default::default(), + ); let expression = parse(symbol, &context).unwrap(); assert_matches!(*expression, RevsetExpression::CommitRef(_)); let symbol_resolver = DefaultSymbolResolver::new(repo, extensions.symbol_resolvers()); @@ -179,7 +187,13 @@ fn test_resolve_symbol_commit_id() { ); let aliases_map = RevsetAliasesMap::default(); let extensions = RevsetExtensions::default(); - let context = RevsetParseContext::new(&aliases_map, settings.user_email(), &extensions, None); + let context = RevsetParseContext::new( + &aliases_map, + settings.user_email(), + &extensions, + None, + Default::default(), + ); assert_matches!( optimize(parse("present(04)", &context).unwrap()).resolve_user_expression(repo.as_ref(), &symbol_resolver), Err(RevsetResolutionError::AmbiguousCommitIdPrefix(s)) if s == "04" @@ -830,7 +844,11 @@ fn test_resolve_symbol_git_refs() { ); } -fn resolve_commit_ids(repo: &dyn Repo, revset_str: &str) -> Vec { +fn resolve_commit_ids_with_mailmap( + repo: &dyn Repo, + mailmap_source: &str, + revset_str: &str, +) -> Vec { let settings = testutils::user_settings(); let aliases_map = RevsetAliasesMap::default(); let revset_extensions = RevsetExtensions::default(); @@ -839,6 +857,7 @@ fn resolve_commit_ids(repo: &dyn Repo, revset_str: &str) -> Vec { settings.user_email(), &revset_extensions, None, + Rc::new(Mailmap::from_bytes(mailmap_source.as_bytes())), ); let expression = optimize(parse(revset_str, &context).unwrap()); let symbol_resolver = DefaultSymbolResolver::new(repo, revset_extensions.symbol_resolvers()); @@ -848,6 +867,10 @@ fn resolve_commit_ids(repo: &dyn Repo, revset_str: &str) -> Vec { expression.evaluate(repo).unwrap().iter().collect() } +fn resolve_commit_ids(repo: &dyn Repo, revset_str: &str) -> Vec { + resolve_commit_ids_with_mailmap(repo, "", revset_str) +} + fn resolve_commit_ids_in_workspace( repo: &dyn Repo, revset_str: &str, @@ -865,11 +888,13 @@ fn resolve_commit_ids_in_workspace( }; let aliases_map = RevsetAliasesMap::default(); let extensions = RevsetExtensions::default(); + let mailmap = Rc::new(get_wc_commit_mailmap(repo, workspace_ctx.workspace_id)); let context = RevsetParseContext::new( &aliases_map, settings.user_email(), &extensions, Some(workspace_ctx), + mailmap, ); let expression = optimize(parse(revset_str, &context).unwrap()); let symbol_resolver = @@ -2411,6 +2436,20 @@ fn test_evaluate_expression_author() { ), vec![commit3.id().clone(), commit1.id().clone()] ); + // Signatures are treated as their mailmapped forms + let mailmap = "nameone "; + assert_eq!( + resolve_commit_ids_with_mailmap(mut_repo, mailmap, "author(\"nameone\")"), + vec![commit1.id().clone()] + ); + assert_eq!( + resolve_commit_ids_with_mailmap(mut_repo, mailmap, "author(\"name1\")"), + vec![] + ); + assert_eq!( + resolve_commit_ids_with_mailmap(mut_repo, mailmap, "author_raw(\"name1\")"), + vec![commit1.id().clone()] + ); } #[test] @@ -2479,6 +2518,16 @@ fn test_evaluate_expression_mine() { commit1.id().clone() ] ); + // Signatures are treated as their mailmapped forms + let user_email = settings.user_email(); + assert_eq!( + resolve_commit_ids_with_mailmap( + mut_repo, + format!("<{user_email}> \n name2 <{user_email}>").as_ref(), + "mine()" + ), + vec![commit3.id().clone(), commit1.id().clone()] + ); } #[test] @@ -2544,6 +2593,20 @@ fn test_evaluate_expression_committer() { resolve_commit_ids(mut_repo, "visible_heads() & committer(\"name2\")"), vec![] ); + // Signatures are treated as their mailmapped forms + let mailmap = "nameone "; + assert_eq!( + resolve_commit_ids_with_mailmap(mut_repo, mailmap, "committer(\"nameone\")"), + vec![commit1.id().clone()] + ); + assert_eq!( + resolve_commit_ids_with_mailmap(mut_repo, mailmap, "committer(\"name1\")"), + vec![] + ); + assert_eq!( + resolve_commit_ids_with_mailmap(mut_repo, mailmap, "committer_raw(\"name1\")"), + vec![commit1.id().clone()] + ); } #[test]