From 01817e43212e0dd38c3fbd42e6fdd107359557a7 Mon Sep 17 00:00:00 2001 From: Ruben Slabbert Date: Sat, 26 Nov 2022 11:33:24 +1000 Subject: [PATCH] feature: support relative timestamps as a config option --- CHANGELOG.md | 3 ++ Cargo.lock | 7 +++ Cargo.toml | 1 + docs/config.md | 9 ++++ docs/config.toml | 5 ++- lib/src/settings.rs | 6 +++ src/commands.rs | 34 ++++++++++---- src/template_parser.rs | 91 +++++++++++++++++++++++++++++--------- src/templater.rs | 10 ++++- tests/test_log_command.rs | 35 +++++++++++++++ tests/test_show_command.rs | 64 +++++++++++++++++++++++++++ 11 files changed, 232 insertions(+), 33 deletions(-) create mode 100644 tests/test_show_command.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d7bff91f8..03381ea41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 another branch called `main/sub`). We now print a warning about these branches instead. +* `jj log`, `jj show`, and `jj obslog` now all support showing relative + timestamps by setting `ui.relative-timestamps = true` in the config file. + ### Fixed bugs * (#463) A bug in the export of branches to Git caused spurious conflicted diff --git a/Cargo.lock b/Cargo.lock index 5d9b12544..484e9b296 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -798,6 +798,7 @@ dependencies = [ "testutils", "textwrap 0.16.0", "thiserror", + "timeago", "tracing", "tracing-subscriber", ] @@ -1763,6 +1764,12 @@ dependencies = [ "ordered-float", ] +[[package]] +name = "timeago" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec32dde57efb15c035ac074118d7f32820451395f28cb0524a01d4e94983b26" + [[package]] name = "tinytemplate" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 5865a8bc2..6a73101e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ serde = { version = "1.0", features = ["derive"] } slab = "0.4.7" tempfile = "3.3.0" textwrap = "0.16.0" +timeago = { version = "0.3.1", default-features = false } thiserror = "1.0.37" tracing = "0.1.37" tracing-subscriber = { version = "0.3.16", default-features = false, features = ["std", "ansi", "env-filter", "fmt"] } diff --git a/docs/config.md b/docs/config.md index 8279050c0..e1a890796 100644 --- a/docs/config.md +++ b/docs/config.md @@ -91,6 +91,15 @@ further settings are passed on via the following: merge-tools.kdiff3.program = "kdiff3" merge-tools.kdiff3.edit-args = ["--merge", "--cs", "CreateBakFiles=0"] + +## Relative timestamps + + ui.relative-timestamps = true + +False by default, but setting to true will change timestamps to be rendered +as `x days/hours/seconds ago` instead of being rendered as a full timestamp. + + # Alternative ways to specify configuration settings Instead of `~/.jjconfig.toml`, the config settings can be located at diff --git a/docs/config.toml b/docs/config.toml index 63dd4977f..fef31e454 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -12,4 +12,7 @@ ui.editor = "pico" # the default diff-editor = "meld" # default, requires meld to be installed -# diff-editor = "vimdiff" \ No newline at end of file +# diff-editor = "vimdiff" + +ui.relative-timestamps = false # the default +# ui.relative-timestamps = true # renders timestamps relatively, e.g. "x hours ago" diff --git a/lib/src/settings.rs b/lib/src/settings.rs index e2418e608..7cb67e8f5 100644 --- a/lib/src/settings.rs +++ b/lib/src/settings.rs @@ -143,6 +143,12 @@ impl UserSettings { .unwrap_or(true) } + pub fn relative_timestamps(&self) -> bool { + self.config + .get_bool("ui.relative-timestamps") + .unwrap_or(false) + } + pub fn config(&self) -> &config::Config { &self.config } diff --git a/src/commands.rs b/src/commands.rs index 8a3ec1d4b..17e5ab7f7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1366,18 +1366,26 @@ fn cmd_show(ui: &mut Ui, command: &CommandHelper, args: &ShowArgs) -> Result<(), let diff_iterator = from_tree.diff(&to_tree, &EverythingMatcher); // TODO: Add branches, tags, etc // TODO: Indent the description like Git does - let template_string = r#" + let (author_timestamp_template, committer_timestamp_template) = + if ui.settings().relative_timestamps() { + ("author.timestamp().ago()", "committer.timestamp().ago()") + } else { + ("author.timestamp()", "committer.timestamp()") + }; + let template_string = format!( + r#" "Commit ID: " commit_id "\n" "Change ID: " change_id "\n" - "Author: " author " <" author.email() "> (" author.timestamp() ")\n" - "Committer: " committer " <" committer.email() "> (" committer.timestamp() ")\n" + "Author: " author " <" author.email() "> (" {author_timestamp_template} ")\n" + "Committer: " committer " <" committer.email() "> (" {committer_timestamp_template} ")\n" "\n" description - "\n""#; + "\n""#, + ); let template = crate::template_parser::parse_commit_template( workspace_command.repo().as_repo_ref(), &workspace_command.workspace_id(), - template_string, + &template_string, ); let mut formatter = ui.stdout_formatter(); let formatter = formatter.as_mut(); @@ -1993,10 +2001,17 @@ fn cmd_status( fn log_template(settings: &UserSettings) -> String { // TODO: define a method on boolean values, so we can get auto-coloring // with e.g. `conflict.then("conflict")` - let default_template = r#" + + let author_timestamp = if settings.relative_timestamps() { + "author.timestamp().ago()" + } else { + "author.timestamp()" + }; + let default_template = format!( + r#" change_id.short() " " author.email() - " " label("timestamp", author.timestamp()) + " " label("timestamp", {author_timestamp}) if(branches, " " branches) if(tags, " " tags) if(working_copies, " " working_copies) @@ -2006,11 +2021,12 @@ fn log_template(settings: &UserSettings) -> String { if(conflict, label("conflict", " conflict")) "\n" description.first_line() - "\n""#; + "\n""#, + ); settings .config() .get_string("template.log.graph") - .unwrap_or_else(|_| default_template.to_string()) + .unwrap_or(default_template) } fn cmd_log(ui: &mut Ui, command: &CommandHelper, args: &LogArgs) -> Result<(), CommandError> { diff --git a/src/template_parser.rs b/src/template_parser.rs index f7126ce42..8b9311be6 100644 --- a/src/template_parser.rs +++ b/src/template_parser.rs @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -use chrono::{FixedOffset, LocalResult, TimeZone, Utc}; -use jujutsu_lib::backend::{CommitId, Signature}; +use chrono::{DateTime, FixedOffset, LocalResult, TimeZone, Utc}; +use jujutsu_lib::backend::{CommitId, Signature, Timestamp}; use jujutsu_lib::commit::Commit; use jujutsu_lib::op_store::WorkspaceId; use jujutsu_lib::repo::RepoRef; @@ -26,8 +26,9 @@ use crate::templater::{ AuthorProperty, BranchProperty, ChangeIdProperty, CommitIdKeyword, CommitterProperty, ConditionalTemplate, ConflictProperty, ConstantTemplateProperty, DescriptionProperty, DivergentProperty, DynamicLabelTemplate, GitRefsProperty, IsGitHeadProperty, - IsWorkingCopyProperty, LabelTemplate, ListTemplate, LiteralTemplate, StringPropertyTemplate, - TagProperty, Template, TemplateFunction, TemplateProperty, WorkingCopiesProperty, + IsWorkingCopyProperty, LabelTemplate, ListTemplate, LiteralTemplate, SignatureTimestamp, + StringPropertyTemplate, TagProperty, Template, TemplateFunction, TemplateProperty, + WorkingCopiesProperty, }; #[derive(Parser)] @@ -94,25 +95,41 @@ impl TemplateProperty for SignatureEmail { } } -struct SignatureTimestamp; +fn datetime_from_timestamp(context: &Timestamp) -> Option> { + let utc = match Utc.timestamp_opt( + context.timestamp.0.div_euclid(1000), + (context.timestamp.0.rem_euclid(1000)) as u32 * 1000000, + ) { + LocalResult::None => { + return None; + } + LocalResult::Single(x) => x, + LocalResult::Ambiguous(y, _z) => y, + }; -impl TemplateProperty for SignatureTimestamp { - fn extract(&self, context: &Signature) -> String { - let utc = match Utc.timestamp_opt( - context.timestamp.timestamp.0.div_euclid(1000), - (context.timestamp.timestamp.0.rem_euclid(1000)) as u32 * 1000000, - ) { - LocalResult::None => { - return "".to_string(); - } - LocalResult::Single(x) => x, - LocalResult::Ambiguous(y, _z) => y, - }; - let datetime = utc.with_timezone( - &FixedOffset::east_opt(context.timestamp.tz_offset * 60) + Some( + utc.with_timezone( + &FixedOffset::east_opt(context.tz_offset * 60) .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()), - ); - datetime.format("%Y-%m-%d %H:%M:%S.%3f %:z").to_string() + ), + ) +} + +struct RelativeTimestampString; + +impl TemplateProperty for RelativeTimestampString { + fn extract(&self, context: &Timestamp) -> String { + datetime_from_timestamp(context) + .and_then(|datetime| { + let now = chrono::Local::now(); + + now.signed_duration_since(datetime).to_std().ok() + }) + .map(|duration| { + let f = timeago::Formatter::new(); + f.convert(duration) + }) + .unwrap_or_else(|| "".to_string()) } } @@ -142,6 +159,10 @@ fn parse_method_chain<'a, I: 'a>( let next_method = parse_signature_method(method); next_method.after(property) } + Property::Timestamp(property) => { + let next_method = parse_timestamp_method(method); + next_method.after(property) + } } } } @@ -200,18 +221,33 @@ fn parse_signature_method<'a>(method: Pair) -> Property<'a, Signature> { // `author % (name "<" email ">")`)? "name" => Property::String(Box::new(SignatureName)), "email" => Property::String(Box::new(SignatureEmail)), - "timestamp" => Property::String(Box::new(SignatureTimestamp)), + "timestamp" => Property::Timestamp(Box::new(SignatureTimestamp)), name => panic!("no such commit ID method: {}", name), }; let chain_method = inner.last().unwrap(); parse_method_chain(chain_method, this_function) } +fn parse_timestamp_method<'a>(method: Pair) -> Property<'a, Timestamp> { + assert_eq!(method.as_rule(), Rule::method); + let mut inner = method.into_inner(); + let name = inner.next().unwrap(); + // TODO: validate arguments + + let this_function = match name.as_str() { + "ago" => Property::String(Box::new(RelativeTimestampString)), + name => panic!("no such timestamp method: {}", name), + }; + let chain_method = inner.last().unwrap(); + parse_method_chain(chain_method, this_function) +} + enum Property<'a, I> { String(Box + 'a>), Boolean(Box + 'a>), CommitId(Box + 'a>), Signature(Box + 'a>), + Timestamp(Box + 'a>), } impl<'a, I: 'a> Property<'a, I> { @@ -233,6 +269,10 @@ impl<'a, I: 'a> Property<'a, I> { first, Box::new(move |value| property.extract(&value)), ))), + Property::Timestamp(property) => Property::Timestamp(Box::new(TemplateFunction::new( + first, + Box::new(move |value| property.extract(&value)), + ))), } } } @@ -282,6 +322,13 @@ fn coerce_to_string<'a, I: 'a>( property, Box::new(|signature| signature.name), )), + Property::Timestamp(property) => Box::new(TemplateFunction::new( + property, + Box::new(|timestamp| match datetime_from_timestamp(×tamp) { + Some(datetime) => datetime.format("%Y-%m-%d %H:%M:%S.%3f %:z").to_string(), + None => "".to_string(), + }), + )), } } diff --git a/src/templater.rs b/src/templater.rs index 7566ecc50..9c0c91e6b 100644 --- a/src/templater.rs +++ b/src/templater.rs @@ -18,7 +18,7 @@ use std::io; use std::ops::{Add, AddAssign}; use itertools::Itertools; -use jujutsu_lib::backend::{ChangeId, CommitId, Signature}; +use jujutsu_lib::backend::{ChangeId, CommitId, Signature, Timestamp}; use jujutsu_lib::commit::Commit; use jujutsu_lib::op_store::WorkspaceId; use jujutsu_lib::repo::RepoRef; @@ -438,3 +438,11 @@ impl TemplateProperty for CommitIdKeyword { context.id().clone() } } + +pub struct SignatureTimestamp; + +impl TemplateProperty for SignatureTimestamp { + fn extract(&self, context: &Signature) -> Timestamp { + context.timestamp.clone() + } +} diff --git a/tests/test_log_command.rs b/tests/test_log_command.rs index dfb16608d..90e1419a9 100644 --- a/tests/test_log_command.rs +++ b/tests/test_log_command.rs @@ -13,6 +13,7 @@ // limitations under the License. use common::{get_stderr_string, get_stdout_string, TestEnvironment}; +use regex::Regex; pub mod common; @@ -310,3 +311,37 @@ fn test_default_revset() { .count() ); } + +#[test] +fn test_log_author_timestamp() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + test_env.jj_cmd_success(&repo_path, &["describe", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["new", "-m", "second"]); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author.timestamp()"]); + insta::assert_snapshot!(stdout, @r###" + @ 2001-02-03 04:05:09.000 +07:00 + o 2001-02-03 04:05:07.000 +07:00 + o 1970-01-01 00:00:00.000 +00:00 + "###); +} + +#[test] +fn test_log_author_timestamp_ago() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + test_env.jj_cmd_success(&repo_path, &["describe", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["new", "-m", "second"]); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", "author.timestamp().ago()"]); + let line_re = Regex::new(r"@|o [0-9]+ years ago").unwrap(); + assert!( + stdout.lines().all(|x| line_re.is_match(x)), + "expected every line to match regex" + ); +} diff --git a/tests/test_show_command.rs b/tests/test_show_command.rs new file mode 100644 index 000000000..21d2aac40 --- /dev/null +++ b/tests/test_show_command.rs @@ -0,0 +1,64 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use common::TestEnvironment; +use itertools::Itertools; +use regex::Regex; + +pub mod common; + +#[test] +fn test_show() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + let stdout = test_env.jj_cmd_success(&repo_path, &["show"]); + let stdout = stdout.lines().skip(2).join("\n"); + + insta::assert_snapshot!(stdout, @r###" + Author: Test User (2001-02-03 04:05:07.000 +07:00) + Committer: Test User (2001-02-03 04:05:07.000 +07:00) + + (no description set) + "###); +} + +#[test] +fn test_show_relative_timestamps() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + test_env.add_config( + br#"[ui] + relative-timestamps = true + "#, + ); + + let stdout = test_env.jj_cmd_success(&repo_path, &["show"]); + let timestamp_re = Regex::new(r"\([0-9]+ years ago\)").unwrap(); + let stdout = stdout + .lines() + .skip(2) + .map(|x| timestamp_re.replace_all(x, "(...timestamp...)")) + .join("\n"); + + insta::assert_snapshot!(stdout, @r###" + Author: Test User (...timestamp...) + Committer: Test User (...timestamp...) + + (no description set) + "###); +}