Merge branch 'mf/format-patch-cover-letter-format' into jch

"git format-patch --cover-letter" learns to use a simpler format
instead of the traditional shortlog format to list its commits with
a new --cover-letter-format option and format.commitListFormat
configuration variable.

* mf/format-patch-cover-letter-format:
  docs: add usage for the cover-letter fmt feature
  format-patch: add commitListFormat config
  format-patch: add ability to use alt cover format
  format-patch: move cover letter summary generation
  pretty.c: add %(count) and %(total) placeholders
This commit is contained in:
Junio C Hamano
2026-03-03 11:08:33 -08:00
6 changed files with 212 additions and 14 deletions

View File

@@ -101,6 +101,13 @@ format.coverLetter::
generate a cover-letter only when there's more than one patch.
Default is false.
format.commitListFormat::
A format string that specifies how to generate the commit list
of a cover-letter when format-patch is invoked. This is the
config coupled with `--cover-letter-format` in the format-patch
command and they both accept the same values.
Default is shortlog.
format.outputDirectory::
Set a custom directory to store the resulting files instead of the
current working directory. All directory components will be created.

View File

@@ -24,6 +24,7 @@ SYNOPSIS
[(--reroll-count|-v) <n>]
[--to=<email>] [--cc=<email>]
[--[no-]cover-letter] [--quiet]
[--cover-letter-format=<format-spec>]
[--[no-]encode-email-headers]
[--no-notes | --notes[=<ref>]]
[--interdiff=<previous>]
@@ -322,6 +323,15 @@ feeding the result to `git send-email`.
containing the branch description, shortlog and the overall diffstat. You can
fill in a description in the file before sending it out.
--cover-letter-format=<format-spec>::
Specify the format in which to generate the commit list of the
patch series. This option is available if the user wants to use
an alternative to the default shortlog format. The accepted
values for format-spec are "shortlog" or a format string
prefixed with `log:`.
e.g. `log: %s (%an)`
This option is relevant only if a cover letter is generated.
--encode-email-headers::
--no-encode-email-headers::
Encode email headers that have non-ASCII characters with
@@ -453,6 +463,7 @@ with configuration variables.
signOff = true
outputDirectory = <directory>
coverLetter = auto
commitListFormat = shortlog
coverFromDescription = auto
------------

View File

