diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fb71dce7..9d5f79609a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * In git diffs, word-level hunks are now highlighted with underline. See [diff colors and styles](docs/config.md#diff-colors-and-styles) for customization. +* New `.diff().()` commit template methods are added. They can be used + in order to show diffs conditionally. For example, + `if(current_working_copy, diff.summary())`. + * `jj git clone` and `jj git init` with an existing git repository adds the default branch of the remote as repository settings for `revset-aliases."trunk()"`.` diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index 26cb7c23c7..d7cf166aa3 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -1064,6 +1064,7 @@ impl WorkspaceCommandHelper { pub fn commit_template_language(&self) -> Result, CommandError> { Ok(CommitTemplateLanguage::new( self.repo().as_ref(), + &self.path_converter, self.workspace_id(), self.revset_parse_context(), self.id_prefix_context()?, @@ -1654,6 +1655,7 @@ impl WorkspaceCommandTransaction<'_> { .expect("parse error should be confined by WorkspaceCommandHelper::new()"); let language = CommitTemplateLanguage::new( self.tx.repo(), + &self.helper.path_converter, self.helper.workspace_id(), self.helper.revset_parse_context(), &id_prefix_context, diff --git a/cli/src/commit_templater.rs b/cli/src/commit_templater.rs index daf7b2fd87..a00656261a 100644 --- a/cli/src/commit_templater.rs +++ b/cli/src/commit_templater.rs @@ -19,28 +19,36 @@ use std::io; use std::rc::Rc; use itertools::Itertools as _; -use jj_lib::backend::{ChangeId, CommitId}; +use jj_lib::backend::{BackendResult, ChangeId, CommitId}; use jj_lib::commit::Commit; use jj_lib::extensions_map::ExtensionsMap; +use jj_lib::fileset::{self, FilesetExpression}; use jj_lib::git; use jj_lib::hex_util::to_reverse_hex; use jj_lib::id_prefix::IdPrefixContext; +use jj_lib::matchers::Matcher; +use jj_lib::merged_tree::{MergedTree, TreeDiffStream}; use jj_lib::object_id::ObjectId as _; use jj_lib::op_store::{RefTarget, RemoteRef, WorkspaceId}; use jj_lib::repo::Repo; +use jj_lib::repo_path::RepoPathUiConverter; use jj_lib::revset::{self, Revset, RevsetExpression, RevsetModifier, RevsetParseContext}; +use jj_lib::store::Store; use once_cell::unsync::OnceCell; +use crate::formatter::Formatter; use crate::template_builder::{ self, merge_fn_map, BuildContext, CoreTemplateBuildFnTable, CoreTemplatePropertyKind, IntoTemplateProperty, TemplateBuildMethodFnMap, TemplateLanguage, }; -use crate::template_parser::{self, FunctionCallNode, TemplateParseError, TemplateParseResult}; +use crate::template_parser::{ + self, ExpressionNode, FunctionCallNode, TemplateParseError, TemplateParseResult, +}; use crate::templater::{ self, PlainTextFormattedProperty, SizeHint, Template, TemplateFormatter, TemplateProperty, TemplatePropertyError, TemplatePropertyExt as _, }; -use crate::{revset_util, text_util}; +use crate::{diff_util, revset_util, text_util}; pub trait CommitTemplateLanguageExtension { fn build_fn_table<'repo>(&self) -> CommitTemplateBuildFnTable<'repo>; @@ -50,12 +58,13 @@ pub trait CommitTemplateLanguageExtension { pub struct CommitTemplateLanguage<'repo> { repo: &'repo dyn Repo, + path_converter: &'repo RepoPathUiConverter, workspace_id: WorkspaceId, // RevsetParseContext doesn't borrow a repo, but we'll need 'repo lifetime // anyway to capture it to evaluate dynamically-constructed user expression // such as `revset("ancestors(" ++ commit_id ++ ")")`. - // TODO: Maybe refactor context structs? WorkspaceId is contained in - // RevsetParseContext for example. + // TODO: Maybe refactor context structs? RepoPathUiConverter and WorkspaceId + // are contained in RevsetParseContext for example. revset_parse_context: RevsetParseContext<'repo>, id_prefix_context: &'repo IdPrefixContext, build_fn_table: CommitTemplateBuildFnTable<'repo>, @@ -68,6 +77,7 @@ impl<'repo> CommitTemplateLanguage<'repo> { /// evaluation tree. pub fn new( repo: &'repo dyn Repo, + path_converter: &'repo RepoPathUiConverter, workspace_id: &WorkspaceId, revset_parse_context: RevsetParseContext<'repo>, id_prefix_context: &'repo IdPrefixContext, @@ -85,6 +95,7 @@ impl<'repo> CommitTemplateLanguage<'repo> { CommitTemplateLanguage { repo, + path_converter, workspace_id: workspace_id.clone(), revset_parse_context, id_prefix_context, @@ -175,6 +186,11 @@ impl<'repo> TemplateLanguage<'repo> for CommitTemplateLanguage<'repo> { let build = template_parser::lookup_method(type_name, table, function)?; build(self, build_ctx, property, function) } + CommitTemplatePropertyKind::TreeDiff(property) => { + let table = &self.build_fn_table.tree_diff_methods; + let build = template_parser::lookup_method(type_name, table, function)?; + build(self, build_ctx, property, function) + } } } } @@ -245,6 +261,12 @@ impl<'repo> CommitTemplateLanguage<'repo> { ) -> CommitTemplatePropertyKind<'repo> { CommitTemplatePropertyKind::ShortestIdPrefix(Box::new(property)) } + + pub fn wrap_tree_diff( + property: impl TemplateProperty + 'repo, + ) -> CommitTemplatePropertyKind<'repo> { + CommitTemplatePropertyKind::TreeDiff(Box::new(property)) + } } pub enum CommitTemplatePropertyKind<'repo> { @@ -257,6 +279,7 @@ pub enum CommitTemplatePropertyKind<'repo> { RefNameList(Box>> + 'repo>), CommitOrChangeId(Box + 'repo>), ShortestIdPrefix(Box + 'repo>), + TreeDiff(Box + 'repo>), } impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> { @@ -271,6 +294,7 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> { CommitTemplatePropertyKind::RefNameList(_) => "List", CommitTemplatePropertyKind::CommitOrChangeId(_) => "CommitOrChangeId", CommitTemplatePropertyKind::ShortestIdPrefix(_) => "ShortestIdPrefix", + CommitTemplatePropertyKind::TreeDiff(_) => "TreeDiff", } } @@ -293,6 +317,9 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> { } CommitTemplatePropertyKind::CommitOrChangeId(_) => None, CommitTemplatePropertyKind::ShortestIdPrefix(_) => None, + // TODO: boolean cast could be implemented, but explicit + // diff.empty() method might be better. + CommitTemplatePropertyKind::TreeDiff(_) => None, } } @@ -328,6 +355,7 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> { CommitTemplatePropertyKind::ShortestIdPrefix(property) => { Some(property.into_template()) } + CommitTemplatePropertyKind::TreeDiff(_) => None, } } } @@ -343,6 +371,7 @@ pub struct CommitTemplateBuildFnTable<'repo> { pub ref_name_methods: CommitTemplateBuildMethodFnMap<'repo, Rc>, pub commit_or_change_id_methods: CommitTemplateBuildMethodFnMap<'repo, CommitOrChangeId>, pub shortest_id_prefix_methods: CommitTemplateBuildMethodFnMap<'repo, ShortestIdPrefix>, + pub tree_diff_methods: CommitTemplateBuildMethodFnMap<'repo, TreeDiff>, } impl<'repo> CommitTemplateBuildFnTable<'repo> { @@ -354,6 +383,7 @@ impl<'repo> CommitTemplateBuildFnTable<'repo> { ref_name_methods: builtin_ref_name_methods(), commit_or_change_id_methods: builtin_commit_or_change_id_methods(), shortest_id_prefix_methods: builtin_shortest_id_prefix_methods(), + tree_diff_methods: builtin_tree_diff_methods(), } } @@ -364,6 +394,7 @@ impl<'repo> CommitTemplateBuildFnTable<'repo> { ref_name_methods: HashMap::new(), commit_or_change_id_methods: HashMap::new(), shortest_id_prefix_methods: HashMap::new(), + tree_diff_methods: HashMap::new(), } } @@ -374,6 +405,7 @@ impl<'repo> CommitTemplateBuildFnTable<'repo> { ref_name_methods, commit_or_change_id_methods, shortest_id_prefix_methods, + tree_diff_methods, } = extension; self.core.merge(core); @@ -387,6 +419,7 @@ impl<'repo> CommitTemplateBuildFnTable<'repo> { &mut self.shortest_id_prefix_methods, shortest_id_prefix_methods, ); + merge_fn_map(&mut self.tree_diff_methods, tree_diff_methods); } } @@ -645,6 +678,21 @@ fn builtin_commit_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, Comm let out_property = self_property.and_then(|commit| Ok(commit.is_empty(repo)?)); Ok(L::wrap_boolean(out_property)) }); + map.insert("diff", |language, _build_ctx, self_property, function| { + let ([], [files_node]) = function.expect_arguments()?; + let files = if let Some(node) = files_node { + expect_fileset_literal(node, language.path_converter)? + } else { + // TODO: defaults to CLI path arguments? + // https://github.com/martinvonz/jj/issues/2933#issuecomment-1925870731 + FilesetExpression::all() + }; + let repo = language.repo; + let matcher: Rc = files.to_matcher().into(); + let out_property = self_property + .and_then(move |commit| Ok(TreeDiff::from_commit(repo, &commit, matcher.clone())?)); + Ok(L::wrap_tree_diff(out_property)) + }); map.insert("root", |language, _build_ctx, self_property, function| { function.expect_no_arguments()?; let repo = language.repo; @@ -669,6 +717,17 @@ fn extract_working_copies(repo: &dyn Repo, commit: &Commit) -> String { names.join(" ") } +fn expect_fileset_literal( + node: &ExpressionNode, + path_converter: &RepoPathUiConverter, +) -> Result { + template_parser::expect_string_literal_with(node, |text, span| { + fileset::parse(text, path_converter).map_err(|err| { + TemplateParseError::expression("Failed to parse fileset", span).with_source(err) + }) + }) +} + type RevsetContainingFn<'repo> = dyn Fn(&CommitId) -> bool + 'repo; fn evaluate_revset_expression<'repo>( @@ -1212,3 +1271,136 @@ fn builtin_shortest_id_prefix_methods<'repo>( }); map } + +/// Pair of trees to be diffed. +#[derive(Debug)] +pub struct TreeDiff { + from_tree: MergedTree, + to_tree: MergedTree, + matcher: Rc, +} + +impl TreeDiff { + fn from_commit( + repo: &dyn Repo, + commit: &Commit, + matcher: Rc, + ) -> BackendResult { + Ok(TreeDiff { + from_tree: commit.parent_tree(repo)?, + to_tree: commit.tree()?, + matcher, + }) + } + + fn diff_stream(&self) -> TreeDiffStream<'_> { + self.from_tree.diff_stream(&self.to_tree, &*self.matcher) + } + + fn into_formatted(self, show: F) -> TreeDiffFormatted + where + F: Fn(&mut dyn Formatter, &Store, TreeDiffStream) -> Result<(), E>, + E: Into, + { + TreeDiffFormatted { diff: self, show } + } +} + +/// Tree diff to be rendered by predefined function `F`. +struct TreeDiffFormatted { + diff: TreeDiff, + show: F, +} + +impl Template for TreeDiffFormatted +where + F: Fn(&mut dyn Formatter, &Store, TreeDiffStream) -> Result<(), E>, + E: Into, +{ + fn format(&self, formatter: &mut TemplateFormatter) -> io::Result<()> { + let show = &self.show; + let store = self.diff.from_tree.store(); + let tree_diff = self.diff.diff_stream(); + show(formatter.as_mut(), store, tree_diff).or_else(|err| formatter.handle_error(err.into())) + } +} + +fn builtin_tree_diff_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, TreeDiff> { + type L<'repo> = CommitTemplateLanguage<'repo>; + // Not using maplit::hashmap!{} or custom declarative macro here because + // code completion inside macro is quite restricted. + let mut map = CommitTemplateBuildMethodFnMap::::new(); + map.insert( + "color_words", + |language, build_ctx, self_property, function| { + let ([], [context_node]) = function.expect_arguments()?; + let context_property = context_node + .map(|node| template_builder::expect_usize_expression(language, build_ctx, node)) + .transpose()?; + let path_converter = language.path_converter; + let template = (self_property, context_property) + .map(move |(diff, context)| { + let context = context.unwrap_or(diff_util::DEFAULT_CONTEXT_LINES); + diff.into_formatted(move |formatter, store, tree_diff| { + diff_util::show_color_words_diff( + formatter, + store, + tree_diff, + path_converter, + context, + ) + }) + }) + .into_template(); + Ok(L::wrap_template(template)) + }, + ); + map.insert("git", |language, build_ctx, self_property, function| { + let ([], [context_node]) = function.expect_arguments()?; + let context_property = context_node + .map(|node| template_builder::expect_usize_expression(language, build_ctx, node)) + .transpose()?; + let template = (self_property, context_property) + .map(|(diff, context)| { + let context = context.unwrap_or(diff_util::DEFAULT_CONTEXT_LINES); + diff.into_formatted(move |formatter, store, tree_diff| { + diff_util::show_git_diff(formatter, store, tree_diff, context) + }) + }) + .into_template(); + Ok(L::wrap_template(template)) + }); + map.insert("stat", |language, build_ctx, self_property, function| { + let [width_node] = function.expect_exact_arguments()?; + let width_property = + template_builder::expect_usize_expression(language, build_ctx, width_node)?; + let path_converter = language.path_converter; + let template = (self_property, width_property) + .map(move |(diff, width)| { + diff.into_formatted(move |formatter, store, tree_diff| { + diff_util::show_diff_stat(formatter, store, tree_diff, path_converter, width) + }) + }) + .into_template(); + Ok(L::wrap_template(template)) + }); + map.insert( + "summary", + |language, _build_ctx, self_property, function| { + function.expect_no_arguments()?; + let path_converter = language.path_converter; + let template = self_property + .map(move |diff| { + diff.into_formatted(move |formatter, _store, tree_diff| { + diff_util::show_diff_summary(formatter, tree_diff, path_converter) + }) + }) + .into_template(); + Ok(L::wrap_template(template)) + }, + ); + // TODO: add types() and name_only()? or let users write their own template? + // TODO: add support for external tools + // TODO: add files() or map() to support custom summary-like formatting? + map +} diff --git a/cli/src/diff_util.rs b/cli/src/diff_util.rs index a63ad46093..49908bc0b1 100644 --- a/cli/src/diff_util.rs +++ b/cli/src/diff_util.rs @@ -48,7 +48,7 @@ use crate::merge_tools::{ use crate::text_util; use crate::ui::Ui; -const DEFAULT_CONTEXT_LINES: usize = 3; +pub const DEFAULT_CONTEXT_LINES: usize = 3; #[derive(clap::Args, Clone, Debug)] #[command(next_help_heading = "Diff Formatting Options")] diff --git a/cli/tests/test_commit_template.rs b/cli/tests/test_commit_template.rs index 0ef6e4a00d..9cf4893121 100644 --- a/cli/tests/test_commit_template.rs +++ b/cli/tests/test_commit_template.rs @@ -938,3 +938,131 @@ fn test_short_prefix_in_transaction() { zz[zzzzzzzzzz] 00[0000000000] "###); } + +#[test] +fn test_log_diff_predefined_formats() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + std::fs::write(repo_path.join("file1"), "a\nb\n").unwrap(); + std::fs::write(repo_path.join("file2"), "a\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file1"), "a\nb\nc\n").unwrap(); + std::fs::write(repo_path.join("file2"), "b\nc\n").unwrap(); + + let template = r#" + concat( + "=== color_words ===\n", + diff.color_words(), + "=== git ===\n", + diff.git(), + "=== stat ===\n", + diff.stat(80), + "=== summary ===\n", + diff.summary(), + ) + "#; + + // color, without paths + let stdout = test_env.jj_cmd_success( + &repo_path, + &["log", "--no-graph", "--color=always", "-r@", "-T", template], + ); + insta::assert_snapshot!(stdout, @r###" + === color_words === + Modified regular file file1: +  1  1: a +  2  2: b +  3: c + Modified regular file file2: +  1  1: ab +  2: c + === git === + diff --git a/file1 b/file1 + index 422c2b7ab3..de980441c3 100644 + --- a/file1 + +++ b/file1 + @@ -1,2 +1,3 @@ + a + b + +c + diff --git a/file2 b/file2 + index 7898192261..9ddeb5c484 100644 + --- a/file2 + +++ b/file2 + @@ -1,1 +1,2 @@ + -a + +b + +c + === stat === + file1 | 1 + + file2 | 3 ++- + 2 files changed, 3 insertions(+), 1 deletion(-) + === summary === + M file1 + M file2 + "###); + + // cwd != workspace root + let stdout = test_env.jj_cmd_success( + test_env.env_root(), + &["log", "-Rrepo", "--no-graph", "-r@", "-T", template], + ); + insta::assert_snapshot!(stdout.replace('\\', "/"), @r###" + === color_words === + Modified regular file repo/file1: + 1 1: a + 2 2: b + 3: c + Modified regular file repo/file2: + 1 1: ab + 2: c + === git === + diff --git a/file1 b/file1 + index 422c2b7ab3..de980441c3 100644 + --- a/file1 + +++ b/file1 + @@ -1,2 +1,3 @@ + a + b + +c + diff --git a/file2 b/file2 + index 7898192261..9ddeb5c484 100644 + --- a/file2 + +++ b/file2 + @@ -1,1 +1,2 @@ + -a + +b + +c + === stat === + repo/file1 | 1 + + repo/file2 | 3 ++- + 2 files changed, 3 insertions(+), 1 deletion(-) + === summary === + M repo/file1 + M repo/file2 + "###); + + // color_words() with parameters + let template = "self.diff('file1').color_words(0)"; + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "--no-graph", "-r@", "-T", template]); + insta::assert_snapshot!(stdout, @r###" + Modified regular file file1: + ... + 3: c + "###); + + // git() with parameters + let template = "self.diff('file1').git(1)"; + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "--no-graph", "-r@", "-T", template]); + insta::assert_snapshot!(stdout, @r###" + diff --git a/file1 b/file1 + index 422c2b7ab3..de980441c3 100644 + --- a/file1 + +++ b/file1 + @@ -2,1 +2,2 @@ + b + +c + "###); +} diff --git a/docs/templates.md b/docs/templates.md index 6317925514..b71c88afec 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -96,6 +96,9 @@ This type cannot be printed. The following methods are defined. * `contained_in(revset: String) -> Boolean`: True if the commit is included in [the provided revset](revsets.md). * `conflict() -> Boolean`: True if the commit contains merge conflicts. * `empty() -> Boolean`: True if the commit modifies no files. +* `diff([files: String]) -> TreeDiff`: Changes from the parents within [the + `files` expression](filesets.md). All files are compared by default, but it is + likely to change in future version to respect the command line path arguments. * `root() -> Boolean`: True if the commit is the root commit. ### CommitId / ChangeId type @@ -262,6 +265,16 @@ The following methods are defined. * `.end() -> Timestamp` * `.duration() -> String` +### TreeDiff type + +This type cannot be printed. The following methods are defined. + +* `.color_words([context: Integer]) -> Template`: Format as a word-level diff + with changes indicated only by color. +* `.git([context: Integer]) -> Template`: Format as a Git diff. +* `.stat(width: Integer) -> Template`: Format as a histogram of the changes. +* `.summary() -> Template`: Format as a list of status code and path pairs. + ## Configuration The default templates and aliases() are defined in the `[templates]` and