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