From 0ab75383eef9e62caff6541d0cd1241959103ba0 Mon Sep 17 00:00:00 2001
From: Stephen Jennings <stephen.g.jennings@gmail.com>
Date: Fri, 7 Jun 2024 17:42:15 -0700
Subject: [PATCH] revset: add author_date and committer_date revset functions

These functions always filter for dates at or after the specified date. To
invert the filter, combine it with the ~ operator:

    # anything authored before yesterday at midnight
    ~author_date("yesterday")
---
 cli/src/cli_util.rs                    |   1 +
 cli/tests/test_revset_output.rs        |   2 +-
 docs/revsets.md                        |  19 +++
 lib/src/default_index/revset_engine.rs |  25 ++++
 lib/src/revset.rs                      |  56 ++++++++-
 lib/src/time_pattern.rs                |   2 +-
 lib/tests/test_revset.rs               | 167 ++++++++++++++++++++++++-
 7 files changed, 267 insertions(+), 5 deletions(-)

diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs
index d33142d5da8..aedfe67af53 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 df723efbb24..53e8f8d5180 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 b7676ec561e..6ec59096fa6 100644
--- a/docs/revsets.md
+++ b/docs/revsets.md
@@ -257,6 +257,10 @@ 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 authored within at or after specified [date](#date-patterns).
+
+* `committer_date(pattern)`: Commits committed at or after the specified [date](#date-patterns).
+
 * `empty()`: Commits modifying no files. This also includes `merges()` without
   user modifications and `root()`.
 
@@ -333,6 +337,21 @@ 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
+
+Date patterns 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 13a93e79a09..6aadeac2a0a 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<dyn FnMut(&CompositeIndex, IndexPosition) -> 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<dyn Matcher> = expr.to_matcher().into();
             box_pure_predicate_fn(move |index, pos| {
diff --git a/lib/src/revset.rs b/lib/src/revset.rs
index 079a883e247..137171a6053 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<HashMap<&'static str, RevsetFunction>> = 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<HashMap<&'static str, RevsetFunction>> = 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<StringPattern, Rev
     revset_parser::expect_pattern_with("string pattern", node, parse_pattern)
 }
 
+fn expect_time_pattern<Tz: TimeZone>(
+    node: &ExpressionNode,
+    now: DateTime<Tz>,
+) -> Result<TimePattern, RevsetParseError>
+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<RevsetWorkspaceContext<'a>>,
 }
 
 impl<'a> RevsetParseContext<'a> {
-    pub fn new(
+    pub fn new<Tz: TimeZone>(
         aliases_map: &'a RevsetAliasesMap,
         user_email: String,
+        now: DateTime<Tz>,
         extensions: &'a RevsetExtensions,
         workspace: Option<RevsetWorkspaceContext<'a>>,
     ) -> 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<FixedOffset> {
+        DateTime::from_naive_utc_and_offset(self.now, self.offset)
+    }
+
     pub fn symbol_resolvers(&self) -> &[impl AsRef<dyn SymbolResolverExtension>] {
         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 71859659dab..9815efee52b 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 8341837ff11..01c53696813 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<Vec<CommitId>, 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<dyn SymbolResolverExtension>; 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<CommitId> {
     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();