feature: support relative timestamps as a config option

This commit is contained in:
Ruben Slabbert 2022-11-26 11:33:24 +10:00
parent c7fb8709b4
commit 01817e4321
11 changed files with 232 additions and 33 deletions

View file

@ -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

7
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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

View file

@ -12,4 +12,7 @@ ui.editor = "pico" # the default
diff-editor = "meld" # default, requires meld to be installed
# diff-editor = "vimdiff"
# diff-editor = "vimdiff"
ui.relative-timestamps = false # the default
# ui.relative-timestamps = true # renders timestamps relatively, e.g. "x hours ago"

View file

@ -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
}

View file

@ -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> {

View file

@ -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<Signature, String> for SignatureEmail {
}
}
struct SignatureTimestamp;
fn datetime_from_timestamp(context: &Timestamp) -> Option<DateTime<FixedOffset>> {
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<Signature, String> 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 "<out-of-range date>".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<Timestamp, String> 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(|| "<out-of-range date>".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<Rule>) -> 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<Rule>) -> 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<dyn TemplateProperty<I, String> + 'a>),
Boolean(Box<dyn TemplateProperty<I, bool> + 'a>),
CommitId(Box<dyn TemplateProperty<I, CommitId> + 'a>),
Signature(Box<dyn TemplateProperty<I, Signature> + 'a>),
Timestamp(Box<dyn TemplateProperty<I, Timestamp> + '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(&timestamp) {
Some(datetime) => datetime.format("%Y-%m-%d %H:%M:%S.%3f %:z").to_string(),
None => "<out-of-range date>".to_string(),
}),
)),
}
}

View file

@ -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<Commit, CommitId> for CommitIdKeyword {
context.id().clone()
}
}
pub struct SignatureTimestamp;
impl TemplateProperty<Signature, Timestamp> for SignatureTimestamp {
fn extract(&self, context: &Signature) -> Timestamp {
context.timestamp.clone()
}
}

View file

@ -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"
);
}

View file

@ -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 <test.user@example.com> (2001-02-03 04:05:07.000 +07:00)
Committer: Test User <test.user@example.com> (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 <test.user@example.com> (...timestamp...)
Committer: Test User <test.user@example.com> (...timestamp...)
(no description set)
"###);
}