Repository: jj-vcs/jj
Pull request #6419: templates: implement `Commit.trailers()`
Author: login: mateusauler
Created: 2025-04-26T20:31:34Z | Merged: 2025-04-27T18:42:59Z

<!--
There's no need to add anything here, but feel free to add a personal message.
Please describe the changes in this PR in the commit message(s) instead, with
each commit representing one logical change. Address code review comments by
rewriting the commits rather than adding commits on top. Use force-push when
pushing the updated commits (`jj git push` does that automatically when you
rewrite commits). Merge the PR at will once it's been approved. See
https://github.com/jj-vcs/jj/blob/main/docs/contributing.md for details.
Note that you need to sign Google's CLA to contribute.
-->

A couple of things I'm unsure of:
- ~Where should I create tests for this?~ (nvm, found it)
- Should `key()` and `value()` be the `Trailer` method names?

# Checklist

If applicable:
- [x] I have updated `CHANGELOG.md`
- [x] I have updated the documentation (README.md, docs/, demos/)
- [ ] I have updated the config schema (cli/src/config-schema.json)
- [x] I have added tests to cover my changes



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3303b45c496..b0ab081a22d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 * `jj git fetch` now warns instead of erroring for unknown `git.fetch` remotes
   if other remotes are available.
 
+* Commit objects in templates now have `trailers() -> List<Trailer>`, the Trailer
+  objects have `key() -> String` and `value() -> String`.
+
 ### Fixed bugs
 
 * Fixed crash on change-delete conflict resolution.
diff --git a/cli/src/commit_templater.rs b/cli/src/commit_templater.rs
index f646a3a1d4d..a525583e24b 100644
--- a/cli/src/commit_templater.rs
+++ b/cli/src/commit_templater.rs
@@ -64,6 +64,8 @@ use jj_lib::signing::SignError;
 use jj_lib::signing::SignResult;
 use jj_lib::signing::Verification;
 use jj_lib::store::Store;
+use jj_lib::trailer;
+use jj_lib::trailer::Trailer;
 use once_cell::unsync::OnceCell;
 use pollster::FutureExt as _;
 
@@ -332,6 +334,23 @@ impl<'repo> TemplateLanguage<'repo> for CommitTemplateLanguage<'repo> {
                 let build = template_parser::lookup_method(type_name, table, function)?;
                 build(self, diagnostics, build_ctx, property, function)
             }
+            CommitTemplatePropertyKind::Trailer(property) => {
+                let table = &self.build_fn_table.trailer_methods;
+                let build = template_parser::lookup_method(type_name, table, function)?;
+                build(self, diagnostics, build_ctx, property, function)
+            }
+            CommitTemplatePropertyKind::TrailerList(property) => {
+                // TODO: migrate to table?
+                template_builder::build_formattable_list_method(
+                    self,
+                    diagnostics,
+                    build_ctx,
+                    property,
+                    function,
+                    Self::wrap_trailer,
+                    Self::wrap_trailer_list,
+                )
+            }
         }
     }
 }
@@ -456,6 +475,18 @@ impl<'repo> CommitTemplateLanguage<'repo> {
     ) -> CommitTemplatePropertyKind<'repo> {
         CommitTemplatePropertyKind::AnnotationLine(Box::new(property))
     }
+
+    pub fn wrap_trailer(
+        property: impl TemplateProperty<Output = Trailer> + 'repo,
+    ) -> CommitTemplatePropertyKind<'repo> {
+        CommitTemplatePropertyKind::Trailer(Box::new(property))
+    }
+
+    pub fn wrap_trailer_list(
+        property: impl TemplateProperty<Output = Vec<Trailer>> + 'repo,
+    ) -> CommitTemplatePropertyKind<'repo> {
+        CommitTemplatePropertyKind::TrailerList(Box::new(property))
+    }
 }
 
 pub enum CommitTemplatePropertyKind<'repo> {
