rebase: support --trailer

Add a new --trailer=<trailer> option to git rebase to append trailer lines to each rewritten commit message (merge backend only). Because the apply backend does not provide a commit-message filter, reject --trailer when --apply is in effect and require the merge backend instead. This option implies --force-rebase so that fast-forwarded commits are also rewritten. Validate trailer arguments early to avoid starting an interactive rebase with invalid input. Add integration tests covering error paths and trailer insertion across non-interactive and interactive rebases. Signed-off-by: Li Chen <me@linux.beauty> Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Li Chen committed Mar 6, 2026 at 14:53 UTC e4f9d6b0ab2e1903765258991a6265599d0007ce
6 files changed +228 -2
Documentation/git-rebase.adoc
+8
@@ -497,6 +497,13 @@ See also INCOMPATIBLE OPTIONS below.
497 +
498 See also INCOMPATIBLE OPTIONS below.
499
500 +--trailer=<trailer>::
501 + Append the given trailer to every rebased commit message, processed
502 + via linkgit:git-interpret-trailers[1]. This option implies
503 + `--force-rebase`.
504 ++
505 +See also INCOMPATIBLE OPTIONS below.
506 +
507 -i::
508 --interactive::
509 Make a list of the commits which are about to be rebased. Let the
@@ -653,6 +660,7 @@ are incompatible with the following options:
660 * --[no-]reapply-cherry-picks when used without --keep-base
661 * --update-refs
662 * --root when used without --onto
663 + * --trailer
664
665 In addition, the following pairs of options are incompatible:
666
builtin/rebase.c
+19
@@ -36,6 +36,7 @@
36 #include "reset.h"
37 #include "trace2.h"
38 #include "hook.h"
39 +#include "trailer.h"
40
41 static char const * const builtin_rebase_usage[] = {
42 N_("git rebase [-i] [options] [--exec <cmd>] "
@@ -113,6 +114,7 @@ struct rebase_options {
114 enum action action;
115 char *reflog_action;
116 int signoff;
117 + struct strvec trailer_args;
118 int allow_rerere_autoupdate;
119 int keep_empty;
120 int autosquash;
@@ -143,6 +145,7 @@ struct rebase_options {
145 .flags = REBASE_NO_QUIET, \
146 .git_am_opts = STRVEC_INIT, \
147 .exec = STRING_LIST_INIT_NODUP, \
148 + .trailer_args = STRVEC_INIT, \
149 .git_format_patch_opt = STRBUF_INIT, \
150 .fork_point = -1, \
151 .reapply_cherry_picks = -1, \
@@ -166,6 +169,7 @@ static void rebase_options_release(struct rebase_options *opts)
169 free(opts->strategy);
170 string_list_clear(&opts->strategy_opts, 0);
171 strbuf_release(&opts->git_format_patch_opt);
172 + strvec_clear(&opts->trailer_args);
173 }
174
175 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)
181 sequencer_init_config(&replay);
182
183 replay.signoff = opts->signoff;
184 +
185 + for (size_t i = 0; i < opts->trailer_args.nr; i++)
186 + strvec_push(&replay.trailer_args, opts->trailer_args.v[i]);
187 +
188 replay.allow_ff = !(opts->flags & REBASE_FORCE);
189 if (opts->allow_rerere_autoupdate)
190 replay.allow_rerere_auto = opts->allow_rerere_autoupdate;
@@ -1132,6 +1140,8 @@ int cmd_rebase(int argc,
1140 .flags = PARSE_OPT_NOARG,
1141 .defval = REBASE_DIFFSTAT,
1142 },
1143 + OPT_STRVEC(0, "trailer", &options.trailer_args, N_("trailer"),
1144 + N_("add custom trailer(s)")),
1145 OPT_BOOL(0, "signoff", &options.signoff,
1146 N_("add a Signed-off-by trailer to each commit")),
1147 OPT_BOOL(0, "committer-date-is-author-date",
@@ -1285,6 +1295,12 @@ int cmd_rebase(int argc,
1295 builtin_rebase_options,
1296 builtin_rebase_usage, 0);
1297
1298 + if (options.trailer_args.nr) {
1299 + if (validate_trailer_args(&options.trailer_args))
1300 + die(NULL);
1301 + options.flags |= REBASE_FORCE;
1302 + }
1303 +
1304 if (preserve_merges_selected)
1305 die(_("--preserve-merges was replaced by --rebase-merges\n"
1306 "Note: Your `pull.rebase` configuration may also be set to 'preserve',\n"
@@ -1542,6 +1558,9 @@ int cmd_rebase(int argc,
1558 if (options.root && !options.onto_name)
1559 imply_merge(&options, "--root without --onto");
1560
1561 + if (options.trailer_args.nr)
1562 + imply_merge(&options, "--trailer");
1563 +
1564 if (isatty(2) && options.flags & REBASE_NO_QUIET)
1565 strbuf_addstr(&options.git_format_patch_opt, " --progress");
1566
sequencer.c
+50 -2
@@ -209,6 +209,7 @@ static GIT_PATH_FUNC(rebase_path_reschedule_failed_exec, "rebase-merge/reschedul
209 static GIT_PATH_FUNC(rebase_path_no_reschedule_failed_exec, "rebase-merge/no-reschedule-failed-exec")
210 static GIT_PATH_FUNC(rebase_path_drop_redundant_commits, "rebase-merge/drop_redundant_commits")
211 static GIT_PATH_FUNC(rebase_path_keep_redundant_commits, "rebase-merge/keep_redundant_commits")
212 +static GIT_PATH_FUNC(rebase_path_trailer, "rebase-merge/trailer")
213
214 /*
215 * A 'struct replay_ctx' represents the private state of the sequencer.
@@ -420,6 +421,7 @@ void replay_opts_release(struct replay_opts *opts)
421 if (opts->revs)
422 release_revisions(opts->revs);
423 free(opts->revs);
424 + strvec_clear(&opts->trailer_args);
425 replay_ctx_release(ctx);
426 free(opts->ctx);
427 }
@@ -2019,12 +2021,15 @@ static int append_squash_message(struct strbuf *buf, const char *body,
2021 if (is_fixup_flag(command, flag) && !seen_squash(ctx)) {
2022 /*
2023 * We're replacing the commit message so we need to
2022 - * append the Signed-off-by: trailer if the user
2023 - * requested '--signoff'.
2024 + * append any trailers if the user requested
2025 + * '--signoff' or '--trailer'.
2026 */
2027 if (opts->signoff)
2028 append_signoff(buf, 0, 0);
2029
2030 + if (opts->trailer_args.nr)
2031 + amend_strbuf_with_trailers(buf, &opts->trailer_args);
2032 +
2033 if ((command == TODO_FIXUP) &&
2034 (flag & TODO_REPLACE_FIXUP_MSG) &&
2035 (file_exists(rebase_path_fixup_msg()) ||
@@ -2443,6 +2448,9 @@ static int do_pick_commit(struct repository *r,
2448 if (opts->signoff && !is_fixup(command))
2449 append_signoff(&ctx->message, 0, 0);
2450
2451 + if (opts->trailer_args.nr && !is_fixup(command))
2452 + amend_strbuf_with_trailers(&ctx->message, &opts->trailer_args);
2453 +
2454 if (is_rebase_i(opts) && write_author_script(msg.message) < 0)
2455 res = -1;
2456 else if (!opts->strategy ||
@@ -3172,6 +3180,33 @@ static void read_strategy_opts(struct replay_opts *opts, struct strbuf *buf)
3180 parse_strategy_opts(opts, buf->buf);
3181 }
3182
3183 +static int read_trailers(struct replay_opts *opts, struct strbuf *buf)
3184 +{
3185 + ssize_t len;
3186 +
3187 + strbuf_reset(buf);
3188 + len = strbuf_read_file(buf, rebase_path_trailer(), 0);
3189 + if (len > 0) {
3190 + char *p = buf->buf, *nl;
3191 +
3192 + trailer_config_init();
3193 +
3194 + while ((nl = strchr(p, '\n'))) {
3195 + *nl = '\0';
3196 + if (!*p)
3197 + return error(_("trailers file contains empty line"));
3198 + strvec_push(&opts->trailer_args, p);
3199 + p = nl + 1;
3200 + }
3201 + } else if (!len) {
3202 + return error(_("trailers file is empty"));
3203 + } else if (errno != ENOENT) {
3204 + return error(_("cannot read trailers files"));
3205 + }
3206 +
3207 + return 0;
3208 +}
3209 +
3210 static int read_populate_opts(struct replay_opts *opts)
3211 {
3212 struct replay_ctx *ctx = opts->ctx;
@@ -3233,6 +3268,11 @@ static int read_populate_opts(struct replay_opts *opts)
3268 opts->keep_redundant_commits = 1;
3269
3270 read_strategy_opts(opts, &buf);
3271 +
3272 + if (read_trailers(opts, &buf)) {
3273 + ret = -1;
3274 + goto done_rebase_i;
3275 + }
3276 strbuf_reset(&buf);
3277
3278 if (read_oneliner(&ctx->current_fixups,
@@ -3328,6 +3368,14 @@ int write_basic_state(struct replay_opts *opts, const char *head_name,
3368 write_file(rebase_path_reschedule_failed_exec(), "%s", "");
3369 else
3370 write_file(rebase_path_no_reschedule_failed_exec(), "%s", "");
3371 + if (opts->trailer_args.nr) {
3372 + struct strbuf buf = STRBUF_INIT;
3373 +
3374 + for (size_t i = 0; i < opts->trailer_args.nr; i++)
3375 + strbuf_addf(&buf, "%s\n", opts->trailer_args.v[i]);
3376 + write_file(rebase_path_trailer(), "%s", buf.buf);
3377 + strbuf_release(&buf);
3378 + }
3379
3380 return 0;
3381 }
sequencer.h
+3
@@ -57,6 +57,8 @@ struct replay_opts {
57 int ignore_date;
58 int commit_use_reference;
59
60 + struct strvec trailer_args;
61 +
62 int mainline;
63
64 char *gpg_sign;
@@ -84,6 +86,7 @@ struct replay_opts {
86 #define REPLAY_OPTS_INIT { \
87 .edit = -1, \
88 .action = -1, \
89 + .trailer_args = STRVEC_INIT, \
90 .xopts = STRVEC_INIT, \
91 .ctx = replay_ctx_new(), \
92 }
t/meson.build
+1
@@ -388,6 +388,7 @@ integration_tests = [
388 't3436-rebase-more-options.sh',
389 't3437-rebase-fixup-options.sh',
390 't3438-rebase-broken-files.sh',
391 + 't3440-rebase-trailer.sh',
392 't3450-history.sh',
393 't3451-history-reword.sh',
394 't3500-cherry.sh',
t/t3440-rebase-trailer.sh new
+147
@@ -0,0 +1,147 @@
1 +#!/bin/sh
2 +#
3 +
4 +test_description='git rebase --trailer integration tests
5 +We verify that --trailer works with the merge backend,
6 +and that it is rejected early when the apply backend is requested.'
7 +
8 +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
9 +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
10 +
11 +. ./test-lib.sh
12 +. "$TEST_DIRECTORY"/lib-rebase.sh # test_commit_message, helpers
13 +
14 +REVIEWED_BY_TRAILER="Reviewed-by: Dev <dev@example.com>"
15 +SP=" "
16 +
17 +test_expect_success 'setup repo with a small history' '
18 + git commit --allow-empty -m "Initial empty commit" &&
19 + test_commit first file a &&
20 + test_commit second file &&
21 + git checkout -b conflict-branch first &&
22 + test_commit file-2 file-2 &&
23 + test_commit conflict file &&
24 + test_commit third file &&
25 + git checkout main
26 +'
27 +
28 +test_expect_success 'apply backend is rejected with --trailer' '
29 + git checkout -B apply-backend third &&
30 + test_expect_code 128 \
31 + git rebase --apply --trailer "$REVIEWED_BY_TRAILER" HEAD^ 2>err &&
32 + test_grep "fatal: --trailer requires the merge backend" err
33 +'
34 +
35 +test_expect_success 'reject empty --trailer argument' '
36 + git checkout -B empty-trailer third &&
37 + test_expect_code 128 git rebase --trailer "" HEAD^ 2>err &&
38 + test_grep "empty --trailer" err
39 +'
40 +
41 +test_expect_success 'reject trailer with missing key before separator' '
42 + git checkout -B missing-key third &&
43 + test_expect_code 128 git rebase --trailer ": no-key" HEAD^ 2>err &&
44 + test_grep "missing key before separator" err
45 +'
46 +
47 +test_expect_success 'allow trailer with missing value after separator' '
48 + git checkout -B missing-value third &&
49 + git rebase --trailer "Acked-by:" HEAD^ &&
50 + test_commit_message HEAD <<-EOF
51 + third
52 +
53 + Acked-by:${SP}
54 + EOF
55 +'
56 +
57 +test_expect_success 'CLI trailer duplicates allowed; replace policy keeps last' '
58 + git checkout -B replace-policy third &&
59 + git -c trailer.Bug.ifexists=replace -c trailer.Bug.ifmissing=add \
60 + rebase --trailer "Bug: 123" --trailer "Bug: 456" HEAD^ &&
61 + test_commit_message HEAD <<-EOF
62 + third
63 +
64 + Bug: 456
65 + EOF
66 +'
67 +
68 +test_expect_success 'multiple Signed-off-by trailers all preserved' '
69 + git checkout -B multiple-signoff third &&
70 + git rebase --trailer "Signed-off-by: Dev A <a@example.com>" \
71 + --trailer "Signed-off-by: Dev B <b@example.com>" HEAD^ &&
72 + test_commit_message HEAD <<-EOF
73 + third
74 +
75 + Signed-off-by: Dev A <a@example.com>
76 + Signed-off-by: Dev B <b@example.com>
77 + EOF
78 +'
79 +
80 +test_expect_success 'rebase --trailer adds trailer after conflicts' '
81 + git checkout -B trailer-conflict third &&
82 + test_commit fourth file &&
83 + test_must_fail git rebase --trailer "$REVIEWED_BY_TRAILER" second &&
84 + git checkout --theirs file &&
85 + git add file &&
86 + git rebase --continue &&
87 + test_commit_message HEAD <<-EOF &&
88 + fourth
89 +
90 + $REVIEWED_BY_TRAILER
91 + EOF
92 + test_commit_message HEAD^ <<-EOF
93 + third
94 +
95 + $REVIEWED_BY_TRAILER
96 + EOF
97 +'
98 +
99 +test_expect_success '--trailer handles fixup commands in todo list' '
100 + git checkout -B fixup-trailer third &&
101 + test_commit fixup-base base &&
102 + test_commit fixup-second second &&
103 + cat >todo <<-\EOF &&
104 + pick fixup-base fixup-base
105 + fixup fixup-second fixup-second
106 + EOF
107 + (
108 + set_replace_editor todo &&
109 + git rebase -i --trailer "$REVIEWED_BY_TRAILER" HEAD~2
110 + ) &&
111 + test_commit_message HEAD <<-EOF &&
112 + fixup-base
113 +
114 + $REVIEWED_BY_TRAILER
115 + EOF
116 + git reset --hard fixup-second &&
117 + cat >todo <<-\EOF &&
118 + pick fixup-base fixup-base
119 + fixup -C fixup-second fixup-second
120 + EOF
121 + (
122 + set_replace_editor todo &&
123 + git rebase -i --trailer "$REVIEWED_BY_TRAILER" HEAD~2
124 + ) &&
125 + test_commit_message HEAD <<-EOF
126 + fixup-second
127 +
128 + $REVIEWED_BY_TRAILER
129 + EOF
130 +'
131 +
132 +test_expect_success 'rebase --root honors trailer.<name>.key' '
133 + git checkout -B root-trailer first &&
134 + git -c trailer.review.key=Reviewed-by rebase --root \
135 + --trailer=review="Dev <dev@example.com>" &&
136 + test_commit_message HEAD <<-EOF &&
137 + first
138 +
139 + Reviewed-by: Dev <dev@example.com>
140 + EOF
141 + test_commit_message HEAD^ <<-EOF
142 + Initial empty commit
143 +
144 + Reviewed-by: Dev <dev@example.com>
145 + EOF
146 +'
147 +test_done