diff --git a/CHANGELOG.md b/CHANGELOG.md index 14640d57d..2ea5ed48e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This simplifies the use case of configuring code formatters for specific file types. See `jj help fix` for details. +* Added revset functions `author_date` and `committer_date`. + ### Fixed bugs * `jj status` will show different messages in a conflicted tree, depending diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index e749c38eb..bd0490800 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -28,6 +28,7 @@ use std::time::SystemTime; use std::{fs, mem, str}; use bstr::ByteVec as _; +use chrono::TimeZone; use clap::builder::{ MapValueParser, NonEmptyStringValueParser, TypedValueParser, ValueParserFactory, }; @@ -1010,9 +1011,17 @@ impl WorkspaceCommandHelper { path_converter: &self.path_converter, workspace_id: self.workspace_id(), }; + let now = if let Some(timestamp) = self.settings.commit_timestamp() { + chrono::Local + .timestamp_millis_opt(timestamp.timestamp.0) + .unwrap() + } else { + chrono::Local::now() + }; RevsetParseContext::new( &self.revset_aliases_map, self.settings.user_email(), + now.into(), &self.revset_extensions, Some(workspace_context), ) diff --git a/cli/tests/test_revset_output.rs b/cli/tests/test_revset_output.rs index 315e5b4c1..e30bdf1e1 100644 --- a/cli/tests/test_revset_output.rs +++ b/cli/tests/test_revset_output.rs @@ -325,7 +325,7 @@ fn test_function_name_hint() { | ^-----^ | = Function "author_" doesn't exist - Hint: Did you mean "author", "my_author"? + Hint: Did you mean "author", "author_date", "my_author"? "###); insta::assert_snapshot!(evaluate_err("my_branches"), @r###" @@ -629,3 +629,126 @@ fn test_all_modifier() { For help, see https://github.com/martinvonz/jj/blob/main/docs/config.md. "###); } + +/// Verifies that the committer_date revset honors the local time zone. +/// This test cannot run on Windows because The TZ env var does not control +/// chrono::Local on that platform. +#[test] +#[cfg(not(target_os = "windows"))] +fn test_revset_committer_date_with_time_zone() { + // Use these for the test instead of tzdb identifiers like America/New_York + // because the tz database may not be installed on some build servers + const NEW_YORK: &str = "EST+5EDT+4,M3.1.0,M11.1.0"; + const CHICAGO: &str = "CST+6CDT+5,M3.1.0,M11.1.0"; + const AUSTRALIA: &str = "AEST-10"; + let mut test_env = TestEnvironment::default(); + test_env.add_env_var("TZ", NEW_YORK); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + test_env.jj_cmd_ok( + &repo_path, + &[ + "--config-toml", + "debug.commit-timestamp='2023-01-25T11:30:00-05:00'", + "describe", + "-m", + "first", + ], + ); + test_env.jj_cmd_ok( + &repo_path, + &[ + "--config-toml", + "debug.commit-timestamp='2023-01-25T12:30:00-05:00'", + "new", + "-m", + "second", + ], + ); + test_env.jj_cmd_ok( + &repo_path, + &[ + "--config-toml", + "debug.commit-timestamp='2023-01-25T13:30:00-05:00'", + "new", + "-m", + "third", + ], + ); + + let mut log_commits_before_and_after = + |committer_date: &str, now: &str, tz: &str| -> (String, String) { + test_env.add_env_var("TZ", tz); + let config = format!("debug.commit-timestamp='{now}'"); + let before_log = test_env.jj_cmd_success( + &repo_path, + &[ + "--config-toml", + config.as_str(), + "log", + "--no-graph", + "-T", + "description.first_line() ++ ' ' ++ committer.timestamp() ++ '\n'", + "-r", + format!("committer_date(before:'{committer_date}') ~ root()").as_str(), + ], + ); + let after_log = test_env.jj_cmd_success( + &repo_path, + &[ + "--config-toml", + config.as_str(), + "log", + "--no-graph", + "-T", + "description.first_line() ++ ' ' ++ committer.timestamp() ++ '\n'", + "-r", + format!("committer_date(after:'{committer_date}')").as_str(), + ], + ); + (before_log, after_log) + }; + + let (before_log, after_log) = + log_commits_before_and_after("2023-01-25 12:00", "2023-02-01T00:00:00-05:00", NEW_YORK); + insta::assert_snapshot!(before_log, @r###" + first 2023-01-25 11:30:00.000 -05:00 + "###); + insta::assert_snapshot!(after_log, @r###" + third 2023-01-25 13:30:00.000 -05:00 + second 2023-01-25 12:30:00.000 -05:00 + "###); + + // Switch to DST and ensure we get the same results, because it should + // evaluate 12:00 on commit date, not the current date + let (before_log, after_log) = + log_commits_before_and_after("2023-01-25 12:00", "2023-06-01T00:00:00-04:00", NEW_YORK); + insta::assert_snapshot!(before_log, @r###" + first 2023-01-25 11:30:00.000 -05:00 + "###); + insta::assert_snapshot!(after_log, @r###" + third 2023-01-25 13:30:00.000 -05:00 + second 2023-01-25 12:30:00.000 -05:00 + "###); + + // Change the local time zone and ensure the result changes + let (before_log, after_log) = + log_commits_before_and_after("2023-01-25 12:00", "2023-06-01T00:00:00-06:00", CHICAGO); + insta::assert_snapshot!(before_log, @r###" + second 2023-01-25 12:30:00.000 -05:00 + first 2023-01-25 11:30:00.000 -05:00 + "###); + insta::assert_snapshot!(after_log, @"third 2023-01-25 13:30:00.000 -05:00"); + + // Time zone far outside USA with no DST + let (before_log, after_log) = + log_commits_before_and_after("2023-01-26 03:00", "2023-06-01T00:00:00+10:00", AUSTRALIA); + insta::assert_snapshot!(before_log, @r###" + first 2023-01-25 11:30:00.000 -05:00 + "###); + insta::assert_snapshot!(after_log, @r###" + third 2023-01-25 13:30:00.000 -05:00 + second 2023-01-25 12:30:00.000 -05:00 + "###); +} diff --git a/docs/revsets.md b/docs/revsets.md index 4b7dcbca4..2b99e9689 100644 --- a/docs/revsets.md +++ b/docs/revsets.md @@ -265,6 +265,12 @@ revsets (expressions) as arguments. * `committer(pattern)`: Commits with the committer's name or email matching the given [string pattern](#string-patterns). +* `author_date(pattern)`: Commits with author dates matching the specified [date + pattern](#date-patterns). + +* `committer_date(pattern)`: Commits with committer dates matching the specified + [date pattern](#date-patterns). + * `empty()`: Commits modifying no files. This also includes `merges()` without user modifications and `root()`. @@ -359,6 +365,26 @@ Functions that perform string matching support the following pattern syntax: You can append `-i` after the kind to match case‐insensitively (e.g. `glob-i:"fix*jpeg*"`). +## Date patterns + +Functions that perform date matching support the following pattern syntax: + +* `after:"string"`: Matches dates exactly at or after the given date. +* `before:"string"`: Matches dates before, but not including, the given date. + +Date strings can be specified in several forms, including: + +* 2024-02-01 +* 2024-02-01T12:00:00 +* 2024-02-01T12:00:00-08:00 +* 2024-02-01 12:00:00 +* 2 days ago +* 5 minutes ago +* yesterday +* yesterday 5pm +* yesterday 10:30 +* yesterday 15:30 + ## Aliases New symbols and functions can be defined in the config file, by using any diff --git a/lib/src/default_index/revset_engine.rs b/lib/src/default_index/revset_engine.rs index c4d68bb67..39199564a 100644 --- a/lib/src/default_index/revset_engine.rs +++ b/lib/src/default_index/revset_engine.rs @@ -1074,6 +1074,24 @@ fn build_predicate_fn( || pattern.matches(&commit.committer().email) }) } + RevsetFilterPredicate::AuthorDate(expression) => { + let expression = *expression; + box_pure_predicate_fn(move |index, pos| { + let entry = index.entry_by_pos(pos); + let commit = store.get_commit(&entry.commit_id()).unwrap(); + let author_date = &commit.author().timestamp; + expression.matches(author_date) + }) + } + RevsetFilterPredicate::CommitterDate(expression) => { + let expression = *expression; + box_pure_predicate_fn(move |index, pos| { + let entry = index.entry_by_pos(pos); + let commit = store.get_commit(&entry.commit_id()).unwrap(); + let committer_date = &commit.committer().timestamp; + expression.matches(committer_date) + }) + } RevsetFilterPredicate::File(expr) => { let matcher: Rc = expr.to_matcher().into(); box_pure_predicate_fn(move |index, pos| { diff --git a/lib/src/revset.rs b/lib/src/revset.rs index afee11cfe..2ab7a045e 100644 --- a/lib/src/revset.rs +++ b/lib/src/revset.rs @@ -43,6 +43,7 @@ pub use crate::revset_parser::{ }; use crate::store::Store; use crate::str_util::StringPattern; +use crate::time_util::{DatePattern, DatePatternContext}; use crate::{dsl_util, fileset, revset_parser}; /// Error occurred during symbol resolution. @@ -132,6 +133,10 @@ pub enum RevsetFilterPredicate { Author(StringPattern), /// Commits with committer name or email matching the pattern. Committer(StringPattern), + /// Commits with author dates matching the given date pattern. + AuthorDate(DatePattern), + /// Commits with committer dates matching the given date pattern. + CommitterDate(DatePattern), /// Commits modifying the paths specified by the fileset. File(FilesetExpression), /// Commits containing diffs matching the `text` pattern within the `files`. @@ -684,6 +689,13 @@ static BUILTIN_FUNCTION_MAP: Lazy> = Lazy: pattern, ))) }); + map.insert("author_date", |function, context| { + let [arg] = function.expect_exact_arguments()?; + let pattern = expect_date_pattern(arg, context.date_pattern_context())?; + Ok(RevsetExpression::filter(RevsetFilterPredicate::AuthorDate( + pattern, + ))) + }); map.insert("mine", |function, context| { function.expect_no_arguments()?; // Email address domains are inherently case‐insensitive, and the local‐parts @@ -700,6 +712,13 @@ static BUILTIN_FUNCTION_MAP: Lazy> = Lazy: pattern, ))) }); + map.insert("committer_date", |function, context| { + let [arg] = function.expect_exact_arguments()?; + let pattern = expect_date_pattern(arg, context.date_pattern_context())?; + Ok(RevsetExpression::filter( + RevsetFilterPredicate::CommitterDate(pattern), + )) + }); map.insert("empty", |function, _context| { function.expect_no_arguments()?; Ok(RevsetExpression::is_empty()) @@ -774,6 +793,20 @@ pub fn expect_string_pattern(node: &ExpressionNode) -> Result Result { + let parse_pattern = + |value: &str, kind: Option<&str>| -> Result<_, Box> { + match kind { + None => Err("Date pattern must specify 'after' or 'before'".into()), + Some(kind) => Ok(context.parse_relative(value, kind)?), + } + }; + revset_parser::expect_pattern_with("date pattern", node, parse_pattern) +} + fn parse_remote_branches_arguments( function: &FunctionCallNode, remote_ref_state: Option, @@ -2035,6 +2068,7 @@ impl RevsetExtensions { pub struct RevsetParseContext<'a> { aliases_map: &'a RevsetAliasesMap, user_email: String, + date_pattern_context: DatePatternContext, extensions: &'a RevsetExtensions, workspace: Option>, } @@ -2043,12 +2077,14 @@ impl<'a> RevsetParseContext<'a> { pub fn new( aliases_map: &'a RevsetAliasesMap, user_email: String, + date_pattern_context: DatePatternContext, extensions: &'a RevsetExtensions, workspace: Option>, ) -> Self { Self { aliases_map, user_email, + date_pattern_context, extensions, workspace, } @@ -2062,6 +2098,10 @@ impl<'a> RevsetParseContext<'a> { &self.user_email } + pub fn date_pattern_context(&self) -> &DatePatternContext { + &self.date_pattern_context + } + pub fn symbol_resolvers(&self) -> &[impl AsRef] { self.extensions.symbol_resolvers() } @@ -2105,6 +2145,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now().fixed_offset().into(), &extensions, None, ); @@ -2133,6 +2174,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now().fixed_offset().into(), &extensions, Some(workspace_ctx), ); @@ -2157,6 +2199,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now().fixed_offset().into(), &extensions, None, ); diff --git a/lib/src/settings.rs b/lib/src/settings.rs index 6ee26b92a..75bc35867 100644 --- a/lib/src/settings.rs +++ b/lib/src/settings.rs @@ -172,6 +172,10 @@ impl UserSettings { // address pub const USER_EMAIL_PLACEHOLDER: &'static str = "(no email configured)"; + pub fn commit_timestamp(&self) -> Option { + self.timestamp.to_owned() + } + pub fn operation_timestamp(&self) -> Option { get_timestamp_config(&self.config, "debug.operation-timestamp") } diff --git a/lib/tests/test_revset.rs b/lib/tests/test_revset.rs index 43f3fb75f..2e5ed958b 100644 --- a/lib/tests/test_revset.rs +++ b/lib/tests/test_revset.rs @@ -16,6 +16,7 @@ use std::iter; use std::path::Path; use assert_matches::assert_matches; +use chrono::DateTime; use itertools::Itertools; use jj_lib::backend::{CommitId, MillisSinceEpoch, Signature, Timestamp}; use jj_lib::commit::Commit; @@ -46,7 +47,9 @@ 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 now = chrono::Local::now(); + let context = + RevsetParseContext::new(&aliases_map, String::new(), now.into(), extensions, None); let expression = parse(symbol, &context).unwrap(); assert_matches!(*expression, RevsetExpression::CommitRef(_)); let symbol_resolver = DefaultSymbolResolver::new(repo, extensions.symbol_resolvers()); @@ -180,7 +183,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(), + chrono::Utc::now().fixed_offset().into(), + &extensions, + None, + ); assert_matches!( optimize(parse("present(04)", &context).unwrap()).resolve_user_expression(repo.as_ref(), &symbol_resolver), Err(RevsetResolutionError::AmbiguousCommitIdPrefix(s)) if s == "04" @@ -838,6 +847,7 @@ fn resolve_commit_ids(repo: &dyn Repo, revset_str: &str) -> Vec { let context = RevsetParseContext::new( &aliases_map, settings.user_email(), + chrono::Utc::now().fixed_offset().into(), &revset_extensions, None, ); @@ -869,6 +879,7 @@ fn resolve_commit_ids_in_workspace( let context = RevsetParseContext::new( &aliases_map, settings.user_email(), + chrono::Utc::now().fixed_offset().into(), &extensions, Some(workspace_ctx), ); @@ -2478,6 +2489,144 @@ fn test_evaluate_expression_author() { ); } +fn parse_timestamp(s: &str) -> Timestamp { + Timestamp::from_datetime(s.parse::>().unwrap()) +} + +#[test] +fn test_evaluate_expression_author_date() { + let settings = testutils::user_settings(); + let test_repo = TestRepo::init(); + let repo = &test_repo.repo; + + let mut tx = repo.start_transaction(&settings); + let mut_repo = tx.mut_repo(); + + let timestamp1 = parse_timestamp("2023-03-25T11:30:00Z"); + let timestamp2 = parse_timestamp("2023-03-25T12:30:00Z"); + let timestamp3 = parse_timestamp("2023-03-25T13:30:00Z"); + + let root_commit = repo.store().root_commit(); + let commit1 = create_random_commit(mut_repo, &settings) + .set_author(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp1.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp2.clone(), + }) + .write() + .unwrap(); + let commit2 = create_random_commit(mut_repo, &settings) + .set_parents(vec![commit1.id().clone()]) + .set_author(Signature { + name: "name2".to_string(), + email: "email2".to_string(), + timestamp: timestamp2.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp2.clone(), + }) + .write() + .unwrap(); + let commit3 = create_random_commit(mut_repo, &settings) + .set_parents(vec![commit2.id().clone()]) + .set_author(Signature { + name: "name3".to_string(), + email: "email3".to_string(), + timestamp: timestamp3, + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp2.clone(), + }) + .write() + .unwrap(); + + // Can find multiple matches + assert_eq!( + resolve_commit_ids(mut_repo, "author_date(after:'2023-03-25 12:00')"), + vec![commit3.id().clone(), commit2.id().clone()] + ); + assert_eq!( + resolve_commit_ids(mut_repo, "author_date(before:'2023-03-25 12:00')"), + vec![commit1.id().clone(), root_commit.id().clone()] + ); +} + +#[test] +fn test_evaluate_expression_committer_date() { + let settings = testutils::user_settings(); + let test_repo = TestRepo::init(); + let repo = &test_repo.repo; + + let mut tx = repo.start_transaction(&settings); + let mut_repo = tx.mut_repo(); + + let timestamp1 = parse_timestamp("2023-03-25T11:30:00Z"); + let timestamp2 = parse_timestamp("2023-03-25T12:30:00Z"); + let timestamp3 = parse_timestamp("2023-03-25T13:30:00Z"); + + let root_commit = repo.store().root_commit(); + let commit1 = create_random_commit(mut_repo, &settings) + .set_author(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp2.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp1.clone(), + }) + .write() + .unwrap(); + let commit2 = create_random_commit(mut_repo, &settings) + .set_parents(vec![commit1.id().clone()]) + .set_author(Signature { + name: "name2".to_string(), + email: "email2".to_string(), + timestamp: timestamp2.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp2.clone(), + }) + .write() + .unwrap(); + let commit3 = create_random_commit(mut_repo, &settings) + .set_parents(vec![commit2.id().clone()]) + .set_author(Signature { + name: "name3".to_string(), + email: "email3".to_string(), + timestamp: timestamp2.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp3, + }) + .write() + .unwrap(); + + // Can find multiple matches + assert_eq!( + resolve_commit_ids(mut_repo, "committer_date(after:'2023-03-25 12:00')"), + vec![commit3.id().clone(), commit2.id().clone()] + ); + assert_eq!( + resolve_commit_ids(mut_repo, "committer_date(before:'2023-03-25 12:00')"), + vec![commit1.id().clone(), root_commit.id().clone()] + ); +} + #[test] fn test_evaluate_expression_mine() { let settings = testutils::user_settings();