@@ -479,6 +510,8 @@ pub enum CommitTemplatePropertyKind<'repo> {
         Box<dyn TemplateProperty<Output = Option<CryptographicSignature>> + 'repo>,
     ),
     AnnotationLine(Box<dyn TemplateProperty<Output = AnnotationLine> + 'repo>),
+    Trailer(Box<dyn TemplateProperty<Output = Trailer> + 'repo>),
+    TrailerList(Box<dyn TemplateProperty<Output = Vec<Trailer>> + 'repo>),
 }
 
 impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> {
@@ -504,6 +537,8 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> {
                 "Option<CryptographicSignature>"
             }
             CommitTemplatePropertyKind::AnnotationLine(_) => "AnnotationLine",
+            CommitTemplatePropertyKind::Trailer(_) => "Trailer",
+            CommitTemplatePropertyKind::TrailerList(_) => "List<Trailer>",
         }
     }
 
@@ -543,6 +578,10 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> {
                 Some(Box::new(property.map(|sig| sig.is_some())))
             }
             CommitTemplatePropertyKind::AnnotationLine(_) => None,
+            CommitTemplatePropertyKind::Trailer(_) => None,
+            CommitTemplatePropertyKind::TrailerList(property) => {
+                Some(Box::new(property.map(|l| !l.is_empty())))
+            }
         }
     }
 
@@ -587,6 +626,8 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> {
             CommitTemplatePropertyKind::DiffStats(property) => Some(property.into_template()),
             CommitTemplatePropertyKind::CryptographicSignatureOpt(_) => None,
             CommitTemplatePropertyKind::AnnotationLine(_) => None,
+            CommitTemplatePropertyKind::Trailer(property) => Some(property.into_template()),
+            CommitTemplatePropertyKind::TrailerList(property) => Some(property.into_template()),
         }
     }
 
@@ -613,6 +654,8 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> {
             (CommitTemplatePropertyKind::DiffStats(_), _) => None,
             (CommitTemplatePropertyKind::CryptographicSignatureOpt(_), _) => None,
             (CommitTemplatePropertyKind::AnnotationLine(_), _) => None,
+            (CommitTemplatePropertyKind::Trailer(_), _) => None,
+            (CommitTemplatePropertyKind::TrailerList(_), _) => None,
         }
     }
 
@@ -642,6 +685,8 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> {
             (CommitTemplatePropertyKind::DiffStats(_), _) => None,
             (CommitTemplatePropertyKind::CryptographicSignatureOpt(_), _) => None,
             (CommitTemplatePropertyKind::AnnotationLine(_), _) => None,
+            (CommitTemplatePropertyKind::Trailer(_), _) => None,
+            (CommitTemplatePropertyKind::TrailerList(_), _) => None,
         }
     }
 }
@@ -665,6 +710,7 @@ pub struct CommitTemplateBuildFnTable<'repo> {
     pub cryptographic_signature_methods:
         CommitTemplateBuildMethodFnMap<'repo, CryptographicSignature>,
     pub annotation_line_methods: CommitTemplateBuildMethodFnMap<'repo, AnnotationLine>,
+    pub trailer_methods: CommitTemplateBuildMethodFnMap<'repo, Trailer>,
 }
 
 impl<'repo> CommitTemplateBuildFnTable<'repo> {
@@ -683,6 +729,7 @@ impl<'repo> CommitTemplateBuildFnTable<'repo> {
             diff_stats_methods: builtin_diff_stats_methods(),
             cryptographic_signature_methods: builtin_cryptographic_signature_methods(),
             annotation_line_methods: builtin_annotation_line_methods(),
+            trailer_methods: builtin_trailer_methods(),
         }
     }
 
@@ -700,6 +747,7 @@ impl<'repo> CommitTemplateBuildFnTable<'repo> {
             diff_stats_methods: HashMap::new(),
             cryptographic_signature_methods: HashMap::new(),
             annotation_line_methods: HashMap::new(),
+            trailer_methods: HashMap::new(),
         }
     }
 
