ok/jj
1
0
Fork 0
forked from mirrors/jj

revset: add author_date and committer_date revset functions

Author dates and committer dates can be filtered like so:

    committer_date(before:"1 hour ago") # more than 1 hour ago
    committer_date(after:"1 hour ago")  # 1 hour ago or less

A date range can be created by combining revsets. For example, to see any
revisions committed yesterday:

    committer_date(after:"yesterday") & committer_date(before:"today")
This commit is contained in:
Stephen Jennings 2024-06-07 17:42:15 -07:00
parent ff9e739798
commit 6c41b1bef8
8 changed files with 377 additions and 3 deletions

View file

@ -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 This simplifies the use case of configuring code formatters for specific file
types. See `jj help fix` for details. types. See `jj help fix` for details.
* Added revset functions `author_date` and `committer_date`.
### Fixed bugs ### Fixed bugs
* `jj status` will show different messages in a conflicted tree, depending * `jj status` will show different messages in a conflicted tree, depending

View file

@ -28,6 +28,7 @@ use std::time::SystemTime;
use std::{fs, mem, str}; use std::{fs, mem, str};
use bstr::ByteVec as _; use bstr::ByteVec as _;
use chrono::TimeZone;
use clap::builder::{ use clap::builder::{
MapValueParser, NonEmptyStringValueParser, TypedValueParser, ValueParserFactory, MapValueParser, NonEmptyStringValueParser, TypedValueParser, ValueParserFactory,
}; };
@ -1010,9 +1011,17 @@ impl WorkspaceCommandHelper {
path_converter: &self.path_converter, path_converter: &self.path_converter,
workspace_id: self.workspace_id(), 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( RevsetParseContext::new(
&self.revset_aliases_map, &self.revset_aliases_map,
self.settings.user_email(), self.settings.user_email(),
now.into(),
&self.revset_extensions, &self.revset_extensions,
Some(workspace_context), Some(workspace_context),
) )

View file

@ -325,7 +325,7 @@ fn test_function_name_hint() {
| ^-----^ | ^-----^
| |
= Function "author_" doesn't exist = 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###" 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. 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
"###);
}

View file

@ -265,6 +265,12 @@ revsets (expressions) as arguments.
* `committer(pattern)`: Commits with the committer's name or email matching the * `committer(pattern)`: Commits with the committer's name or email matching the
given [string pattern](#string-patterns). 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 * `empty()`: Commits modifying no files. This also includes `merges()` without
user modifications and `root()`. 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 caseinsensitively (e.g. You can append `-i` after the kind to match caseinsensitively (e.g.
`glob-i:"fix*jpeg*"`). `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 ## Aliases
New symbols and functions can be defined in the config file, by using any New symbols and functions can be defined in the config file, by using any

View file

@ -1074,6 +1074,24 @@ fn build_predicate_fn(
|| pattern.matches(&commit.committer().email) || 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) => { RevsetFilterPredicate::File(expr) => {
let matcher: Rc<dyn Matcher> = expr.to_matcher().into(); let matcher: Rc<dyn Matcher> = expr.to_matcher().into();
box_pure_predicate_fn(move |index, pos| { box_pure_predicate_fn(move |index, pos| {

View file

@ -43,6 +43,7 @@ pub use crate::revset_parser::{
}; };
use crate::store::Store; use crate::store::Store;
use crate::str_util::StringPattern; use crate::str_util::StringPattern;
use crate::time_util::{DatePattern, DatePatternContext};
use crate::{dsl_util, fileset, revset_parser}; use crate::{dsl_util, fileset, revset_parser};
/// Error occurred during symbol resolution. /// Error occurred during symbol resolution.
@ -132,6 +133,10 @@ pub enum RevsetFilterPredicate {
Author(StringPattern), Author(StringPattern),
/// Commits with committer name or email matching the pattern. /// Commits with committer name or email matching the pattern.
Committer(StringPattern), 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. /// Commits modifying the paths specified by the fileset.
File(FilesetExpression), File(FilesetExpression),
/// Commits containing diffs matching the `text` pattern within the `files`. /// Commits containing diffs matching the `text` pattern within the `files`.
@ -684,6 +689,13 @@ static BUILTIN_FUNCTION_MAP: Lazy<HashMap<&'static str, RevsetFunction>> = Lazy:
pattern, 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| { map.insert("mine", |function, context| {
function.expect_no_arguments()?; function.expect_no_arguments()?;
// Email address domains are inherently caseinsensitive, and the localparts // Email address domains are inherently caseinsensitive, and the localparts
@ -700,6 +712,13 @@ static BUILTIN_FUNCTION_MAP: Lazy<HashMap<&'static str, RevsetFunction>> = Lazy:
pattern, 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| { map.insert("empty", |function, _context| {
function.expect_no_arguments()?; function.expect_no_arguments()?;
Ok(RevsetExpression::is_empty()) Ok(RevsetExpression::is_empty())
@ -774,6 +793,20 @@ pub fn expect_string_pattern(node: &ExpressionNode) -> Result<StringPattern, Rev
revset_parser::expect_pattern_with("string pattern", node, parse_pattern) revset_parser::expect_pattern_with("string pattern", node, parse_pattern)
} }
pub fn expect_date_pattern(
node: &ExpressionNode,
context: &DatePatternContext,
) -> Result<DatePattern, RevsetParseError> {
let parse_pattern =
|value: &str, kind: Option<&str>| -> Result<_, Box<dyn std::error::Error + Send + Sync>> {
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( fn parse_remote_branches_arguments(
function: &FunctionCallNode, function: &FunctionCallNode,
remote_ref_state: Option<RemoteRefState>, remote_ref_state: Option<RemoteRefState>,
@ -2035,6 +2068,7 @@ impl RevsetExtensions {
pub struct RevsetParseContext<'a> { pub struct RevsetParseContext<'a> {
aliases_map: &'a RevsetAliasesMap, aliases_map: &'a RevsetAliasesMap,
user_email: String, user_email: String,
date_pattern_context: DatePatternContext,
extensions: &'a RevsetExtensions, extensions: &'a RevsetExtensions,
workspace: Option<RevsetWorkspaceContext<'a>>, workspace: Option<RevsetWorkspaceContext<'a>>,
} }
@ -2043,12 +2077,14 @@ impl<'a> RevsetParseContext<'a> {
pub fn new( pub fn new(
aliases_map: &'a RevsetAliasesMap, aliases_map: &'a RevsetAliasesMap,
user_email: String, user_email: String,
date_pattern_context: DatePatternContext,
extensions: &'a RevsetExtensions, extensions: &'a RevsetExtensions,
workspace: Option<RevsetWorkspaceContext<'a>>, workspace: Option<RevsetWorkspaceContext<'a>>,
) -> Self { ) -> Self {
Self { Self {
aliases_map, aliases_map,
user_email, user_email,
date_pattern_context,
extensions, extensions,
workspace, workspace,
} }
@ -2062,6 +2098,10 @@ impl<'a> RevsetParseContext<'a> {
&self.user_email &self.user_email
} }
pub fn date_pattern_context(&self) -> &DatePatternContext {
&self.date_pattern_context
}
pub fn symbol_resolvers(&self) -> &[impl AsRef<dyn SymbolResolverExtension>] { pub fn symbol_resolvers(&self) -> &[impl AsRef<dyn SymbolResolverExtension>] {
self.extensions.symbol_resolvers() self.extensions.symbol_resolvers()
} }
@ -2105,6 +2145,7 @@ mod tests {
let context = RevsetParseContext::new( let context = RevsetParseContext::new(
&aliases_map, &aliases_map,
"test.user@example.com".to_string(), "test.user@example.com".to_string(),
chrono::Utc::now().fixed_offset().into(),
&extensions, &extensions,
None, None,
); );
@ -2133,6 +2174,7 @@ mod tests {
let context = RevsetParseContext::new( let context = RevsetParseContext::new(
&aliases_map, &aliases_map,
"test.user@example.com".to_string(), "test.user@example.com".to_string(),
chrono::Utc::now().fixed_offset().into(),
&extensions, &extensions,
Some(workspace_ctx), Some(workspace_ctx),
); );
@ -2157,6 +2199,7 @@ mod tests {
let context = RevsetParseContext::new( let context = RevsetParseContext::new(
&aliases_map, &aliases_map,
"test.user@example.com".to_string(), "test.user@example.com".to_string(),
chrono::Utc::now().fixed_offset().into(),
&extensions, &extensions,
None, None,
); );

View file

@ -172,6 +172,10 @@ impl UserSettings {
// address // address
pub const USER_EMAIL_PLACEHOLDER: &'static str = "(no email configured)"; pub const USER_EMAIL_PLACEHOLDER: &'static str = "(no email configured)";
pub fn commit_timestamp(&self) -> Option<Timestamp> {
self.timestamp.to_owned()
}
pub fn operation_timestamp(&self) -> Option<Timestamp> { pub fn operation_timestamp(&self) -> Option<Timestamp> {
get_timestamp_config(&self.config, "debug.operation-timestamp") get_timestamp_config(&self.config, "debug.operation-timestamp")
} }

View file

@ -16,6 +16,7 @@ use std::iter;
use std::path::Path; use std::path::Path;
use assert_matches::assert_matches; use assert_matches::assert_matches;
use chrono::DateTime;
use itertools::Itertools; use itertools::Itertools;
use jj_lib::backend::{CommitId, MillisSinceEpoch, Signature, Timestamp}; use jj_lib::backend::{CommitId, MillisSinceEpoch, Signature, Timestamp};
use jj_lib::commit::Commit; use jj_lib::commit::Commit;
@ -46,7 +47,9 @@ fn resolve_symbol_with_extensions(
symbol: &str, symbol: &str,
) -> Result<Vec<CommitId>, RevsetResolutionError> { ) -> Result<Vec<CommitId>, RevsetResolutionError> {
let aliases_map = RevsetAliasesMap::default(); 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(); let expression = parse(symbol, &context).unwrap();
assert_matches!(*expression, RevsetExpression::CommitRef(_)); assert_matches!(*expression, RevsetExpression::CommitRef(_));
let symbol_resolver = DefaultSymbolResolver::new(repo, extensions.symbol_resolvers()); 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 aliases_map = RevsetAliasesMap::default();
let extensions = RevsetExtensions::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!( assert_matches!(
optimize(parse("present(04)", &context).unwrap()).resolve_user_expression(repo.as_ref(), &symbol_resolver), optimize(parse("present(04)", &context).unwrap()).resolve_user_expression(repo.as_ref(), &symbol_resolver),
Err(RevsetResolutionError::AmbiguousCommitIdPrefix(s)) if s == "04" Err(RevsetResolutionError::AmbiguousCommitIdPrefix(s)) if s == "04"
@ -838,6 +847,7 @@ fn resolve_commit_ids(repo: &dyn Repo, revset_str: &str) -> Vec<CommitId> {
let context = RevsetParseContext::new( let context = RevsetParseContext::new(
&aliases_map, &aliases_map,
settings.user_email(), settings.user_email(),
chrono::Utc::now().fixed_offset().into(),
&revset_extensions, &revset_extensions,
None, None,
); );
@ -869,6 +879,7 @@ fn resolve_commit_ids_in_workspace(
let context = RevsetParseContext::new( let context = RevsetParseContext::new(
&aliases_map, &aliases_map,
settings.user_email(), settings.user_email(),
chrono::Utc::now().fixed_offset().into(),
&extensions, &extensions,
Some(workspace_ctx), Some(workspace_ctx),
); );
@ -2478,6 +2489,144 @@ fn test_evaluate_expression_author() {
); );
} }
fn parse_timestamp(s: &str) -> Timestamp {
Timestamp::from_datetime(s.parse::<DateTime<chrono::FixedOffset>>().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] #[test]
fn test_evaluate_expression_mine() { fn test_evaluate_expression_mine() {
let settings = testutils::user_settings(); let settings = testutils::user_settings();