@@ -886,6 +886,7 @@ struct format_config {
char *signature;
char *signature_file;
enum cover_setting config_cover_letter;
char *fmt_cover_letter_commit_list;
char *config_output_directory;
enum cover_from_description cover_from_description_mode;
int show_notes;
@@ -930,6 +931,7 @@ static void format_config_release(struct format_config *cfg)
string_list_clear(&cfg->extra_cc, 0);
strbuf_release(&cfg->sprefix);
free(cfg->fmt_patch_suffix);
free(cfg->fmt_cover_letter_commit_list);
}
static enum cover_from_description parse_cover_from_description(const char *arg)
@@ -1052,6 +1054,19 @@ static int git_format_config(const char *var, const char *value,
cfg->config_cover_letter = git_config_bool(var, value) ? COVER_ON : COVER_OFF;
return 0;
}
if (!strcmp(var, "format.commitlistformat")) {
struct strbuf tmp = STRBUF_INIT;
strbuf_init(&tmp, 0);
if (value)
strbuf_addstr(&tmp, value);
else
strbuf_addstr(&tmp, "log:[%(count)/%(total)] %s");
FREE_AND_NULL(cfg->fmt_cover_letter_commit_list);
git_config_string(&cfg->fmt_cover_letter_commit_list, var, tmp.buf);
strbuf_release(&tmp);
return 0;
}
if (!strcmp(var, "format.outputdirectory")) {
FREE_AND_NULL(cfg->config_output_directory);
return git_config_string(&cfg->config_output_directory, var, value);
@@ -1335,13 +1350,55 @@ static void get_notes_args(struct strvec *arg, struct rev_info *rev)
}
}
static void generate_shortlog_cover_letter(struct shortlog *log,
struct rev_info *rev,
struct commit **list,
int nr)
{
shortlog_init(log);
log->wrap_lines = 1;
log->wrap = MAIL_DEFAULT_WRAP;
log->in1 = 2;
log->in2 = 4;
log->file = rev->diffopt.file;
log->groups = SHORTLOG_GROUP_AUTHOR;
shortlog_finish_setup(log);
for (int i = 0; i < nr; i++)
shortlog_add_commit(log, list[i]);
shortlog_output(log);
}
static void generate_commit_list_cover(FILE *cover_file,const char *format,
struct commit **list, int n)
{
struct strbuf commit_line = STRBUF_INIT;
struct pretty_print_context ctx = {0};
struct rev_info rev = REV_INFO_INIT;
strbuf_init(&commit_line, 0);
rev.total = n;
ctx.rev = &rev;
for (int i = n - 1; i >= 0; i--) {
rev.nr = n - i;
repo_format_commit_message(the_repository, list[i], format,
&commit_line, &ctx);
fprintf(cover_file, "%s\n", commit_line.buf);
strbuf_reset(&commit_line);
}
fprintf(cover_file, "\n");
strbuf_release(&commit_line);
}
static void make_cover_letter(struct rev_info *rev, int use_separate_file,
struct commit *origin,
int nr, struct commit **list,
const char *description_file,
const char *branch_name,
int quiet,
const struct format_config *cfg)
const struct format_config *cfg,
const char *format)
{
const char *from;
struct shortlog log;
@@ -1388,18 +1445,12 @@ static void make_cover_letter(struct rev_info *rev, int use_separate_file,
free(pp.after_subject);
strbuf_release(&sb);
shortlog_init(&log);
log.wrap_lines = 1;
log.wrap = MAIL_DEFAULT_WRAP;
log.in1 = 2;
log.in2 = 4;
log.file = rev->diffopt.file;
log.groups = SHORTLOG_GROUP_AUTHOR;
shortlog_finish_setup(&log);
for (i = 0; i < nr; i++)
shortlog_add_commit(&log, list[i]);
shortlog_output(&log);
if (skip_prefix(format, "log:", &format))
generate_commit_list_cover(rev->diffopt.file, format, list, nr);
else if (!strcmp(format, "shortlog"))
generate_shortlog_cover_letter(&log, rev, list, nr);
else
die(_("'%s' is not a valid format string"), format);
/* We can only do diffstat with a unique reference point */
if (origin)
@@ -1917,6 +1968,7 @@ int cmd_format_patch(int argc,
int just_numbers = 0;
int ignore_if_in_upstream = 0;
int cover_letter = -1;
const char *cover_letter_fmt = NULL;
int boundary_count = 0;
int no_binary_diff = 0;
int zero_commit = 0;
@@ -1963,6 +2015,8 @@ int cmd_format_patch(int argc,
N_("print patches to standard out")),
OPT_BOOL(0, "cover-letter", &cover_letter,
N_("generate a cover letter")),
OPT_STRING(0, "cover-letter-format", &cover_letter_fmt, N_("format-spec"),
N_("format spec used for the commit list in the cover letter")),
OPT_BOOL(0, "numbered-files", &just_numbers,
N_("use simple number sequence for output file names")),
OPT_STRING(0, "suffix", &fmt_patch_suffix, N_("sfx"),
@@ -2300,6 +2354,13 @@ int cmd_format_patch(int argc,
/* nothing to do */
goto done;
total = list.nr;
if (!cover_letter_fmt) {
cover_letter_fmt = cfg.fmt_cover_letter_commit_list;
if (!cover_letter_fmt)
cover_letter_fmt = "shortlog";
}
if (cover_letter == -1) {
if (cfg.config_cover_letter == COVER_AUTO)
cover_letter = (total > 1);
@@ -2386,12 +2447,14 @@ int cmd_format_patch(int argc,
}
rev.numbered_files = just_numbers;
rev.patch_suffix = fmt_patch_suffix;
if (cover_letter) {
if (cfg.thread)
gen_message_id(&rev, "cover");
make_cover_letter(&rev, !!output_directory,
origin, list.nr, list.items,
description_file, branch_name, quiet, &cfg);
description_file, branch_name, quiet, &cfg,
cover_letter_fmt);
print_bases(&bases, rev.diffopt.file);
print_signature(signature, rev.diffopt.file);
total++;

View File

@@ -1549,6 +1549,21 @@ static size_t format_commit_one(struct strbuf *sb, /* in UTF-8 */
if (!commit->object.parsed)
parse_object(the_repository, &commit->object.oid);
if (starts_with(placeholder, "(count)")) {
if (!c->pretty_ctx->rev)
die(_("this format specifier can't be used with this command"));
strbuf_addf(sb, "%0*d", decimal_width(c->pretty_ctx->rev->total),
c->pretty_ctx->rev->nr);
return 7;
}
if (starts_with(placeholder, "(total)")) {
if (!c->pretty_ctx->rev)
die(_("this format specifier can't be used with this command"));
strbuf_addf(sb, "%d", c->pretty_ctx->rev->total);
return 7;
}
switch (placeholder[0]) {
case 'H': /* commit hash */
strbuf_addstr(sb, diff_get_color(c->auto_color, DIFF_COMMIT));

View File

@@ -380,6 +380,107 @@ test_expect_success 'filename limit applies only to basename' '
done
'
test_expect_success 'cover letter with subject, author and count' '
rm -rf patches &&
test_when_finished "git reset --hard HEAD~1" &&
test_when_finished "rm -rf patches result test_file" &&
touch test_file &&
git add test_file &&
git commit -m "This is a subject" &&
git format-patch --cover-letter \
--cover-letter-format="log:[%(count)/%(total)] %s (%an)" -o patches HEAD~1 &&
grep "^\[1/1\] This is a subject (A U Thor)$" patches/0000-cover-letter.patch >result &&
test_line_count = 1 result
'
test_expected_success 'cover letter with author and count' '
test_when_finished "git reset --hard HEAD~1" &&
test_when_finished "rm -rf patches result test_file" &&
touch test_file &&
git add test_file &&
git commit -m "This is a subject" &&
git format-patch --cover-letter \
--cover-letter-format="log:[%(count)/%(total)] %an" -o patches HEAD~1 &&
grep "^\[1/1\] A U Thor$" patches/0000-cover-letter.patch >result &&
test_line_count = 1 result
'
test_expect_success 'cover letter shortlog' '
test_when_finished "git reset --hard HEAD~1" &&
test_when_finished "rm -rf patches result test_file" &&
touch test_file &&
git add test_file &&
git commit -m "This is a subject" &&
git format-patch --cover-letter --cover-letter-format=shortlog \
-o patches HEAD~1 &&
sed -n -e "/^A U Thor/p;" patches/0000-cover-letter.patch >result &&
test_line_count = 1 result
'
test_expect_success 'cover letter no format' '
test_when_finished "git reset --hard HEAD~1" &&
test_when_finished "rm -rf patches result test_file" &&
touch test_file &&
git add test_file &&
git commit -m "This is a subject" &&
git format-patch --cover-letter -o patches HEAD~1 &&
sed -n -e "/^A U Thor/p;" patches/0000-cover-letter.patch >result &&
test_line_count = 1 result
'
test_expect_success 'cover letter config with count, subject and author' '
test_when_finished "rm -rf patches result" &&
test_when_finished "git config unset format.coverletter" &&
test_when_finished "git config unset format.commitlistformat" &&
git config set format.coverletter true &&
git config set format.commitlistformat "log:[%(count)/%(total)] %s (%an)" &&
git format-patch -o patches HEAD~2 &&
grep -E "^[[[:digit:]]+/[[:digit:]]+] .* \(A U Thor\)" patches/0000-cover-letter.patch >result &&
test_line_count = 2 result
'
test_expect_success 'cover letter config with count and author' '
test_when_finished "rm -rf patches result" &&
test_when_finished "git config unset format.coverletter" &&
test_when_finished "git config unset format.commitlistformat" &&
git config set format.coverletter true &&
git config set format.commitlistformat "log:[%(count)/%(total)] (%an)" &&
git format-patch -o patches HEAD~2 &&
grep -E "^[[[:digit:]]+/[[:digit:]]+] \(A U Thor\)" patches/0000-cover-letter.patch >result &&
test_line_count = 2 result
'
test_expect_success 'cover letter config commitlistformat set but no format' '
test_when_finished "rm -rf patches result" &&
test_when_finished "git config unset format.coverletter" &&
test_when_finished "git config unset format.commitlistformat" &&
git config set format.coverletter true &&
printf "\tcommitlistformat" >> .git/config &&
git format-patch -o patches HEAD~2 &&
grep -E "^[[[:digit:]]+/[[:digit:]]+] .*" patches/0000-cover-letter.patch >result &&
test_line_count = 2 result
'
test_expect_success 'cover letter config commitlistformat set to shortlog' '
test_when_finished "rm -rf patches result" &&
test_when_finished "git config unset format.coverletter" &&
test_when_finished "git config unset format.commitlistformat" &&
git config set format.coverletter true &&
git config set format.commitlistformat shortlog &&
git format-patch -o patches HEAD~2 &&
grep -E "^A U Thor \([[:digit:]]+\)" patches/0000-cover-letter.patch >result &&
test_line_count = 1 result
'
test_expect_success 'cover letter config commitlistformat not set' '
test_when_finished "rm -rf patches result" &&
test_when_finished "git config unset format.coverletter" &&
git config set format.coverletter true &&
git format-patch -o patches HEAD~2 &&
grep -E "^A U Thor \([[:digit:]]+\)" patches/0000-cover-letter.patch >result &&
test_line_count = 1 result
'
test_expect_success 'reroll count' '
rm -fr patches &&
git format-patch -o patches --cover-letter --reroll-count 4 main..side >list &&

View File

@@ -2775,6 +2775,7 @@ test_expect_success PERL 'send-email' '
test_completion "git send-email --cov" <<-\EOF &&
--cover-from-description=Z
--cover-letter Z
--cover-letter-format=Z
EOF
test_completion "git send-email --val" <<-\EOF &&
--validate Z