@@ -717,6 +765,7 @@ impl<'repo> CommitTemplateBuildFnTable<'repo> {
             diff_stats_methods,
             cryptographic_signature_methods,
             annotation_line_methods,
+            trailer_methods,
         } = extension;
 
         self.core.merge(core);
@@ -740,6 +789,7 @@ impl<'repo> CommitTemplateBuildFnTable<'repo> {
             cryptographic_signature_methods,
         );
         merge_fn_map(&mut self.annotation_line_methods, annotation_line_methods);
+        merge_fn_map(&mut self.trailer_methods, trailer_methods);
     }
 }
 
@@ -798,6 +848,15 @@ fn builtin_commit_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, Comm
             Ok(L::wrap_string(out_property))
         },
     );
+    map.insert(
+        "trailers",
+        |_language, _diagnostics, _build_ctx, self_property, function| {
+            function.expect_no_arguments()?;
+            let out_property = self_property
+                .map(|commit| trailer::parse_description_trailers(commit.description()));
+            Ok(L::wrap_trailer_list(out_property))
+        },
+    );
     map.insert(
         "change_id",
         |_language, _diagnostics, _build_ctx, self_property, function| {
@@ -2264,3 +2323,37 @@ pub fn builtin_annotation_line_methods<'repo>(
     );
     map
 }
