mirror of
https://github.com/martinvonz/jj.git
synced 2025-01-12 07:14:38 +00:00
feature: support relative timestamps as a config option
This commit is contained in:
parent
c7fb8709b4
commit
01817e4321
11 changed files with 232 additions and 33 deletions
|
@ -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
7
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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(×tamp) {
|
||||
Some(datetime) => datetime.format("%Y-%m-%d %H:%M:%S.%3f %:z").to_string(),
|
||||
None => "<out-of-range date>".to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
64
tests/test_show_command.rs
Normal file
64
tests/test_show_command.rs
Normal 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)
|
||||
"###);
|
||||
}
|
Loading…
Reference in a new issue