]> git.ipfire.org Git - thirdparty/git.git/commitdiff
rebase: support --trailer
authorLi Chen <chenl311@chinatelecom.cn>
Wed, 5 Nov 2025 14:29:44 +0000 (22:29 +0800)
committerJunio C Hamano <gitster@pobox.com>
Thu, 6 Nov 2025 17:45:00 +0000 (09:45 -0800)
Implement a new `--trailer <text>` option for `git rebase`
(support merge backend only now), which appends arbitrary
trailer lines to each rebased commit message.

Reject it if the user passes an option that requires the
apply backend (git am) since it lacks message‑filter/trailer
hook. otherwise we can just use the merge backend.

Automatically set REBASE_FORCE when any trailer is supplied.

And reject invalid input before user edits the interactive file.

Signed-off-by: Li Chen <chenl311@chinatelecom.cn>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
Documentation/git-rebase.adoc
builtin/rebase.c
sequencer.c
sequencer.h
t/meson.build
t/t3440-rebase-trailer.sh [new file with mode: 0755]
trailer.c
trailer.h

index 005caf61646ec74a448000564ca75038bb342142..4d2fe4be6e66cdee3193e4ce29d7e0b9783f237e 100644 (file)
@@ -487,9 +487,16 @@ See also INCOMPATIBLE OPTIONS below.
        Add a `Signed-off-by` trailer to all the rebased commits. Note
        that if `--interactive` is given then only commits marked to be
        picked, edited or reworded will have the trailer added.
-+
+
 See also INCOMPATIBLE OPTIONS below.
 
+--trailer=<trailer>::
+       Append the given trailer line(s) to every rebased commit
+       message, processed via linkgit:git-interpret-trailers[1].
+       When this option is present *rebase automatically implies*
+       `--force-rebase` so that fast‑forwarded commits are also
+       rewritten.
+
 -i::
 --interactive::
        Make a list of the commits which are about to be rebased.  Let the
index c46882818982aa516f2c0444cb7e3b9d4208753a..a88abe08b47c8328a60bc9ad716f152f750e2516 100644 (file)
@@ -36,6 +36,7 @@
 #include "reset.h"
 #include "trace2.h"
 #include "hook.h"