+
+impl Template for Trailer {
+    fn format(&self, formatter: &mut TemplateFormatter) -> io::Result<()> {
+        write!(formatter, "{}: {}", self.key, self.value)
+    }
+}
+
+impl Template for Vec<Trailer> {
+    fn format(&self, formatter: &mut TemplateFormatter) -> io::Result<()> {
+        templater::format_joined(formatter, self, "\n")
+    }
+}
+
+fn builtin_trailer_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, Trailer> {
+    type L<'repo> = CommitTemplateLanguage<'repo>;
+    let mut map = CommitTemplateBuildMethodFnMap::<Trailer>::new();
+    map.insert(
+        "key",
+        |_language, _diagnostics, _build_ctx, self_property, function| {
+            function.expect_no_arguments()?;
+            let out_property = self_property.map(|trailer| trailer.key);
+            Ok(L::wrap_string(out_property))
+        },
+    );
+    map.insert(
+        "value",
+        |_language, _diagnostics, _build_ctx, self_property, function| {
+            function.expect_no_arguments()?;
+            let out_property = self_property.map(|trailer| trailer.value);
+            Ok(L::wrap_string(out_property))
+        },
+    );
+    map
+}
diff --git a/cli/tests/test_commit_template.rs b/cli/tests/test_commit_template.rs
index 41466d3e190..eeb6bca564f 100644
--- a/cli/tests/test_commit_template.rs
+++ b/cli/tests/test_commit_template.rs
@@ -1674,4 +1674,39 @@ fn test_log_format_trailers() {
     Signed-off-by: Test User <test.user@example.com>
     [EOF]
     ");
+
+    work_dir
+        .run_jj([
+            "describe",
+            "-r@",
+            "-m",
+            "a change with trailers",
+            r#"--config=templates.commit_trailers="format_signed_off_by_trailer(self) ++ format_gerrit_change_id_trailer(self)""#,
+        ])
+        .success();
+
+    let output = work_dir.run_jj(["log", "--no-graph", "-T", r#"trailers ++ "\n""#, "-r@"]);
+    insta::assert_snapshot!(output, @r"
+    Signed-off-by: Test User <test.user@example.com>
+    Change-Id: I6a6a69649a45c67d3e96a7e5007c110ede34dec5
+    [EOF]
+    ");
+
+    let output = work_dir.run_jj([
+        "log",
+        "--no-graph",
+        "-T",
+        "trailers.map(|t| t.key())",
+        "-r@",
+    ]);
+    insta::assert_snapshot!(output, @"Signed-off-by Change-Id[EOF]");
+
+    let output = work_dir.run_jj([
+        "log",
+        "--no-graph",
+        "-T",
+        "trailers.map(|t| t.value())",
+        "-r@",
+    ]);
+    insta::assert_snapshot!(output, @"Test User <test.user@example.com> I6a6a69649a45c67d3e96a7e5007c110ede34dec5[EOF]");
 }
diff --git a/docs/templates.md b/docs/templates.md
index c1c3d4987f6..25da74e3840 100644
--- a/docs/templates.md
+++ b/docs/templates.md
@@ -109,6 +109,7 @@ No methods are defined. Can be constructed with `false` or `true` literal.
 This type cannot be printed. The following methods are defined.
 
 * `.description() -> String`
+* `.trailers() -> List<Trailer>`
 * `.change_id() -> ChangeId`
 * `.commit_id() -> CommitId`
 * `.parents() -> List<Commit>`
@@ -385,6 +386,13 @@ The following methods are defined.
 * `.end() -> Timestamp`
 * `.duration() -> String`
 
+### Trailer type
+
+The following methods are defined.
+
+* `.key() -> String`
+* `.value() -> String`
+
 ### TreeDiff type
 
 This type cannot be printed. The following methods are defined.


Reviews:
user: login: yuja
body: The code looks good to me overall, thanks.

Can you squash test and doc changes?
https://jj-vcs.github.io/jj/latest/contributing/#commit-guidelines
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T01:52:35Z
user: login: mateusauler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T02:44:28Z
user: login: mateusauler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T02:49:13Z
user: login: yuja
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T06:17:27Z
user: login: mateusauler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T17:23:16Z

Review comments:
diff_hunk: @@ -479,6 +510,8 @@ pub enum CommitTemplatePropertyKind<'repo> {
         Box<dyn TemplateProperty<Output = Option<CryptographicSignature>> + 'repo>,
     ),
     AnnotationLine(Box<dyn TemplateProperty<Output = AnnotationLine> + 'repo>),
+    Trailer(Box<dyn TemplateProperty<Output = Trailer> + 'repo>),
+    Trailers(Box<dyn TemplateProperty<Output = Vec<Trailer>> + 'repo>),
path: cli/src/commit_templater.rs
user: login: yuja
body: nit: `TrailerList` (and `trailer_list`) per convention
created_at: 2025-04-27T01:47:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -543,6 +578,8 @@ impl<'repo> IntoTemplateProperty<'repo> for CommitTemplatePropertyKind<'repo> {
                 Some(Box::new(property.map(|sig| sig.is_some())))
             }
             CommitTemplatePropertyKind::AnnotationLine(_) => None,
+            CommitTemplatePropertyKind::Trailer(_) => None,
+            CommitTemplatePropertyKind::Trailers(_) => None,
path: cli/src/commit_templater.rs
user: login: yuja
body: List types support implicit conversion to bool
created_at: 2025-04-27T01:47:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1344,6 +1401,18 @@ impl Template for Vec<Rc<CommitRef>> {
     }
 }
 
+impl Template for Trailer {
+    fn format(&self, formatter: &mut TemplateFormatter) -> io::Result<()> {
+        write!(formatter, "{}: {}", self.key, self.value)
+    }
+}
+
+impl Template for Vec<Trailer> {
+    fn format(&self, formatter: &mut TemplateFormatter) -> io::Result<()> {
+        templater::format_joined(formatter, self, "\n")
+    }
+}
+
path: cli/src/commit_templater.rs
user: login: yuja
body: nit: can you move these closer to `builtin_trailer_methods()`?
created_at: 2025-04-27T01:49:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2264,3 +2333,25 @@ pub fn builtin_annotation_line_methods<'repo>(
     );
     map
 }
+
+pub fn builtin_trailer_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, Trailer> {
path: cli/src/commit_templater.rs
user: login: yuja
body: nit: remove `pub` (appears that some existing functions unintentionally have `pub`)
created_at: 2025-04-27T01:50:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2264,3 +2333,25 @@ pub fn builtin_annotation_line_methods<'repo>(
     );
     map
 }
