diff --git a/CHANGELOG.md b/CHANGELOG.md index ace2489aa8..d68fcbe694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). address unconditionally. Only ASCII case folding is currently implemented, but this will likely change in the future. +* Added revset functions `author_date` and `committer_date`. + ### Fixed bugs ## [0.19.0] - 2024-07-03 diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index 08f54cd23e..b1112cb750 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -994,6 +994,7 @@ impl WorkspaceCommandHelper { RevsetParseContext::new( &self.revset_aliases_map, self.settings.user_email(), + chrono::Local::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 766f84f6ef..a81f26121a 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_date", "my_author"? "###); insta::assert_snapshot!(evaluate_err("my_branches"), @r###" diff --git a/docs/revsets.md b/docs/revsets.md index 8bebbac8c9..9829516266 100644 --- a/docs/revsets.md +++ b/docs/revsets.md @@ -257,6 +257,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()`. @@ -336,6 +342,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 76a495222f..fea8383f27 100644 --- a/lib/src/default_index/revset_engine.rs +++ b/lib/src/default_index/revset_engine.rs @@ -1068,6 +1068,24 @@ fn build_predicate_fn( || pattern.matches(&commit.committer().email) }) } + RevsetFilterPredicate::AuthorDate(expression) => { + let expression = expression.clone(); + 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.clone(); + 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 d88a584d4b..42cc73eddb 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, revset_parser}; /// Error occurred during symbol resolution. @@ -131,6 +132,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 with conflicts @@ -685,6 +690,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 @@ -701,6 +713,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()) @@ -752,6 +771,22 @@ pub fn expect_string_pattern(node: &ExpressionNode) -> Result Result { + revset_parser::expect_pattern_with("date pattern", node, |value, kind| match kind { + None => Err(RevsetParseError::expression( + "Date pattern must specify 'after' or 'before'", + node.span, + )), + Some(kind) => context.to_date_pattern(value, kind).map_err(|err| { + RevsetParseError::expression(format!("Unable to parse date pattern: {err}"), node.span) + .with_source(err) + }), + }) +} + /// Resolves function call by using the given function map. fn lower_function_call( function: &FunctionCallNode, @@ -1980,6 +2015,8 @@ impl RevsetExtensions { pub struct RevsetParseContext<'a> { aliases_map: &'a RevsetAliasesMap, user_email: String, + /// The current local time when the revset expression was written. + date_pattern_context: DatePatternContext, extensions: &'a RevsetExtensions, workspace: Option>, } @@ -1988,12 +2025,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, } @@ -2007,6 +2046,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() } @@ -2050,6 +2093,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now().into(), &extensions, None, ); @@ -2078,6 +2122,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now().into(), &extensions, Some(workspace_ctx), ); @@ -2102,6 +2147,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now().into(), &extensions, None, ); diff --git a/lib/tests/test_revset.rs b/lib/tests/test_revset.rs index 86ca9260d0..b45c4840be 100644 --- a/lib/tests/test_revset.rs +++ b/lib/tests/test_revset.rs @@ -15,6 +15,7 @@ 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; @@ -45,7 +46,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()); @@ -179,7 +182,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().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" @@ -837,6 +846,7 @@ fn resolve_commit_ids(repo: &dyn Repo, revset_str: &str) -> Vec { let context = RevsetParseContext::new( &aliases_map, settings.user_email(), + chrono::Utc::now().into(), &revset_extensions, None, ); @@ -868,6 +878,7 @@ fn resolve_commit_ids_in_workspace( let context = RevsetParseContext::new( &aliases_map, settings.user_email(), + chrono::Utc::now().into(), &extensions, Some(workspace_ctx), ); @@ -2426,6 +2437,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();