diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc374473a..a831268877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * `jj commit` now accepts `--reset-author` option to match `jj describe`. +* Added revset functions `author_date` and `committer_date`. + ### Fixed bugs * `jj git push` now ignores immutable commits when checking whether a diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index d33142d5da..aedfe67af5 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(), &self.revset_extensions, Some(workspace_context), ) diff --git a/cli/tests/test_revset_output.rs b/cli/tests/test_revset_output.rs index df723efbb2..53e8f8d518 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 b7676ec561..71f657528e 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()`. @@ -333,6 +339,26 @@ Functions that perform string matching support the following pattern syntax: * `glob:"pattern"`: Matches strings with Unix-style shell [wildcard `pattern`](https://docs.rs/glob/latest/glob/struct.Pattern.html). +## Date patterns + +Functions that perform date matching support the following pattern syntax: + +* `after:"string"`, or `"string"`: Matches dates 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 13a93e79a0..6aadeac2a0 100644 --- a/lib/src/default_index/revset_engine.rs +++ b/lib/src/default_index/revset_engine.rs @@ -36,6 +36,7 @@ use crate::revset::{ RevsetFilterPredicate, GENERATION_RANGE_FULL, }; use crate::store::Store; +use crate::time_pattern::TimePattern; use crate::{rewrite, union_find}; type BoxedPredicateFn<'a> = Box bool + 'a>; @@ -1069,6 +1070,30 @@ 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; + match &expression { + TimePattern::AtOrAfter(ts) => ts.le(author_date), + TimePattern::Before(ts) => ts.gt(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; + match &expression { + TimePattern::AtOrAfter(ts) => ts.le(committer_date), + TimePattern::Before(ts) => ts.gt(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 079a883e24..3bf9649382 100644 --- a/lib/src/revset.rs +++ b/lib/src/revset.rs @@ -22,6 +22,7 @@ use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use chrono::{DateTime, FixedOffset, NaiveDateTime, Offset, TimeZone}; use itertools::Itertools; use once_cell::sync::Lazy; use thiserror::Error; @@ -43,6 +44,7 @@ pub use crate::revset_parser::{ }; use crate::store::Store; use crate::str_util::StringPattern; +use crate::time_pattern::TimePattern; use crate::{dsl_util, revset_parser}; /// Error occurred during symbol resolution. @@ -131,6 +133,10 @@ pub enum RevsetFilterPredicate { Author(StringPattern), /// Commits with committer's name or email containing the needle. Committer(StringPattern), + /// Commits authored after the given date. + AuthorDate(TimePattern), + /// Commits committed after the given date. + CommitterDate(TimePattern), /// Commits modifying the paths specified by the fileset. File(FilesetExpression), /// Commits with conflicts @@ -685,6 +691,13 @@ static BUILTIN_FUNCTION_MAP: Lazy> = Lazy: pattern, ))) }); + map.insert("author_date", |function, context| { + let [arg] = function.expect_exact_arguments()?; + let pattern = expect_time_pattern(arg, context.now().to_owned())?; + Ok(RevsetExpression::filter(RevsetFilterPredicate::AuthorDate( + pattern, + ))) + }); map.insert("mine", |function, context| { function.expect_no_arguments()?; Ok(RevsetExpression::filter(RevsetFilterPredicate::Author( @@ -698,6 +711,13 @@ static BUILTIN_FUNCTION_MAP: Lazy> = Lazy: pattern, ))) }); + map.insert("committer_date", |function, context| { + let [arg] = function.expect_exact_arguments()?; + let pattern = expect_time_pattern(arg, context.now().to_owned())?; + Ok(RevsetExpression::filter( + RevsetFilterPredicate::CommitterDate(pattern), + )) + }); map.insert("empty", |function, _context| { function.expect_no_arguments()?; Ok(RevsetExpression::is_empty()) @@ -749,6 +769,24 @@ pub fn expect_string_pattern(node: &ExpressionNode) -> Result( + node: &ExpressionNode, + now: DateTime, +) -> Result +where + Tz::Offset: Copy, +{ + revset_parser::expect_pattern_with("time expression", node, |value, kind| { + TimePattern::from_str_kind(value, kind, now).map_err(|err| { + RevsetParseError::expression( + format!("Unable to parse time expression: {err}"), + node.span, + ) + .with_source(err) + }) + }) +} + /// Resolves function call by using the given function map. fn lower_function_call( function: &FunctionCallNode, @@ -1977,20 +2015,29 @@ impl RevsetExtensions { pub struct RevsetParseContext<'a> { aliases_map: &'a RevsetAliasesMap, user_email: String, + /// The current local time when the revset expression was written. + now: NaiveDateTime, + /// The offset from UTC at the time the revset expression was written. TODO: + /// It would be better if this was the TimeZone so times could be computed + /// correctly across DST shifts. + offset: FixedOffset, extensions: &'a RevsetExtensions, workspace: Option>, } impl<'a> RevsetParseContext<'a> { - pub fn new( + pub fn new( aliases_map: &'a RevsetAliasesMap, user_email: String, + now: DateTime, extensions: &'a RevsetExtensions, workspace: Option>, ) -> Self { Self { aliases_map, user_email, + now: now.naive_local(), + offset: now.offset().fix(), extensions, workspace, } @@ -2004,6 +2051,10 @@ impl<'a> RevsetParseContext<'a> { &self.user_email } + pub fn now(&self) -> DateTime { + DateTime::from_naive_utc_and_offset(self.now, self.offset) + } + pub fn symbol_resolvers(&self) -> &[impl AsRef] { self.extensions.symbol_resolvers() } @@ -2047,6 +2098,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now(), &extensions, None, ); @@ -2076,6 +2128,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now(), &extensions, Some(workspace_ctx), ); @@ -2101,6 +2154,7 @@ mod tests { let context = RevsetParseContext::new( &aliases_map, "test.user@example.com".to_string(), + chrono::Utc::now(), &extensions, None, ); diff --git a/lib/src/time_pattern.rs b/lib/src/time_pattern.rs index d9d3000847..fb384e9b26 100644 --- a/lib/src/time_pattern.rs +++ b/lib/src/time_pattern.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Provides a TimeExpression type that represents a range of time. +//! Provides a TimePattern type that represents a range of time. use chrono::{DateTime, TimeZone}; use chrono_english::{parse_date_string, DateError, Dialect}; diff --git a/lib/tests/test_revset.rs b/lib/tests/test_revset.rs index 8341837ff1..01c5369681 100644 --- a/lib/tests/test_revset.rs +++ b/lib/tests/test_revset.rs @@ -45,7 +45,8 @@ 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::Utc::now(); + let context = RevsetParseContext::new(&aliases_map, String::new(), now, extensions, None); let expression = parse(symbol, &context).unwrap(); assert_matches!(*expression, RevsetExpression::CommitRef(_)); let symbol_resolver = DefaultSymbolResolver::new(repo, extensions.symbol_resolvers()); @@ -177,9 +178,11 @@ fn test_resolve_symbol_commit_id() { repo.as_ref(), &([] as [&Box; 0]), ); + let now = chrono::Utc::now(); 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(), now, &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 +840,7 @@ fn resolve_commit_ids(repo: &dyn Repo, revset_str: &str) -> Vec { let context = RevsetParseContext::new( &aliases_map, settings.user_email(), + chrono::Utc::now(), &revset_extensions, None, ); @@ -868,6 +872,7 @@ fn resolve_commit_ids_in_workspace( let context = RevsetParseContext::new( &aliases_map, settings.user_email(), + chrono::Utc::now(), &extensions, Some(workspace_ctx), ); @@ -2413,6 +2418,164 @@ fn test_evaluate_expression_author() { ); } +#[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 timestamp_day_before = Timestamp { + timestamp: MillisSinceEpoch(1679659200000), // 2023-03-24T12:00:00Z + tz_offset: 0, + }; + let timestamp = Timestamp { + timestamp: MillisSinceEpoch(1679745600000), // 2023-03-25T12:00:00Z + tz_offset: 0, + }; + let timestamp_day_after = Timestamp { + timestamp: MillisSinceEpoch(1679832000000), // 2023-03-26T12:00:00Z + tz_offset: 0, + }; + 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: timestamp_day_before.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp.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: timestamp.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp.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: timestamp_day_after, + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp.clone(), + }) + .write() + .unwrap(); + + // Can find multiple matches + assert_eq!( + resolve_commit_ids(mut_repo, "author_date('2023-03-25')"), + vec![commit3.id().clone(), commit2.id().clone()] + ); + assert_eq!( + resolve_commit_ids(mut_repo, "author_date(after:'2023-03-25')"), + vec![commit3.id().clone(), commit2.id().clone()] + ); + assert_eq!( + resolve_commit_ids(mut_repo, "author_date(before:'2023-03-25')"), + 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 timestamp_day_before = Timestamp { + timestamp: MillisSinceEpoch(1679659200000), // 2023-03-24T12:00:00Z + tz_offset: 0, + }; + let timestamp = Timestamp { + timestamp: MillisSinceEpoch(1679745600000), // 2023-03-25T12:00:00Z + tz_offset: 0, + }; + let timestamp_day_after = Timestamp { + timestamp: MillisSinceEpoch(1679832000000), // 2023-03-26T12:00:00Z + tz_offset: 0, + }; + 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: timestamp.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp_day_before.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: timestamp.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp.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: timestamp.clone(), + }) + .set_committer(Signature { + name: "name1".to_string(), + email: "email1".to_string(), + timestamp: timestamp_day_after, + }) + .write() + .unwrap(); + + // Can find multiple matches + assert_eq!( + resolve_commit_ids(mut_repo, "committer_date('2023-03-25')"), + vec![commit3.id().clone(), commit2.id().clone()] + ); + assert_eq!( + resolve_commit_ids(mut_repo, "committer_date(after:'2023-03-25')"), + vec![commit3.id().clone(), commit2.id().clone()] + ); + assert_eq!( + resolve_commit_ids(mut_repo, "committer_date(before:'2023-03-25')"), + vec![commit1.id().clone(), root_commit.id().clone()] + ); +} + #[test] fn test_evaluate_expression_mine() { let settings = testutils::user_settings();