+
+pub fn builtin_trailer_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, Trailer> {
path: cli/src/commit_templater.rs
user: login: mateusauler
body: should I remove the unnecessary `pub`s from the other functions?
created_at: 2025-04-27T02:44:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -479,6 +510,8 @@ pub enum CommitTemplatePropertyKind<'repo> {
         Box<dyn TemplateProperty<Output = Option<CryptographicSignature>> + 'repo>,
     ),
     AnnotationLine(Box<dyn TemplateProperty<Output = AnnotationLine> + 'repo>),
+    Trailer(Box<dyn TemplateProperty<Output = Trailer> + 'repo>),
+    Trailers(Box<dyn TemplateProperty<Output = Vec<Trailer>> + 'repo>),
path: cli/src/commit_templater.rs
user: login: mateusauler
body: should I also change the exposed method name?
created_at: 2025-04-27T02:49:12Z
author_association: CONTRIBUTOR
path: CHANGELOG.md
user: login: yuja
body: > Can you squash test and doc changes?
> https://jj-vcs.github.io/jj/latest/contributing/#commit-guidelines

Oops, I meant squash test/doc changes into the implementation commit.

"Include tests and documentation in the same commit as the code they test and document."
created_at: 2025-04-27T06:12:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2264,3 +2333,25 @@ pub fn builtin_annotation_line_methods<'repo>(
     );
     map
 }
+
+pub fn builtin_trailer_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, Trailer> {
path: cli/src/commit_templater.rs
user: login: yuja
body: https://github.com/jj-vcs/jj/pull/6420
created_at: 2025-04-27T06:13:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -479,6 +510,8 @@ pub enum CommitTemplatePropertyKind<'repo> {
         Box<dyn TemplateProperty<Output = Option<CryptographicSignature>> + 'repo>,
     ),
     AnnotationLine(Box<dyn TemplateProperty<Output = AnnotationLine> + 'repo>),
+    Trailer(Box<dyn TemplateProperty<Output = Trailer> + 'repo>),
+    Trailers(Box<dyn TemplateProperty<Output = Vec<Trailer>> + 'repo>),
path: cli/src/commit_templater.rs
user: login: yuja
body: > should I also change the exposed method name?

You mean `commit.trailers()` to `.trailer_list()`? No. It's not a function/enum that processes an object of certain template type.
created_at: 2025-04-27T06:17:01Z
author_association: CONTRIBUTOR
path: CHANGELOG.md
user: login: mateusauler
body: I read that exact phrase and ignored the second half :/ sorry about that!
created_at: 2025-04-27T17:23:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: glehmann
created_at: 2025-04-27T17:05:01Z
body: I'm not sure we have everything necessary to check that a trailer key is defined in the trailers, as required for #6402.

We could have a `contains()` method in `List`:  `trailers.map(|t| b.key()).contains("Change-id")`
Or something more direct: `trailers.contains_key("Change-id")`, if it's possible to have a method specifically for the `List<Trailer>` type.
Or any better idea :-)
author_association: CONTRIBUTOR
user: login: mateusauler
created_at: 2025-04-27T18:15:33Z
body: > We could have a `contains()` method in `List`: `trailers.map(|t| b.key()).contains("Change-id")` Or something more direct: `trailers.contains_key("Change-id")`, if it's possible to have a method specifically for the `List<Trailer>` type. Or any better idea :-)

`!self.trailers().filter(|t| t.key() == "Change-Id")` works but is a bit janky.
I agree that `List` could have more methods. But at first glance, it requires some deeper modifications, mainly because `ListTemplate` doesn't implement `TemplateProperty`.


author_association: CONTRIBUTOR
user: login: glehmann
created_at: 2025-04-28T07:16:17Z
body: It works. For better readability and simpler syntax, I would prefer something like `commit.contains_trailer(key)`.
author_association: CONTRIBUTOR