+#include "trailer.h"
 
 static char const * const builtin_rebase_usage[] = {
        N_("git rebase [-i] [options] [--exec <cmd>] "
@@ -113,6 +114,7 @@ struct rebase_options {
        enum action action;
        char *reflog_action;
        int signoff;
+       struct strvec trailer_args;
        int allow_rerere_autoupdate;
        int keep_empty;
        int autosquash;
@@ -143,6 +145,7 @@ struct rebase_options {
                .flags = REBASE_NO_QUIET,               \
                .git_am_opts = STRVEC_INIT,             \
                .exec = STRING_LIST_INIT_NODUP,         \
+               .trailer_args = STRVEC_INIT,  \
                .git_format_patch_opt = STRBUF_INIT,    \
                .fork_point = -1,                       \
                .reapply_cherry_picks = -1,             \
@@ -166,6 +169,7 @@ static void rebase_options_release(struct rebase_options *opts)
        free(opts->strategy);
        string_list_clear(&opts->strategy_opts, 0);
        strbuf_release(&opts->git_format_patch_opt);
+       strvec_clear(&opts->trailer_args);
 }
 
 static struct replay_opts get_replay_opts(const struct rebase_options *opts)
@@ -177,6 +181,10 @@ static struct replay_opts get_replay_opts(const struct rebase_options *opts)
        sequencer_init_config(&replay);
 
        replay.signoff = opts->signoff;
+
+       for (size_t i = 0; i < opts->trailer_args.nr; i++)
+               strvec_push(&replay.trailer_args, opts->trailer_args.v[i]);
+
        replay.allow_ff = !(opts->flags & REBASE_FORCE);
        if (opts->allow_rerere_autoupdate)
                replay.allow_rerere_auto = opts->allow_rerere_autoupdate;
@@ -500,6 +508,23 @@ static int read_basic_state(struct rebase_options *opts)
                opts->gpg_sign_opt = xstrdup(buf.buf);
        }
 
+       strbuf_reset(&buf);
+
+       if (strbuf_read_file(&buf, state_dir_path("trailer", opts), 0) >= 0) {
+               const char *p = buf.buf, *end = buf.buf + buf.len;
+
+               while (p < end) {
+                       char *nl = memchr(p, '\n', end - p);
+                       if (!nl)
+                               die("nl shouldn't be NULL");
+                       *nl = '\0';
+
+                       if (*p)
+                               strvec_push(&opts->trailer_args, p);
+
+                       p = nl + 1;
+               }
+       }
        strbuf_release(&buf);
 
        return 0;
@@ -528,6 +553,21 @@ static int rebase_write_basic_state(struct rebase_options *opts)
        if (opts->signoff)
                write_file(state_dir_path("signoff", opts), "--signoff");
 
+       /*
+        * save opts->trailer_args into state_dir/trailer
+        */
+       if (opts->trailer_args.nr) {
+               struct strbuf buf = STRBUF_INIT;
+
+               for (size_t i = 0; i < opts->trailer_args.nr; i++) {
+                               strbuf_addstr(&buf, opts->trailer_args.v[i]);
+                               strbuf_addch(&buf, '\n');
+               }
+               write_file(state_dir_path("trailer", opts),
+                                  "%s", buf.buf);
+               strbuf_release(&buf);
+       }
+
        return 0;
 }
 
@@ -1132,6 +1172,8 @@ int cmd_rebase(int argc,
                        .flags = PARSE_OPT_NOARG,
                        .defval = REBASE_DIFFSTAT,
                },
+               OPT_STRVEC(0, "trailer", &options.trailer_args, N_("trailer"),
+                               N_("add custom trailer(s)")),
                OPT_BOOL(0, "signoff", &options.signoff,
                         N_("add a Signed-off-by trailer to each commit")),
                OPT_BOOL(0, "committer-date-is-author-date",
@@ -1285,6 +1327,11 @@ int cmd_rebase(int argc,
                             builtin_rebase_options,
                             builtin_rebase_usage, 0);
 
+       if (options.trailer_args.nr) {
+               validate_trailer_args_after_config(&options.trailer_args);
+               options.flags |= REBASE_FORCE;
+       }
+
        if (preserve_merges_selected)
                die(_("--preserve-merges was replaced by --rebase-merges\n"
                        "Note: Your `pull.rebase` configuration may also be set to 'preserve',\n"
@@ -1542,6 +1589,9 @@ int cmd_rebase(int argc,
        if (options.root && !options.onto_name)
                imply_merge(&options, "--root without --onto");
 
+       if (options.trailer_args.nr)
+               imply_merge(&options, "--trailer");
+
        if (isatty(2) && options.flags & REBASE_NO_QUIET)
                strbuf_addstr(&options.git_format_patch_opt, " --progress");
 
index 5476d39ba9b0970d6a8d1ef80ece8f11b88279b1..fbf35cb474ba14c1fec7e7a56edafd70c418400c 100644 (file)
@@ -209,6 +209,7 @@ static GIT_PATH_FUNC(rebase_path_reschedule_failed_exec, "rebase-merge/reschedul
 static GIT_PATH_FUNC(rebase_path_no_reschedule_failed_exec, "rebase-merge/no-reschedule-failed-exec")
 static GIT_PATH_FUNC(rebase_path_drop_redundant_commits, "rebase-merge/drop_redundant_commits")
 static GIT_PATH_FUNC(rebase_path_keep_redundant_commits, "rebase-merge/keep_redundant_commits")
+static GIT_PATH_FUNC(rebase_path_trailer, "rebase-merge/trailer")
 
 /*
  * A 'struct replay_ctx' represents the private state of the sequencer.
@@ -420,6 +421,7 @@ void replay_opts_release(struct replay_opts *opts)
        if (opts->revs)
                release_revisions(opts->revs);
        free(opts->revs);
+       strvec_clear(&opts->trailer_args);
        replay_ctx_release(ctx);
        free(opts->ctx);
 }
@@ -2025,6 +2027,10 @@ static int append_squash_message(struct strbuf *buf, const char *body,
                if (opts->signoff)
                        append_signoff(buf, 0, 0);
 
+               if (opts->trailer_args.nr &&
+                       amend_strbuf_with_trailers(buf, &opts->trailer_args))
+                       return error(_("unable to add trailers to commit message"));
+
                if ((command == TODO_FIXUP) &&
                    (flag & TODO_REPLACE_FIXUP_MSG) &&
                    (file_exists(rebase_path_fixup_msg()) ||
@@ -2443,6 +2449,14 @@ static int do_pick_commit(struct repository *r,
        if (opts->signoff && !is_fixup(command))
                append_signoff(&ctx->message, 0, 0);
 
+       if (opts->trailer_args.nr && !is_fixup(command)) {
+               if (amend_strbuf_with_trailers(&ctx->message,
+                                              &opts->trailer_args)) {
+                       res = error(_("unable to add trailers to commit message"));
+                       goto leave;
+               }
+       }
+
        if (is_rebase_i(opts) && write_author_script(msg.message) < 0)
                res = -1;
        else if (!opts->strategy ||
@@ -2517,6 +2531,7 @@ static int do_pick_commit(struct repository *r,
                        _("dropping %s %s -- patch contents already upstream\n"),
                        oid_to_hex(&commit->object.oid), msg.subject);
        } /* else allow == 0 and there's nothing special to do */
+
        if (!opts->no_commit && !drop_commit) {
                if (author || command == TODO_REVERT || (flags & AMEND_MSG))
                        res = do_commit(r, msg_file, author, reflog_action,
@@ -3234,6 +3249,17 @@ static int read_populate_opts(struct replay_opts *opts)
 
                read_strategy_opts(opts, &buf);
                strbuf_reset(&buf);
+               if (strbuf_read_file(&buf, rebase_path_trailer(), 0) >= 0) {
+                       char *p = buf.buf, *nl;
+
+                       while ((nl = strchr(p, '\n'))) {
+                               *nl = '\0';
+                               if (*p)
+                                       strvec_push(&opts->trailer_args, p);
+                               p = nl + 1;
+                       }
+                       strbuf_reset(&buf);
+               }
 
                if (read_oneliner(&ctx->current_fixups,
                                  rebase_path_current_fixups(),
@@ -3328,6 +3354,14 @@ int write_basic_state(struct replay_opts *opts, const char *head_name,
                write_file(rebase_path_reschedule_failed_exec(), "%s", "");
        else
                write_file(rebase_path_no_reschedule_failed_exec(), "%s", "");
+       if (opts->trailer_args.nr) {
+               struct strbuf buf = STRBUF_INIT;
+
+               for (size_t i = 0; i < opts->trailer_args.nr; i++)
+                       strbuf_addf(&buf, "%s\n", opts->trailer_args.v[i]);
+               write_file(rebase_path_trailer(), "%s", buf.buf);
+               strbuf_release(&buf);
+       }
 
        return 0;
 }
index 719684c8a9fb2e5ab10464e3edf1726097fe33c8..e21835c5a0d186a441786c4cd6841f96ddc30495 100644 (file)
@@ -44,6 +44,7 @@ struct replay_opts {
        int record_origin;
        int no_commit;
        int signoff;
+       struct strvec trailer_args;
        int allow_ff;
        int allow_rerere_auto;
        int allow_empty;
@@ -82,8 +83,9 @@ struct replay_opts {
        struct replay_ctx *ctx;
 };
 #define REPLAY_OPTS_INIT {                     \
-       .edit = -1,                             \
        .action = -1,                           \
+       .edit = -1,                             \
+       .trailer_args = STRVEC_INIT, \
        .xopts = STRVEC_INIT,                   \
        .ctx = replay_ctx_new(),                \
 }
index a5531df415ffe2cda8ff81fd688e9cb1e6717ab4..56bc3291ced13efe36f6f5557566e47f0b258a3c 100644 (file)
@@ -385,6 +385,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3440-rebase-trailer.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3440-rebase-trailer.sh b/t/t3440-rebase-trailer.sh
new file mode 100755 (executable)
index 0000000..d0e0434
--- /dev/null
@@ -0,0 +1,134 @@
+#!/bin/sh
+#
+
+test_description='git rebase --trailer integration tests
+We verify that --trailer works with the merge backend,
+and that it is rejected early when the apply backend is requested.'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+. "$TEST_DIRECTORY"/lib-rebase.sh # test_commit_message, helpers
+
+REVIEWED_BY_TRAILER="Reviewed-by: Dev <dev@example.com>"
+
+expect_trailer_msg() {
+       test_commit_message "$1" <<-EOF
+       $2
+
+       ${3:-$REVIEWED_BY_TRAILER}
+       EOF
+}
+
+test_expect_success 'setup repo with a small history' '
+       git commit --allow-empty -m "Initial empty commit" &&
+       test_commit first file a &&
+       test_commit second file &&
+       git checkout -b conflict-branch first &&
+       test_commit file-2 file-2 &&
+       test_commit conflict file &&
+       test_commit third file
+'
+
+test_expect_success 'apply backend is rejected with --trailer' '
+       head_before=$(git rev-parse HEAD) &&
+       test_expect_code 128 \
+       git rebase --apply --trailer "$REVIEWED_BY_TRAILER" \
+                               HEAD^ 2>err &&
+       test_grep "fatal: --trailer requires the merge backend" err &&
+       test_cmp_rev HEAD $head_before
+'
+
+test_expect_success 'reject empty --trailer argument' '
+       test_expect_code 128 git rebase -m --trailer "" HEAD^ 2>err &&
+       test_grep "empty --trailer" err
+'
+
+test_expect_success 'reject trailer with missing key before separator' '
+       test_expect_code 128 git rebase -m --trailer ": no-key" HEAD^ 2>err &&
+       test_grep "missing key before separator" err
+'
+
+test_expect_success 'allow trailer with missing value after separator' '
+       git rebase -m --trailer "Acked-by:" HEAD~1 third &&
+       sed -e "s/_/ /g" <<-\EOF >expect &&
+       third
+
+       Acked-by:_
+       EOF
+       test_commit_message HEAD expect
+'
+
+test_expect_success 'CLI trailer duplicates allowed; replace policy keeps last' '
+       git -c trailer.Bug.ifexists=replace -c trailer.Bug.ifmissing=add \
+               rebase -m --trailer "Bug: 123" --trailer "Bug: 456" HEAD~1 third &&
+       cat >expect <<-\EOF &&
+       third
+
+       Bug: 456
+       EOF
+       test_commit_message HEAD expect
+'
+
+test_expect_success 'multiple Signed-off-by trailers all preserved' '
+       git rebase -m \
+                       --trailer "Signed-off-by: Dev A <a@example.com>" \
+                       --trailer "Signed-off-by: Dev B <b@example.com>" HEAD~1 third &&
+       cat >expect <<-\EOF &&
+       third
+
+       Signed-off-by: Dev A <a@example.com>
+       Signed-off-by: Dev B <b@example.com>
+       EOF
+       test_commit_message HEAD expect
+'
+
+test_expect_success 'rebase -m --trailer adds trailer after conflicts' '
+       git checkout -B conflict-branch third &&
+       test_commit fourth file &&
+       test_must_fail git rebase -m \
+                       --trailer "$REVIEWED_BY_TRAILER" \
+                       second &&
+       git checkout --theirs file &&
+       git add file &&
+       git rebase --continue &&
+       expect_trailer_msg HEAD "fourth" &&
+       expect_trailer_msg HEAD^ "third"
+'
+
+test_expect_success '--trailer handles fixup commands in todo list' '
+       git checkout -B fixup-trailer HEAD &&
+       test_commit fixup-base base &&
+       test_commit fixup-second second &&
+       first_short=$(git rev-parse --short fixup-base) &&
+       second_short=$(git rev-parse --short fixup-second) &&
+       cat >todo <<EOF &&
+pick $first_short fixup-base
+fixup $second_short fixup-second
+EOF
+       (
+               set_replace_editor todo &&
+               git rebase -i --trailer "$REVIEWED_BY_TRAILER" HEAD~2
+       ) &&
+       expect_trailer_msg HEAD "fixup-base" &&
+       git reset --hard fixup-second &&
+       cat >todo <<EOF &&
+pick $first_short fixup-base
+fixup -C $second_short fixup-second
+EOF
+       (
+               set_replace_editor todo &&
+               git rebase -i --trailer "$REVIEWED_BY_TRAILER" HEAD~2
+       ) &&
+       expect_trailer_msg HEAD "fixup-second"
+'
+
+test_expect_success 'rebase --root --trailer updates every commit' '
+       git checkout first &&
+       git -c trailer.review.key=Reviewed-by rebase --root \
+               --trailer=review="Dev <dev@example.com>" &&
+       expect_trailer_msg HEAD  "first" &&
+       expect_trailer_msg HEAD^ "Initial empty commit"
+'
+test_done
index f5838f5699e3b05614126e5c4e1ef6e551cbb7ba..f6ff2f01ee9b9039e393aa0c66baea1a41b62af3 100644 (file)
--- a/trailer.c
+++ b/trailer.c
@@ -7,6 +7,7 @@
 #include "string-list.h"
 #include "run-command.h"
 #include "commit.h"
+#include "strvec.h"
 #include "trailer.h"
 #include "list.h"
 #include "wrapper.h"
@@ -774,6 +775,30 @@ void parse_trailers_from_command_line_args(struct list_head *arg_head,
        free(cl_separators);
 }
 
+void validate_trailer_args_after_config(const struct strvec *cli_args)
+{
+       char *cl_separators;
+
+       trailer_config_init();
+
+       cl_separators = xstrfmt("=%s", separators);
+
+       for (size_t i = 0; i < cli_args->nr; i++) {
+               const char *txt = cli_args->v[i];
+               ssize_t separator_pos;
+
+               if (!*txt)
+                       die(_("empty --trailer argument"));
+
+               separator_pos = find_separator(txt, cl_separators);
+               if (separator_pos == 0)
+                       die(_("invalid trailer '%s': missing key before separator"),
+                   txt);
+       }
+
+       free(cl_separators);
+}
+
 static const char *next_line(const char *str)
 {
        const char *nl = strchrnul(str, '\n');
@@ -1226,8 +1251,8 @@ void trailer_iterator_release(struct trailer_iterator *iter)
        strbuf_release(&iter->key);
 }
 
-static int amend_strbuf_with_trailers(struct strbuf *buf,
-                                     const struct strvec *trailer_args)
+int amend_strbuf_with_trailers(struct strbuf *buf,
+                              const struct strvec *trailer_args)
 {
        struct process_trailer_options opts = PROCESS_TRAILER_OPTIONS_INIT;
        LIST_HEAD(new_trailer_head);
index daea46ca5d358cff93c61ab35f05bb6475666e82..541657a11f52c73f4657b848a0fb7a8002596394 100644 (file)
--- a/trailer.h
+++ b/trailer.h
@@ -68,6 +68,8 @@ void parse_trailers_from_config(struct list_head *config_head);
 void parse_trailers_from_command_line_args(struct list_head *arg_head,
                                           struct list_head *new_trailer_head);
 
+void validate_trailer_args_after_config(const struct strvec *cli_args);
+
 void process_trailers_lists(struct list_head *head,
                            struct list_head *arg_head);
 
@@ -195,6 +197,9 @@ int trailer_iterator_advance(struct trailer_iterator *iter);
  */
 void trailer_iterator_release(struct trailer_iterator *iter);
 
+int amend_strbuf_with_trailers(struct strbuf *buf,
+                              const struct strvec *trailer_args);
+
 /*
  * Augment a file to add trailers to it (similar to 'git interpret-trailers').
  * Returns 0 on success or a non-zero error code on failure.