From 2af59cbcf4375f4d7c61954a19244d130de0a0db Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Sat, 7 Mar 2026 00:34:41 +0100 Subject: format-patch: move cover letter summary generation As of now format-patch allows generation of a template cover letter for patch series through "--cover-letter". Move shortlog summary code generation to its own function. This is done in preparation to other patches where we enable the user to format the commit list using thier own format string. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- builtin/log.c | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) (limited to 'builtin') diff --git a/builtin/log.c b/builtin/log.c index 5c9a8ef363..0d12272031 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -1324,6 +1324,25 @@ 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 make_cover_letter(struct rev_info *rev, int use_separate_file, struct commit *origin, int nr, struct commit **list, @@ -1377,18 +1396,7 @@ 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); + generate_shortlog_cover_letter(&log, rev, list, nr); /* We can only do diffstat with a unique reference point */ if (origin) -- cgit v1.2.3 From 6005932d95ff05541f9dbe8c49a45b7abaf7432e Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Sat, 7 Mar 2026 00:34:42 +0100 Subject: format-patch: add ability to use alt cover format Often when sending patch series there's a need to clarify to the reviewer what's the purpose of said series, since it might be difficult to understand it from reading the commits messages one by one. "git format-patch" provides the useful "--cover-letter" flag to declare if we want it to generate a template for us to use. By default it will generate a "git shortlog" of the changes, which developers find less useful than they'd like, mainly because the shortlog groups commits by author, and gives no obvious chronological order. Give format-patch the ability to specify an alternative format spec through the "--cover-letter-format" option. This option either takes "shortlog", which is the current format, or a format spec prefixed with "log:". Example: git format-patch --cover-letter \ --cover-letter-format="log:[%(count)/%(total)] %s (%an)" HEAD~3 [1/3] this is a commit summary (Mirko Faina) [2/3] this is another commit summary (Mirko Faina) ... Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- builtin/log.c | 40 +++++++++++++++++++++++++++++++++++++--- t/t4014-format-patch.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ t/t9902-completion.sh | 1 + 3 files changed, 86 insertions(+), 3 deletions(-) (limited to 'builtin') diff --git a/builtin/log.c b/builtin/log.c index 0d12272031..95e5d9755f 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -1343,13 +1343,36 @@ static void generate_shortlog_cover_letter(struct shortlog *log, 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 *committer; struct shortlog log; @@ -1396,7 +1419,12 @@ static void make_cover_letter(struct rev_info *rev, int use_separate_file, free(pp.after_subject); strbuf_release(&sb); - generate_shortlog_cover_letter(&log, rev, list, nr); + 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) @@ -1914,6 +1942,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; @@ -1960,6 +1989,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"), @@ -2297,6 +2328,7 @@ int cmd_format_patch(int argc, /* nothing to do */ goto done; total = list.nr; + if (cover_letter == -1) { if (cfg.config_cover_letter == COVER_AUTO) cover_letter = (total > 1); @@ -2383,12 +2415,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++; diff --git a/t/t4014-format-patch.sh b/t/t4014-format-patch.sh index 21d6d0cd9e..458da80721 100755 --- a/t/t4014-format-patch.sh +++ b/t/t4014-format-patch.sh @@ -380,6 +380,54 @@ 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 'reroll count' ' rm -fr patches && git format-patch -o patches --cover-letter --reroll-count 4 main..side >list && diff --git a/t/t9902-completion.sh b/t/t9902-completion.sh index 964e1f1569..4f760a7468 100755 --- a/t/t9902-completion.sh +++ b/t/t9902-completion.sh @@ -2774,6 +2774,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 -- cgit v1.2.3 From be0ef6fcd2379ea3dc1569d6d8c360d6d59d031f Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Sat, 7 Mar 2026 00:34:43 +0100 Subject: format-patch: add commitListFormat config Using "--cover-letter" we can tell format-patch to generate a cover letter, in this cover letter there's a list of commits included in the patch series and the format is specified by the "--cover-letter-format" option. Would be useful if this format could be configured from the config file instead of always needing to pass it from the command line. Teach format-patch how to read the format spec for the cover letter from the config files. The variable it should look for is called format.commitListFormat. Possible values: - commitListFormat is set but no string is passed: it will default to "[%(count)/%(total)] %s" - if a string is passed: will use it as a format spec. Note that this is either "shortlog" or a format spec prefixed by "log:" e.g."log:%s (%an)" - if commitListFormat is not set: it will default to the shortlog format. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- builtin/log.c | 21 ++++++++++++++++++++ t/t4014-format-patch.sh | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) (limited to 'builtin') diff --git a/builtin/log.c b/builtin/log.c index 95e5d9755f..5fec0ddaf9 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -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); @@ -2329,6 +2344,12 @@ int cmd_format_patch(int argc, 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); diff --git a/t/t4014-format-patch.sh b/t/t4014-format-patch.sh index 458da80721..4891389a53 100755 --- a/t/t4014-format-patch.sh +++ b/t/t4014-format-patch.sh @@ -428,6 +428,59 @@ test_expect_success 'cover letter no format' ' 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 && -- cgit v1.2.3 From 3482b4278793f3adb1fa811dd30c637563ca9cec Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Mon, 23 Mar 2026 17:57:29 +0100 Subject: format-patch: refactor generate_commit_list_cover Refactor for readability and remove unnecessary initialization. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- builtin/log.c | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'builtin') diff --git a/builtin/log.c b/builtin/log.c index 716ebc2701..997bdd608e 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -1376,12 +1376,11 @@ static void generate_commit_list_cover(FILE *cover_file, const char *format, 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, + for (int i = 1; i <= n; i++) { + rev.nr = i; + repo_format_commit_message(the_repository, list[n - i], format, &commit_line, &ctx); fprintf(cover_file, "%s\n", commit_line.buf); strbuf_reset(&commit_line); -- cgit v1.2.3 From 67ea2ad7d1b006194762cbfcc0b7801ffe652ca4 Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Mon, 23 Mar 2026 17:57:30 +0100 Subject: format-patch: rename --cover-letter-format option To align the name of the configuration variable and the name of the command line option, either one should change name. By changing the name of the option we get the added benefit of having --cover- expand to --cover-letter without ambiguity. If the user gives the --cover-letter-format option it would be reasonable to expect that the user wants to generate the cover letter despite not giving --cover-letter. Rename --cover-letter-format to --commit-list-format and make it imply --cover-letter unless --no-cover-letter is given. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- Documentation/git-format-patch.adoc | 17 ++++++++------- builtin/log.c | 4 +++- t/t4014-format-patch.sh | 41 +++++++++++++++++++------------------ t/t9902-completion.sh | 1 - 4 files changed, 32 insertions(+), 31 deletions(-) (limited to 'builtin') diff --git a/Documentation/git-format-patch.adoc b/Documentation/git-format-patch.adoc index 31fa492335..45ca72e670 100644 --- a/Documentation/git-format-patch.adoc +++ b/Documentation/git-format-patch.adoc @@ -24,7 +24,7 @@ SYNOPSIS [(--reroll-count|-v) ] [--to=] [--cc=] [--[no-]cover-letter] [--quiet] - [--cover-letter-format=] + [--commit-list-format=] [--[no-]encode-email-headers] [--no-notes | --notes[=]] [--interdiff=] @@ -323,16 +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=:: - 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:`. +--commit-list-format=:: + Specify the format in which to generate the commit list of the patch + series. The accepted values for format-spec are "shortlog" or a format + string prefixed with `log:`. e.g. `log: %s (%an)` - If defined, defaults to the `format.commitListFormat` configuration + If not given, defaults to the `format.commitListFormat` configuration variable. - This option is relevant only if a cover letter is generated. + This option implies the use of `--cover-letter` unless + `--no-cover-letter` is given. --encode-email-headers:: --no-encode-email-headers:: diff --git a/builtin/log.c b/builtin/log.c index 997bdd608e..a7f129d583 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -2014,7 +2014,7 @@ 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"), + OPT_STRING(0, "commit-list-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")), @@ -2358,6 +2358,8 @@ int cmd_format_patch(int argc, cover_letter_fmt = cfg.fmt_cover_letter_commit_list; if (!cover_letter_fmt) cover_letter_fmt = "shortlog"; + } else if (cover_letter == -1) { + cover_letter = 1; } if (cover_letter == -1) { diff --git a/t/t4014-format-patch.sh b/t/t4014-format-patch.sh index 7c67bdf922..d2a775f78d 100755 --- a/t/t4014-format-patch.sh +++ b/t/t4014-format-patch.sh @@ -383,49 +383,50 @@ test_expect_success 'filename limit applies only to basename' ' 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" && + test_when_finished "rm -rf patches 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 + git format-patch --commit-list-format="log:[%(count)/%(total)] %s (%an)" \ + -o patches HEAD~1 && + test_grep "^\[1/1\] This is a subject (A U Thor)$" patches/0000-cover-letter.patch ' -test_expected_success 'cover letter with author and count' ' +test_expect_success 'cover letter with author and count' ' test_when_finished "git reset --hard HEAD~1" && - test_when_finished "rm -rf patches result test_file" && + test_when_finished "rm -rf patches 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 + git format-patch --commit-list-format="log:[%(count)/%(total)] %an" \ + -o patches HEAD~1 && + test_grep "^\[1/1\] A U Thor$" patches/0000-cover-letter.patch ' test_expect_success 'cover letter shortlog' ' test_when_finished "git reset --hard HEAD~1" && - test_when_finished "rm -rf patches result test_file" && + test_when_finished "rm -rf expect patches result test_file" && + cat >expect <<-"EOF" && + A U Thor (1): + This is a subject + EOF 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 + git format-patch --commit-list-format=shortlog -o patches HEAD~1 && + grep -E -A 1 "^A U Thor \([[:digit:]]+\):$" patches/0000-cover-letter.patch >result && + cat result && + test_cmp expect result ' -test_expect_success 'cover letter no format' ' +test_expect_success 'no cover letter but with format specified' ' 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 + git format-patch --no-cover-letter --commit-list-format="[%(count)] %s" -o patches HEAD~1 && + test_path_is_missing patches/0000-cover-letter.patch ' test_expect_success 'cover letter config with count, subject and author' ' diff --git a/t/t9902-completion.sh b/t/t9902-completion.sh index 35e20b5351..2f9a597ec7 100755 --- a/t/t9902-completion.sh +++ b/t/t9902-completion.sh @@ -2775,7 +2775,6 @@ 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 -- cgit v1.2.3 From 24d174f9917cce804c9057061f7da0dbd3b88a24 Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Mon, 23 Mar 2026 17:57:32 +0100 Subject: format.commitListFormat: strip meaning from empty The configuration variable format.commitListFormat allows for an empty value. This is unusual and can create issues when interacting with this configuration variable through the CLI. Strip meaning to format.commitListFormat with an empty value. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- builtin/log.c | 11 +---------- t/t4014-format-patch.sh | 11 ----------- 2 files changed, 1 insertion(+), 21 deletions(-) (limited to 'builtin') diff --git a/builtin/log.c b/builtin/log.c index a7f129d583..47126f9064 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -1055,17 +1055,8 @@ static int git_format_config(const char *var, const char *value, 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; + return git_config_string(&cfg->fmt_cover_letter_commit_list, var, value); } if (!strcmp(var, "format.outputdirectory")) { FREE_AND_NULL(cfg->config_output_directory); diff --git a/t/t4014-format-patch.sh b/t/t4014-format-patch.sh index d2a775f78d..ca37f40a6a 100755 --- a/t/t4014-format-patch.sh +++ b/t/t4014-format-patch.sh @@ -451,17 +451,6 @@ test_expect_success 'cover letter config with count and author' ' 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" && -- cgit v1.2.3 From 617db87921e835f02ac132e86d0f7edc0d72915e Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Mon, 23 Mar 2026 17:57:33 +0100 Subject: format-patch: wrap generate_commit_list_cover() While most conventions should not allow for the text lines in commit messages to get too long, when they do it could make emails harder to read. Teach generate_commit_list_cover() to wrap its commit lines if they are too long. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- builtin/log.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'builtin') diff --git a/builtin/log.c b/builtin/log.c index 47126f9064..d1765ce4ad 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -40,6 +40,7 @@ #include "progress.h" #include "commit-slab.h" #include "advice.h" +#include "utf8.h" #include "commit-reach.h" #include "range-diff.h" @@ -1364,6 +1365,7 @@ static void generate_commit_list_cover(FILE *cover_file, const char *format, struct commit **list, int n) { struct strbuf commit_line = STRBUF_INIT; + struct strbuf wrapped_line = STRBUF_INIT; struct pretty_print_context ctx = {0}; struct rev_info rev = REV_INFO_INIT; @@ -1373,12 +1375,16 @@ static void generate_commit_list_cover(FILE *cover_file, const char *format, rev.nr = i; repo_format_commit_message(the_repository, list[n - i], format, &commit_line, &ctx); - fprintf(cover_file, "%s\n", commit_line.buf); + strbuf_add_wrapped_text(&wrapped_line, commit_line.buf, 0, 0, + MAIL_DEFAULT_WRAP); + fprintf(cover_file, "%s\n", wrapped_line.buf); strbuf_reset(&commit_line); + strbuf_reset(&wrapped_line); } fprintf(cover_file, "\n"); strbuf_release(&commit_line); + strbuf_release(&wrapped_line); } static void make_cover_letter(struct rev_info *rev, int use_separate_file, -- cgit v1.2.3 From d022dc77ab81fcc005b9965e91f524d4a43e74b5 Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Mon, 23 Mar 2026 17:57:34 +0100 Subject: format-patch: add preset for --commit-list-format "git format-patch --commit-list-format" enables the user to make their own format for the commit list in the cover letter. It would be nice to have a ready to use format to replace shortlog. Teach make_cover_letter() the "modern" format preset. This new format is the same as: "log:[%(count)/%(total)] %s". Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- Documentation/config/format.adoc | 2 +- Documentation/git-format-patch.adoc | 4 ++-- builtin/log.c | 3 +++ t/t4014-format-patch.sh | 20 +++++++++++++++----- 4 files changed, 21 insertions(+), 8 deletions(-) (limited to 'builtin') diff --git a/Documentation/config/format.adoc b/Documentation/config/format.adoc index ea5ec5df7a..ef1ed1d250 100644 --- a/Documentation/config/format.adoc +++ b/Documentation/config/format.adoc @@ -104,7 +104,7 @@ format.coverLetter:: format.commitListFormat:: When the `--cover-letter-format` option is not given, `format-patch` uses the value of this variable to decide how to format the title of - each commit. Default to `shortlog`. + each commit. Defaults to `shortlog`. format.outputDirectory:: Set a custom directory to store the resulting files instead of the diff --git a/Documentation/git-format-patch.adoc b/Documentation/git-format-patch.adoc index 45ca72e670..55cc680685 100644 --- a/Documentation/git-format-patch.adoc +++ b/Documentation/git-format-patch.adoc @@ -325,8 +325,8 @@ feeding the result to `git send-email`. --commit-list-format=:: Specify the format in which to generate the commit list of the patch - series. The accepted values for format-spec are "shortlog" or a format - string prefixed with `log:`. + series. The accepted values for format-spec are `shortlog`, `modern` or a + format string prefixed with `log:`. e.g. `log: %s (%an)` If not given, defaults to the `format.commitListFormat` configuration variable. diff --git a/builtin/log.c b/builtin/log.c index d1765ce4ad..c6cf04350a 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -1445,6 +1445,9 @@ static void make_cover_letter(struct rev_info *rev, int use_separate_file, generate_commit_list_cover(rev->diffopt.file, format, list, nr); else if (!strcmp(format, "shortlog")) generate_shortlog_cover_letter(&log, rev, list, nr); + else if (!strcmp(format, "modern")) + generate_commit_list_cover(rev->diffopt.file, "[%(count)/%(total)] %s", + list, nr); else die(_("'%s' is not a valid format string"), format); diff --git a/t/t4014-format-patch.sh b/t/t4014-format-patch.sh index ca37f40a6a..7571cc582b 100755 --- a/t/t4014-format-patch.sh +++ b/t/t4014-format-patch.sh @@ -392,18 +392,17 @@ test_expect_success 'cover letter with subject, author and count' ' test_grep "^\[1/1\] This is a subject (A U Thor)$" patches/0000-cover-letter.patch ' -test_expect_success 'cover letter with author and count' ' +test_expect_success 'cover letter modern format' ' test_when_finished "git reset --hard HEAD~1" && test_when_finished "rm -rf patches test_file" && touch test_file && git add test_file && git commit -m "This is a subject" && - git format-patch --commit-list-format="log:[%(count)/%(total)] %an" \ - -o patches HEAD~1 && - test_grep "^\[1/1\] A U Thor$" patches/0000-cover-letter.patch + git format-patch --commit-list-format="modern" -o patches HEAD~1 && + test_grep "^\[1/1\] This is a subject$" patches/0000-cover-letter.patch ' -test_expect_success 'cover letter shortlog' ' +test_expect_success 'cover letter shortlog format' ' test_when_finished "git reset --hard HEAD~1" && test_when_finished "rm -rf expect patches result test_file" && cat >expect <<-"EOF" && @@ -451,6 +450,17 @@ test_expect_success 'cover letter config with count and author' ' test_line_count = 2 result ' +test_expect_success 'cover letter config commitlistformat set to modern' ' + 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 modern && + 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" && -- cgit v1.2.3 From 36c16a5b7fff7806b475b5fa07eca3a5179d7fa6 Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Mon, 23 Mar 2026 17:57:35 +0100 Subject: format-patch: --commit-list-format without prefix Having to prefix a custom format-string with "log:" when passed from the CLI can be annoying. It would be great if this prefix wasn't required. Teach make_cover_letter() to accept custom format-strings without the "log:" prefix if a placeholder is detected. Note that both here and in "git log --format" the check is done naively by just checking for the presence of a '%'. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- Documentation/git-format-patch.adoc | 4 +++- builtin/log.c | 2 ++ t/t4014-format-patch.sh | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) (limited to 'builtin') diff --git a/Documentation/git-format-patch.adoc b/Documentation/git-format-patch.adoc index 55cc680685..c52dbcc170 100644 --- a/Documentation/git-format-patch.adoc +++ b/Documentation/git-format-patch.adoc @@ -326,8 +326,10 @@ feeding the result to `git send-email`. --commit-list-format=:: Specify the format in which to generate the commit list of the patch series. The accepted values for format-spec are `shortlog`, `modern` or a - format string prefixed with `log:`. + format-string prefixed with `log:`. e.g. `log: %s (%an)` + The user is allowed to drop the prefix if the format-string contains a + `%`. If not given, defaults to the `format.commitListFormat` configuration variable. This option implies the use of `--cover-letter` unless diff --git a/builtin/log.c b/builtin/log.c index c6cf04350a..ad7b7215fe 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -1448,6 +1448,8 @@ static void make_cover_letter(struct rev_info *rev, int use_separate_file, else if (!strcmp(format, "modern")) generate_commit_list_cover(rev->diffopt.file, "[%(count)/%(total)] %s", list, nr); + else if (strchr(format, '%')) + generate_commit_list_cover(rev->diffopt.file, format, list, nr); else die(_("'%s' is not a valid format string"), format); diff --git a/t/t4014-format-patch.sh b/t/t4014-format-patch.sh index 7571cc582b..7517094bd6 100755 --- a/t/t4014-format-patch.sh +++ b/t/t4014-format-patch.sh @@ -392,6 +392,30 @@ test_expect_success 'cover letter with subject, author and count' ' test_grep "^\[1/1\] This is a subject (A U Thor)$" patches/0000-cover-letter.patch ' +test_expect_success 'cover letter with custom format no prefix' ' + rm -rf patches && + test_when_finished "git reset --hard HEAD~1" && + test_when_finished "rm -rf patches test_file" && + touch test_file && + git add test_file && + git commit -m "This is a subject" && + git format-patch --commit-list-format="[%(count)/%(total)] %s (%an)" \ + -o patches HEAD~1 && + test_grep "^\[1/1\] This is a subject (A U Thor)$" patches/0000-cover-letter.patch +' + +test_expect_success 'cover letter fail when no prefix and no placeholder' ' + rm -rf patches && + test_when_finished "git reset --hard HEAD~1" && + test_when_finished "rm -rf patches test_file err" && + touch test_file && + git add test_file && + git commit -m "This is a subject" && + test_must_fail git format-patch --commit-list-format="this should fail" \ + -o patches HEAD~1 2>err && + test_grep "is not a valid format string" err +' + test_expect_success 'cover letter modern format' ' test_when_finished "git reset --hard HEAD~1" && test_when_finished "rm -rf patches test_file" && -- cgit v1.2.3 From 6b9f9e2d2f3d9d6634e72d190e800f65f9a88f30 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Wed, 25 Mar 2026 21:54:53 +0200 Subject: builtin/receive-pack: properly init receive_hook strbuf The run_receive_hook() stack-allocated `struct receive_hook_feed_state` is a template with initial values for child states allocated on the heap for each hook process, by calling receive_hook_feed_state_alloc() when spinning up each hook child. All these values are already initialized to zero, however I forgot to properly initialize the strbuf, which I left NULL. This is more of a code cleanup because in practice it has no effect, the states used by the children are always initialized, however it's good to fix in case someone ends up accidentally dereferencing the NULL pointer in the future. Reported-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/receive-pack.c | 1 + 1 file changed, 1 insertion(+) (limited to 'builtin') diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index 415bb57362..63b2f7d543 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -963,6 +963,7 @@ static int run_receive_hook(struct command *commands, /* set up stdin callback */ feed_init_state.cmd = commands; feed_init_state.skip_broken = skip_broken; + strbuf_init(&feed_init_state.buf, 0); opt.feed_pipe_ctx = &feed_init_state; opt.feed_pipe = feed_receive_hook_cb; opt.feed_pipe_cb_data_alloc = receive_hook_feed_state_alloc; -- cgit v1.2.3 From b06770e5d8948c7cad76d7507423376eacf1e005 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Wed, 25 Mar 2026 21:54:54 +0200 Subject: hook: fix minor style issues Fix some minor style nits pointed out by Patrick, Junio and Eric: * Use CALLOC_ARRAY instead of xcalloc. * Init struct members during declaration. * Simplify if condition boolean logic. * Missing curly braces in if/else stmts. * Unnecessary header includes. * Capitalization and full-stop in error/warn messages. * Curly brace on separate line when defining struct. * Comment spelling: free'd -> freed. * Sort the included headers. * Blank line fixes to improve readability. These contain no logic changes, the code behaves the same as before. Suggested-by: Eric Sunshine Suggested-by: Junio C Hamano Suggested-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/hook.c | 6 ++--- builtin/receive-pack.c | 65 ++++++++++++++++++++++++++------------------------ hook.c | 34 +++++++++++++------------- hook.h | 5 ++-- refs.c | 3 ++- t/t1800-hook.sh | 2 +- transport.c | 3 ++- 7 files changed, 61 insertions(+), 57 deletions(-) (limited to 'builtin') diff --git a/builtin/hook.c b/builtin/hook.c index 83020dfb4f..e641614b84 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -5,8 +5,6 @@ #include "gettext.h" #include "hook.h" #include "parse-options.h" -#include "strvec.h" -#include "abspath.h" #define BUILTIN_HOOK_RUN_USAGE \ N_("git hook run [--ignore-missing] [--to-stdin=] [-- ]") @@ -51,7 +49,7 @@ static int list(int argc, const char **argv, const char *prefix, * arguments later they probably should be caught by parse_options. */ if (argc != 1) - usage_msg_opt(_("You must specify a hook event name to list."), + usage_msg_opt(_("you must specify a hook event name to list"), builtin_hook_list_usage, list_options); hookname = argv[0]; @@ -59,7 +57,7 @@ static int list(int argc, const char **argv, const char *prefix, head = list_hooks(repo, hookname, NULL); if (!head->nr) { - warning(_("No hooks found for event '%s'"), hookname); + warning(_("no hooks found for event '%s'"), hookname); ret = 1; /* no hooks found */ goto cleanup; } diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index 63b2f7d543..3385ad120f 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -3,46 +3,45 @@ #include "builtin.h" #include "abspath.h" - +#include "commit.h" +#include "commit-reach.h" #include "config.h" +#include "connect.h" +#include "connected.h" #include "environment.h" +#include "exec-cmd.h" +#include "fsck.h" #include "gettext.h" +#include "gpg-interface.h" #include "hex.h" -#include "lockfile.h" -#include "pack.h" -#include "refs.h" -#include "pkt-line.h" -#include "sideband.h" -#include "run-command.h" #include "hook.h" -#include "exec-cmd.h" -#include "commit.h" +#include "lockfile.h" #include "object.h" -#include "remote.h" -#include "connect.h" -#include "string-list.h" -#include "oid-array.h" -#include "connected.h" -#include "strvec.h" -#include "version.h" -#include "gpg-interface.h" -#include "sigchain.h" -#include "fsck.h" -#include "tmp-objdir.h" -#include "oidset.h" -#include "packfile.h" #include "object-file.h" #include "object-name.h" #include "odb.h" +#include "oid-array.h" +#include "oidset.h" +#include "pack.h" +#include "packfile.h" +#include "parse-options.h" +#include "pkt-line.h" #include "protocol.h" -#include "commit-reach.h" +#include "refs.h" +#include "remote.h" +#include "run-command.h" #include "server-info.h" +#include "setup.h" +#include "shallow.h" +#include "sideband.h" +#include "sigchain.h" +#include "string-list.h" +#include "strvec.h" +#include "tmp-objdir.h" #include "trace.h" #include "trace2.h" +#include "version.h" #include "worktree.h" -#include "shallow.h" -#include "setup.h" -#include "parse-options.h" static const char * const receive_pack_usage[] = { N_("git receive-pack "), @@ -904,11 +903,14 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_ static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx) { struct receive_hook_feed_state *init_state = feed_pipe_ctx; - struct receive_hook_feed_state *data = xcalloc(1, sizeof(*data)); + struct receive_hook_feed_state *data; + + CALLOC_ARRAY(data, 1); data->report = init_state->report; data->cmd = init_state->cmd; data->skip_broken = init_state->skip_broken; strbuf_init(&data->buf, 0); + return data; } @@ -928,7 +930,11 @@ static int run_receive_hook(struct command *commands, { struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; struct command *iter = commands; - struct receive_hook_feed_state feed_init_state = { 0 }; + struct receive_hook_feed_state feed_init_state = { + .cmd = commands, + .skip_broken = skip_broken, + .buf = STRBUF_INIT, + }; struct async sideband_async; int sideband_async_started = 0; int saved_stderr = -1; @@ -961,9 +967,6 @@ static int run_receive_hook(struct command *commands, prepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started); /* set up stdin callback */ - feed_init_state.cmd = commands; - feed_init_state.skip_broken = skip_broken; - strbuf_init(&feed_init_state.buf, 0); opt.feed_pipe_ctx = &feed_init_state; opt.feed_pipe = feed_receive_hook_cb; opt.feed_pipe_cb_data_alloc = receive_hook_feed_state_alloc; diff --git a/hook.c b/hook.c index 67cc9a66df..935237fc1d 100644 --- a/hook.c +++ b/hook.c @@ -1,16 +1,16 @@ #include "git-compat-util.h" #include "abspath.h" #include "advice.h" +#include "config.h" +#include "environment.h" #include "gettext.h" #include "hook.h" -#include "path.h" #include "parse.h" +#include "path.h" #include "run-command.h" -#include "config.h" +#include "setup.h" #include "strbuf.h" #include "strmap.h" -#include "environment.h" -#include "setup.h" const char *find_hook(struct repository *r, const char *name) { @@ -57,9 +57,9 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free) if (!h) return; - if (h->kind == HOOK_TRADITIONAL) + if (h->kind == HOOK_TRADITIONAL) { free((void *)h->u.traditional.path); - else if (h->kind == HOOK_CONFIGURED) { + } else if (h->kind == HOOK_CONFIGURED) { free((void *)h->u.configured.friendly_name); free((void *)h->u.configured.command); } @@ -91,7 +91,7 @@ static void list_hooks_add_default(struct repository *r, const char *hookname, if (!hook_path) return; - h = xcalloc(1, sizeof(struct hook)); + CALLOC_ARRAY(h, 1); /* * If the hook is to run in a specific dir, a relative path can @@ -154,7 +154,7 @@ static int hook_config_lookup_all(const char *key, const char *value, strmap_get(&data->event_hooks, value); if (!hooks) { - hooks = xcalloc(1, sizeof(*hooks)); + CALLOC_ARRAY(hooks, 1); string_list_init_dup(hooks); strmap_put(&data->event_hooks, value, hooks); } @@ -227,8 +227,9 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) /* Construct the cache from parsed configs. */ strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { struct string_list *hook_names = e->value; - struct string_list *hooks = xcalloc(1, sizeof(*hooks)); + struct string_list *hooks; + CALLOC_ARRAY(hooks, 1); string_list_init_dup(hooks); for (size_t i = 0; i < hook_names->nr; i++) { @@ -281,7 +282,7 @@ static struct strmap *get_hook_config_cache(struct repository *r) * it just once on the first call. */ if (!r->hook_config_cache) { - r->hook_config_cache = xcalloc(1, sizeof(*cache)); + CALLOC_ARRAY(r->hook_config_cache, 1); strmap_init(r->hook_config_cache); build_hook_config_map(r, r->hook_config_cache); } @@ -289,9 +290,9 @@ static struct strmap *get_hook_config_cache(struct repository *r) } else { /* * Out-of-repo calls (no gitdir) allocate and return a temporary - * map cache which gets free'd immediately by the caller. + * cache which gets freed immediately by the caller. */ - cache = xcalloc(1, sizeof(*cache)); + CALLOC_ARRAY(cache, 1); strmap_init(cache); build_hook_config_map(r, cache); } @@ -311,7 +312,9 @@ static void list_hooks_add_configured(struct repository *r, for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) { const char *friendly_name = configured_hooks->items[i].string; const char *command = configured_hooks->items[i].util; - struct hook *hook = xcalloc(1, sizeof(struct hook)); + struct hook *hook; + + CALLOC_ARRAY(hook, 1); if (options && options->feed_pipe_cb_data_alloc) hook->feed_pipe_cb_data = @@ -343,7 +346,7 @@ struct string_list *list_hooks(struct repository *r, const char *hookname, if (!hookname) BUG("null hookname was provided to hook_list()!"); - hook_head = xmalloc(sizeof(struct string_list)); + CALLOC_ARRAY(hook_head, 1); string_list_init_dup(hook_head); /* Add hooks from the config, e.g. hook.myhook.event = pre-commit */ @@ -493,8 +496,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name, * Ensure cb_data copy and free functions are either provided together, * or neither one is provided. */ - if ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) || - (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free)) + if (!options->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free) BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together"); if (options->invoked_hook) diff --git a/hook.h b/hook.h index e949f5d488..1c447cbb6b 100644 --- a/hook.h +++ b/hook.h @@ -1,9 +1,9 @@ #ifndef HOOK_H #define HOOK_H -#include "strvec.h" #include "run-command.h" #include "string-list.h" #include "strmap.h" +#include "strvec.h" struct repository; @@ -46,8 +46,7 @@ struct hook { typedef void (*cb_data_free_fn)(void *data); typedef void *(*cb_data_alloc_fn)(void *init_ctx); -struct run_hooks_opt -{ +struct run_hooks_opt { /* Environment vars to be set for each hook */ struct strvec env; diff --git a/refs.c b/refs.c index a3363518e8..bd91c5c882 100644 --- a/refs.c +++ b/refs.c @@ -2599,7 +2599,8 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_ static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED) { - struct transaction_feed_cb_data *data = xmalloc(sizeof(*data)); + struct transaction_feed_cb_data *data; + CALLOC_ARRAY(data, 1); strbuf_init(&data->buf, 0); data->index = 0; return data; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index b1583e9ef9..952bf97b86 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -34,7 +34,7 @@ test_expect_success 'git hook usage' ' test_expect_success 'git hook list: nonexistent hook' ' cat >stderr.expect <<-\EOF && - warning: No hooks found for event '\''test-hook'\'' + warning: no hooks found for event '\''test-hook'\'' EOF test_expect_code 1 git hook list test-hook 2>stderr.actual && test_cmp stderr.expect stderr.actual diff --git a/transport.c b/transport.c index 107f4fa5dc..56a4015389 100644 --- a/transport.c +++ b/transport.c @@ -1360,7 +1360,8 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void static void *pre_push_hook_data_alloc(void *feed_pipe_ctx) { - struct feed_pre_push_hook_data *data = xmalloc(sizeof(*data)); + struct feed_pre_push_hook_data *data; + CALLOC_ARRAY(data, 1); strbuf_init(&data->buf, 0); data->refs = (struct ref *)feed_pipe_ctx; return data; -- cgit v1.2.3 From a8b1ba86d494ea8825292c91c243e5d84fd7ee2c Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Wed, 25 Mar 2026 21:54:57 +0200 Subject: hook: replace hook_list_clear() -> string_list_clear_func() Replace the custom function with string_list_clear_func() which is a more common pattern for clearing a string_list. To be able to do this, rework hook_clear() into hook_free(), so it can be passed to string_list_clear_func(). A slight complication is the need to keep a copy of the internal cb data free() pointer, however I think it's worth it since the API becomes cleaner, e.g. no more calls with NULL function args like hook_list_clear(hooks, NULL). In other words, the callers don't need to keep track of hook internal state to determine when cleanup is necessary or not (pass NULL) because each `struct hook` now owns its data_free callback. Suggested-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/hook.c | 2 +- hook.c | 40 ++++++++++++++++++++++------------------ hook.h | 20 ++++++++++++++------ 3 files changed, 37 insertions(+), 25 deletions(-) (limited to 'builtin') diff --git a/builtin/hook.c b/builtin/hook.c index e641614b84..54b737990b 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -78,7 +78,7 @@ static int list(int argc, const char **argv, const char *prefix, } cleanup: - hook_list_clear(head, NULL); + string_list_clear_func(head, hook_free); free(head); return ret; } diff --git a/hook.c b/hook.c index b0226ed716..021110f216 100644 --- a/hook.c +++ b/hook.c @@ -52,8 +52,10 @@ const char *find_hook(struct repository *r, const char *name) return path.buf; } -static void hook_clear(struct hook *h, hook_data_free_fn cb_data_free) +void hook_free(void *p, const char *str UNUSED) { + struct hook *h = p; + if (!h) return; @@ -64,22 +66,12 @@ static void hook_clear(struct hook *h, hook_data_free_fn cb_data_free) free((void *)h->u.configured.command); } - if (cb_data_free) - cb_data_free(h->feed_pipe_cb_data); + if (h->data_free && h->feed_pipe_cb_data) + h->data_free(h->feed_pipe_cb_data); free(h); } -void hook_list_clear(struct string_list *hooks, hook_data_free_fn cb_data_free) -{ - struct string_list_item *item; - - for_each_string_list_item(item, hooks) - hook_clear(item->util, cb_data_free); - - string_list_clear(hooks, 0); -} - /* Helper to detect and add default "traditional" hooks from the hookdir. */ static void list_hooks_add_default(struct repository *r, const char *hookname, struct string_list *hook_list, @@ -100,9 +92,15 @@ static void list_hooks_add_default(struct repository *r, const char *hookname, if (options && options->dir) hook_path = absolute_path(hook_path); - /* Setup per-hook internal state cb data */ - if (options && options->feed_pipe_cb_data_alloc) + /* + * Setup per-hook internal state callback data. + * When provided, the alloc/free callbacks are always provided + * together, so use them to alloc/free the internal hook state. + */ + if (options && options->feed_pipe_cb_data_alloc) { h->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx); + h->data_free = options->feed_pipe_cb_data_free; + } h->kind = HOOK_TRADITIONAL; h->u.traditional.path = xstrdup(hook_path); @@ -316,10 +314,16 @@ static void list_hooks_add_configured(struct repository *r, CALLOC_ARRAY(hook, 1); - if (options && options->feed_pipe_cb_data_alloc) + /* + * When provided, the alloc/free callbacks are always provided + * together, so use them to alloc/free the internal hook state. + */ + if (options && options->feed_pipe_cb_data_alloc) { hook->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc( options->feed_pipe_ctx); + hook->data_free = options->feed_pipe_cb_data_free; + } hook->kind = HOOK_CONFIGURED; hook->u.configured.friendly_name = xstrdup(friendly_name); @@ -362,7 +366,7 @@ int hook_exists(struct repository *r, const char *name) { struct string_list *hooks = list_hooks(r, name, NULL); int exists = hooks->nr > 0; - hook_list_clear(hooks, NULL); + string_list_clear_func(hooks, hook_free); free(hooks); return exists; } @@ -516,7 +520,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name, run_processes_parallel(&opts); ret = cb_data.rc; cleanup: - hook_list_clear(cb_data.hook_command_list, options->feed_pipe_cb_data_free); + string_list_clear_func(cb_data.hook_command_list, hook_free); free(cb_data.hook_command_list); run_hooks_opt_clear(options); return ret; diff --git a/hook.h b/hook.h index 965794a5b8..a56ac20ccf 100644 --- a/hook.h +++ b/hook.h @@ -7,6 +7,9 @@ struct repository; +typedef void (*hook_data_free_fn)(void *data); +typedef void *(*hook_data_alloc_fn)(void *init_ctx); + /** * Represents a hook command to be run. * Hooks can be: @@ -41,10 +44,15 @@ struct hook { * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it. */ void *feed_pipe_cb_data; -}; -typedef void (*hook_data_free_fn)(void *data); -typedef void *(*hook_data_alloc_fn)(void *init_ctx); + /** + * Callback to free `feed_pipe_cb_data`. + * + * It is called automatically and points to the `feed_pipe_cb_data_free` + * provided via the `run_hook_opt` parameter. + */ + hook_data_free_fn data_free; +}; struct run_hooks_opt { /* Environment vars to be set for each hook */ @@ -185,10 +193,10 @@ struct string_list *list_hooks(struct repository *r, const char *hookname, struct run_hooks_opt *options); /** - * Frees the memory allocated for the hook list, including the `struct hook` - * items and their internal state. + * Frees a struct hook stored as the util pointer of a string_list_item. + * Suitable for use as a string_list_clear_func_t callback. */ -void hook_list_clear(struct string_list *hooks, hook_data_free_fn cb_data_free); +void hook_free(void *p, const char *str); /** * Frees the hook configuration cache stored in `struct repository`. -- cgit v1.2.3 From b66efad2b1f53755a80699dc39f94e2b15d6af67 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Wed, 25 Mar 2026 21:55:01 +0200 Subject: hook: show config scope in git hook list Users running "git hook list" can see which hooks are configured but have no way to tell at which config scope (local, global, system...) each hook was defined. Store the scope from ctx->kvi->scope in the single-pass config callback, then carry it through the cache to the hook structs, so we can expose it to users via the "git hook list --show-scope" flag, which mirrors the existing git config --show-scope convention. Without the flag the output is unchanged. The scope is printed as a tab-separated prefix (like "git config --show-scope"), making it unambiguously machine-parseable even when the friendly name contains spaces. Example usage: $ git hook list --show-scope pre-commit global linter local no-leaks hook from hookdir Traditional hooks from the hookdir are unaffected by --show-scope since the config scope concept does not apply to them. Suggested-by: Junio C Hamano Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/git-hook.adoc | 10 ++++++++-- builtin/hook.c | 14 ++++++++++++-- hook.c | 24 ++++++++++++++++++++---- hook.h | 2 ++ t/t1800-hook.sh | 21 +++++++++++++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) (limited to 'builtin') diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc index 966388660a..e7d399ae57 100644 --- a/Documentation/git-hook.adoc +++ b/Documentation/git-hook.adoc @@ -9,7 +9,7 @@ SYNOPSIS -------- [verse] 'git hook' run [--ignore-missing] [--to-stdin=] [-- ] -'git hook' list [-z] +'git hook' list [-z] [--show-scope] DESCRIPTION ----------- @@ -113,7 +113,7 @@ Any positional arguments to the hook should be passed after a mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See linkgit:githooks[5] for arguments hooks might expect (if any). -list [-z]:: +list [-z] [--show-scope]:: Print a list of hooks which will be run on `` event. If no hooks are configured for that event, print a warning and return 1. Use `-z` to terminate output lines with NUL instead of newlines. @@ -134,6 +134,12 @@ OPTIONS -z:: Terminate "list" output lines with NUL instead of newlines. +--show-scope:: + For "list"; prefix each configured hook's friendly name with a + tab-separated config scope (e.g. `local`, `global`, `system`), + mirroring the output style of `git config --show-scope`. Traditional + hooks from the hookdir are unaffected. + WRAPPERS -------- diff --git a/builtin/hook.c b/builtin/hook.c index 54b737990b..4cc65a0dc5 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -9,7 +9,7 @@ #define BUILTIN_HOOK_RUN_USAGE \ N_("git hook run [--ignore-missing] [--to-stdin=] [-- ]") #define BUILTIN_HOOK_LIST_USAGE \ - N_("git hook list [-z] ") + N_("git hook list [-z] [--show-scope] ") static const char * const builtin_hook_usage[] = { BUILTIN_HOOK_RUN_USAGE, @@ -33,11 +33,14 @@ static int list(int argc, const char **argv, const char *prefix, struct string_list_item *item; const char *hookname = NULL; int line_terminator = '\n'; + int show_scope = 0; int ret = 0; struct option list_options[] = { OPT_SET_INT('z', NULL, &line_terminator, N_("use NUL as line terminator"), '\0'), + OPT_BOOL(0, "show-scope", &show_scope, + N_("show the config scope that defined each hook")), OPT_END(), }; @@ -70,7 +73,14 @@ static int list(int argc, const char **argv, const char *prefix, printf("%s%c", _("hook from hookdir"), line_terminator); break; case HOOK_CONFIGURED: - printf("%s%c", h->u.configured.friendly_name, line_terminator); + if (show_scope) + printf("%s\t%s%c", + config_scope_name(h->u.configured.scope), + h->u.configured.friendly_name, + line_terminator); + else + printf("%s%c", h->u.configured.friendly_name, + line_terminator); break; default: BUG("unknown hook kind"); diff --git a/hook.c b/hook.c index 54f99f4989..74f5a1df35 100644 --- a/hook.c +++ b/hook.c @@ -110,11 +110,11 @@ static void list_hooks_add_default(struct repository *r, const char *hookname, /* * Cache entry stored as the .util pointer of string_list items inside the - * hook config cache. For now carries only the command for the hook. Next - * commits will add more data. + * hook config cache. */ struct hook_config_cache_entry { char *command; + enum config_scope scope; }; /* @@ -131,7 +131,7 @@ struct hook_all_config_cb { /* repo_config() callback that collects all hook.* configuration in one pass. */ static int hook_config_lookup_all(const char *key, const char *value, - const struct config_context *ctx UNUSED, + const struct config_context *ctx, void *cb_data) { struct hook_all_config_cb *data = cb_data; @@ -168,7 +168,19 @@ static int hook_config_lookup_all(const char *key, const char *value, /* Re-insert if necessary to preserve last-seen order. */ unsorted_string_list_remove(hooks, hook_name, 0); - string_list_append(hooks, hook_name); + + if (!ctx->kvi) + BUG("hook config callback called without key-value info"); + + /* + * Stash the config scope in the util pointer for + * later retrieval in build_hook_config_map(). This + * intermediate struct is transient and never leaves + * that function, so we pack the enum value into the + * pointer rather than heap-allocating a wrapper. + */ + string_list_append(hooks, hook_name)->util = + (void *)(uintptr_t)ctx->kvi->scope; } } else if (!strcmp(subkey, "command")) { /* Store command overwriting the old value */ @@ -246,6 +258,8 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) for (size_t i = 0; i < hook_names->nr; i++) { const char *hname = hook_names->items[i].string; + enum config_scope scope = + (enum config_scope)(uintptr_t)hook_names->items[i].util; struct hook_config_cache_entry *entry; char *command; @@ -263,6 +277,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) /* util stores a cache entry; owned by the cache. */ CALLOC_ARRAY(entry, 1); entry->command = xstrdup(command); + entry->scope = scope; string_list_append(hooks, hname)->util = entry; } @@ -344,6 +359,7 @@ static void list_hooks_add_configured(struct repository *r, hook->kind = HOOK_CONFIGURED; hook->u.configured.friendly_name = xstrdup(friendly_name); hook->u.configured.command = xstrdup(entry->command); + hook->u.configured.scope = entry->scope; string_list_append(list, friendly_name)->util = hook; } diff --git a/hook.h b/hook.h index d2cf59e649..a0432e8307 100644 --- a/hook.h +++ b/hook.h @@ -1,5 +1,6 @@ #ifndef HOOK_H #define HOOK_H +#include "config.h" #include "run-command.h" #include "string-list.h" #include "strmap.h" @@ -29,6 +30,7 @@ struct hook { struct { const char *friendly_name; const char *command; + enum config_scope scope; } configured; } u; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 7eee84fc39..6fc6603da8 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -408,6 +408,27 @@ test_expect_success 'configured hooks run before hookdir hook' ' test_cmp expected actual ' +test_expect_success 'git hook list --show-scope shows config scope' ' + setup_hookdir && + test_config_global hook.global-hook.command "echo global" && + test_config_global hook.global-hook.event pre-commit --add && + test_config hook.local-hook.command "echo local" && + test_config hook.local-hook.event pre-commit --add && + + cat >expected <<-\EOF && + global global-hook + local local-hook + hook from hookdir + EOF + git hook list --show-scope pre-commit >actual && + test_cmp expected actual && + + # without --show-scope the scope must not appear + git hook list pre-commit >actual && + test_grep ! "^global " actual && + test_grep ! "^local " actual +' + test_expect_success 'git hook run a hook with a bad shebang' ' test_when_finished "rm -rf bad-hooks" && mkdir bad-hooks && -- cgit v1.2.3 From e17bd99281ae01a758d717bdfaa759bbeefb6149 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Wed, 25 Mar 2026 21:55:02 +0200 Subject: hook: show disabled hooks in "git hook list" Disabled hooks were filtered out of the cache entirely, making them invisible to "git hook list". Keep them in the cache with a new "disabled" flag which is propagated to the respective struct hook. "git hook list" now shows disabled hooks as tab-separated columns, with the status as a prefix before the name (like scope with --show-scope). With --show-scope it looks like: $ git hook list --show-scope pre-commit global linter local disabled no-leaks hook from hookdir A disabled hook without a command issues a warning instead of the fatal "hook.X.command must be configured" error. We could also throw an error, however it seemd a bit excessive to me in this case. Suggested-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/hook.c | 20 ++++++++++++-------- hook.c | 54 +++++++++++++++++++++++++++++++++++++----------------- hook.h | 1 + t/t1800-hook.sh | 33 ++++++++++++++++++++++++++++++--- 4 files changed, 80 insertions(+), 28 deletions(-) (limited to 'builtin') diff --git a/builtin/hook.c b/builtin/hook.c index 4cc65a0dc5..f671e7f91a 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -72,16 +72,20 @@ static int list(int argc, const char **argv, const char *prefix, case HOOK_TRADITIONAL: printf("%s%c", _("hook from hookdir"), line_terminator); break; - case HOOK_CONFIGURED: - if (show_scope) - printf("%s\t%s%c", - config_scope_name(h->u.configured.scope), - h->u.configured.friendly_name, - line_terminator); + case HOOK_CONFIGURED: { + const char *name = h->u.configured.friendly_name; + const char *scope = show_scope ? + config_scope_name(h->u.configured.scope) : NULL; + if (scope) + printf("%s\t%s%s%c", scope, + h->u.configured.disabled ? "disabled\t" : "", + name, line_terminator); else - printf("%s%c", h->u.configured.friendly_name, - line_terminator); + printf("%s%s%c", + h->u.configured.disabled ? "disabled\t" : "", + name, line_terminator); break; + } default: BUG("unknown hook kind"); } diff --git a/hook.c b/hook.c index 74f5a1df35..cc23276d27 100644 --- a/hook.c +++ b/hook.c @@ -115,6 +115,7 @@ static void list_hooks_add_default(struct repository *r, const char *hookname, struct hook_config_cache_entry { char *command; enum config_scope scope; + bool disabled; }; /* @@ -213,8 +214,10 @@ static int hook_config_lookup_all(const char *key, const char *value, * every item's string is the hook's friendly-name and its util pointer is * the corresponding command string. Both strings are owned by the map. * - * Disabled hooks and hooks missing a command are already filtered out at - * parse time, so callers can iterate the list directly. + * Disabled hooks are kept in the cache with entry->disabled set, so that + * "git hook list" can display them. A non-disabled hook missing a command + * is fatal; a disabled hook missing a command emits a warning and is kept + * in the cache with entry->command = NULL. */ void hook_cache_clear(struct strmap *cache) { @@ -263,21 +266,26 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) struct hook_config_cache_entry *entry; char *command; - /* filter out disabled hooks */ - if (unsorted_string_list_lookup(&cb_data.disabled_hooks, - hname)) - continue; + bool is_disabled = + !!unsorted_string_list_lookup( + &cb_data.disabled_hooks, hname); command = strmap_get(&cb_data.commands, hname); - if (!command) - die(_("'hook.%s.command' must be configured or " - "'hook.%s.event' must be removed;" - " aborting."), hname, hname); + if (!command) { + if (is_disabled) + warning(_("disabled hook '%s' has no " + "command configured"), hname); + else + die(_("'hook.%s.command' must be configured or " + "'hook.%s.event' must be removed;" + " aborting."), hname, hname); + } /* util stores a cache entry; owned by the cache. */ CALLOC_ARRAY(entry, 1); - entry->command = xstrdup(command); + entry->command = xstrdup_or_null(command); entry->scope = scope; + entry->disabled = is_disabled; string_list_append(hooks, hname)->util = entry; } @@ -358,8 +366,10 @@ static void list_hooks_add_configured(struct repository *r, hook->kind = HOOK_CONFIGURED; hook->u.configured.friendly_name = xstrdup(friendly_name); - hook->u.configured.command = xstrdup(entry->command); + hook->u.configured.command = + entry->command ? xstrdup(entry->command) : NULL; hook->u.configured.scope = entry->scope; + hook->u.configured.disabled = entry->disabled; string_list_append(list, friendly_name)->util = hook; } @@ -397,7 +407,16 @@ struct string_list *list_hooks(struct repository *r, const char *hookname, int hook_exists(struct repository *r, const char *name) { struct string_list *hooks = list_hooks(r, name, NULL); - int exists = hooks->nr > 0; + int exists = 0; + + for (size_t i = 0; i < hooks->nr; i++) { + struct hook *h = hooks->items[i].util; + if (h->kind == HOOK_TRADITIONAL || + !h->u.configured.disabled) { + exists = 1; + break; + } + } string_list_clear_func(hooks, hook_free); free(hooks); return exists; @@ -412,10 +431,11 @@ static int pick_next_hook(struct child_process *cp, struct string_list *hook_list = hook_cb->hook_command_list; struct hook *h; - if (hook_cb->hook_to_run_index >= hook_list->nr) - return 0; - - h = hook_list->items[hook_cb->hook_to_run_index++].util; + do { + if (hook_cb->hook_to_run_index >= hook_list->nr) + return 0; + h = hook_list->items[hook_cb->hook_to_run_index++].util; + } while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled); cp->no_stdin = 1; strvec_pushv(&cp->env, hook_cb->options->env.v); diff --git a/hook.h b/hook.h index a0432e8307..5c5628dd1f 100644 --- a/hook.h +++ b/hook.h @@ -31,6 +31,7 @@ struct hook { const char *friendly_name; const char *command; enum config_scope scope; + bool disabled; } configured; } u; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 6fc6603da8..8c5237449d 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -357,7 +357,15 @@ test_expect_success 'disabled hook is not run' ' test_must_be_empty actual ' -test_expect_success 'disabled hook does not appear in git hook list' ' +test_expect_success 'disabled hook with no command warns' ' + test_config hook.nocommand.event "pre-commit" && + test_config hook.nocommand.enabled false && + + git hook list pre-commit 2>actual && + test_grep "disabled hook.*nocommand.*no command configured" actual +' + +test_expect_success 'disabled hook appears as disabled in git hook list' ' test_config hook.active.event "pre-commit" && test_config hook.active.command "echo active" && test_config hook.inactive.event "pre-commit" && @@ -365,8 +373,27 @@ test_expect_success 'disabled hook does not appear in git hook list' ' test_config hook.inactive.enabled false && git hook list pre-commit >actual && - test_grep "active" actual && - test_grep ! "inactive" actual + test_grep "^active$" actual && + test_grep "^disabled inactive$" actual +' + +test_expect_success 'disabled hook shows scope with --show-scope' ' + test_config hook.myhook.event "pre-commit" && + test_config hook.myhook.command "echo hi" && + test_config hook.myhook.enabled false && + + git hook list --show-scope pre-commit >actual && + test_grep "^local disabled myhook$" actual +' + +test_expect_success 'disabled configured hook is not reported as existing by hook_exists' ' + test_when_finished "rm -f git-bugreport-hook-exists-test.txt" && + test_config hook.linter.event "pre-commit" && + test_config hook.linter.command "echo lint" && + test_config hook.linter.enabled false && + + git bugreport -s hook-exists-test && + test_grep ! "pre-commit" git-bugreport-hook-exists-test.txt ' test_expect_success 'globally disabled hook can be re-enabled locally' ' -- cgit v1.2.3 From 5c58dbc887a1f3530cb29c995f63675beebb22e9 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Wed, 25 Mar 2026 21:55:03 +0200 Subject: hook: reject unknown hook names in git-hook(1) Teach "git hook run" and "git hook list" to reject hook event names that are not recognized by Git. This helps catch typos such as "prereceive" when "pre-receive" was intended, since in 99% of the cases users want known (already-existing) hook names. The list of known hooks is derived from the generated hook-list.h (built from Documentation/githooks.adoc). This is why the Makefile is updated, so builtin/hook.c depends on hook-list.h. In meson the header is already a dependency for all builtins, no change required. The "--allow-unknown-hook-name" flag can be used to bypass this check. Suggested-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/git-hook.adoc | 13 +++++-- Makefile | 1 + builtin/hook.c | 35 +++++++++++++++++-- t/t1800-hook.sh | 82 +++++++++++++++++++++++++++++++-------------- 4 files changed, 100 insertions(+), 31 deletions(-) (limited to 'builtin') diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc index e7d399ae57..318c637bd8 100644 --- a/Documentation/git-hook.adoc +++ b/Documentation/git-hook.adoc @@ -8,8 +8,8 @@ git-hook - Run git hooks SYNOPSIS -------- [verse] -'git hook' run [--ignore-missing] [--to-stdin=] [-- ] -'git hook' list [-z] [--show-scope] +'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=] [-- ] +'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] DESCRIPTION ----------- @@ -121,6 +121,13 @@ list [-z] [--show-scope]:: OPTIONS ------- +--allow-unknown-hook-name:: + By default `git hook run` and `git hook list` will bail out when + `` is not a hook event known to Git (see linkgit:githooks[5] + for the list of known hooks). This is meant to help catch typos + such as `prereceive` when `pre-receive` was intended. Pass this + flag to allow unknown hook names. + --to-stdin:: For "run"; specify a file which will be streamed into the hook's stdin. The hook will receive the entire file from @@ -159,7 +166,7 @@ Then, in your 'mywrapper' tool, you can invoke any users' configured hooks by running: ---- -git hook run mywrapper-start-tests \ +git hook run --allow-unknown-hook-name mywrapper-start-tests \ # providing something to stdin --stdin some-tempfile-123 \ # execute hooks in serial diff --git a/Makefile b/Makefile index f3264d0a37..c5a1b549a8 100644 --- a/Makefile +++ b/Makefile @@ -2663,6 +2663,7 @@ git$X: git.o GIT-LDFLAGS $(BUILTIN_OBJS) $(GITLIBS) help.sp help.s help.o: command-list.h builtin/bugreport.sp builtin/bugreport.s builtin/bugreport.o: hook-list.h +builtin/hook.sp builtin/hook.s builtin/hook.o: hook-list.h builtin/help.sp builtin/help.s builtin/help.o: config-list.h GIT-PREFIX builtin/help.sp builtin/help.s builtin/help.o: EXTRA_CPPFLAGS = \ diff --git a/builtin/hook.c b/builtin/hook.c index f671e7f91a..c0585587e5 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -4,12 +4,22 @@ #include "environment.h" #include "gettext.h" #include "hook.h" +#include "hook-list.h" #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ - N_("git hook run [--ignore-missing] [--to-stdin=] [-- ]") + N_("git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=] [-- ]") #define BUILTIN_HOOK_LIST_USAGE \ - N_("git hook list [-z] [--show-scope] ") + N_("git hook list [--allow-unknown-hook-name] [-z] [--show-scope] ") + +static int is_known_hook(const char *name) +{ + const char **p; + for (p = hook_name_list; *p; p++) + if (!strcmp(*p, name)) + return 1; + return 0; +} static const char * const builtin_hook_usage[] = { BUILTIN_HOOK_RUN_USAGE, @@ -34,6 +44,7 @@ static int list(int argc, const char **argv, const char *prefix, const char *hookname = NULL; int line_terminator = '\n'; int show_scope = 0; + int allow_unknown = 0; int ret = 0; struct option list_options[] = { @@ -41,6 +52,8 @@ static int list(int argc, const char **argv, const char *prefix, N_("use NUL as line terminator"), '\0'), OPT_BOOL(0, "show-scope", &show_scope, N_("show the config scope that defined each hook")), + OPT_BOOL(0, "allow-unknown-hook-name", &allow_unknown, + N_("allow running a hook with a non-native hook name")), OPT_END(), }; @@ -57,6 +70,13 @@ static int list(int argc, const char **argv, const char *prefix, hookname = argv[0]; + if (!allow_unknown && !is_known_hook(hookname)) { + error(_("unknown hook event '%s';\n" + "use --allow-unknown-hook-name to allow non-native hook names"), + hookname); + return 1; + } + head = list_hooks(repo, hookname, NULL); if (!head->nr) { @@ -103,8 +123,11 @@ static int run(int argc, const char **argv, const char *prefix, int i; struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; int ignore_missing = 0; + int allow_unknown = 0; const char *hook_name; struct option run_options[] = { + OPT_BOOL(0, "allow-unknown-hook-name", &allow_unknown, + N_("allow running a hook with a non-native hook name")), OPT_BOOL(0, "ignore-missing", &ignore_missing, N_("silently ignore missing requested ")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), @@ -136,6 +159,14 @@ static int run(int argc, const char **argv, const char *prefix, repo_config(the_repository, git_default_config, NULL); hook_name = argv[0]; + + if (!allow_unknown && !is_known_hook(hook_name)) { + error(_("unknown hook event '%s';\n" + "use --allow-unknown-hook-name to allow non-native hook names"), + hook_name); + return 1; + } + if (!ignore_missing) opt.error_if_missing = 1; ret = run_hooks_opt(the_repository, hook_name, &opt); diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 8c5237449d..96749fc06d 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -31,11 +31,41 @@ test_expect_success 'git hook usage' ' grep "unknown option" err ' +test_expect_success 'git hook list: unknown hook name is rejected' ' + test_must_fail git hook list prereceive 2>err && + test_grep "unknown hook event" err +' + +test_expect_success 'git hook run: unknown hook name is rejected' ' + test_must_fail git hook run prereceive 2>err && + test_grep "unknown hook event" err +' + +test_expect_success 'git hook list: known hook name is accepted' ' + test_must_fail git hook list pre-receive 2>err && + test_grep ! "unknown hook event" err +' + +test_expect_success 'git hook run: known hook name is accepted' ' + git hook run --ignore-missing pre-receive 2>err && + test_grep ! "unknown hook event" err +' + +test_expect_success 'git hook run: --allow-unknown-hook-name overrides rejection' ' + git hook run --allow-unknown-hook-name --ignore-missing custom-hook 2>err && + test_grep ! "unknown hook event" err +' + +test_expect_success 'git hook list: --allow-unknown-hook-name overrides rejection' ' + test_must_fail git hook list --allow-unknown-hook-name custom-hook 2>err && + test_grep ! "unknown hook event" err +' + test_expect_success 'git hook list: nonexistent hook' ' cat >stderr.expect <<-\EOF && warning: no hooks found for event '\''test-hook'\'' EOF - test_expect_code 1 git hook list test-hook 2>stderr.actual && + test_expect_code 1 git hook list --allow-unknown-hook-name test-hook 2>stderr.actual && test_cmp stderr.expect stderr.actual ' @@ -47,7 +77,7 @@ test_expect_success 'git hook list: traditional hook from hookdir' ' cat >expect <<-\EOF && hook from hookdir EOF - git hook list test-hook >actual && + git hook list --allow-unknown-hook-name test-hook >actual && test_cmp expect actual ' @@ -56,7 +86,7 @@ test_expect_success 'git hook list: configured hook' ' test_config hook.myhook.event test-hook --add && echo "myhook" >expect && - git hook list test-hook >actual && + git hook list --allow-unknown-hook-name test-hook >actual && test_cmp expect actual ' @@ -68,7 +98,7 @@ test_expect_success 'git hook list: -z shows NUL-terminated output' ' test_config hook.myhook.event test-hook --add && printf "myhookQhook from hookdirQ" >expect && - git hook list -z test-hook >actual.raw && + git hook list --allow-unknown-hook-name -z test-hook >actual.raw && nul_to_q actual && test_cmp expect actual ' @@ -77,12 +107,12 @@ test_expect_success 'git hook run: nonexistent hook' ' cat >stderr.expect <<-\EOF && error: cannot find a hook named test-hook EOF - test_expect_code 1 git hook run test-hook 2>stderr.actual && + test_expect_code 1 git hook run --allow-unknown-hook-name test-hook 2>stderr.actual && test_cmp stderr.expect stderr.actual ' test_expect_success 'git hook run: nonexistent hook with --ignore-missing' ' - git hook run --ignore-missing does-not-exist 2>stderr.actual && + git hook run --allow-unknown-hook-name --ignore-missing does-not-exist 2>stderr.actual && test_must_be_empty stderr.actual ' @@ -94,7 +124,7 @@ test_expect_success 'git hook run: basic' ' cat >expect <<-\EOF && Test hook EOF - git hook run test-hook 2>actual && + git hook run --allow-unknown-hook-name test-hook 2>actual && test_cmp expect actual ' @@ -108,7 +138,7 @@ test_expect_success 'git hook run: stdout and stderr both write to our stderr' ' Will end up on stderr Will end up on stderr EOF - git hook run test-hook >stdout.actual 2>stderr.actual && + git hook run --allow-unknown-hook-name test-hook >stdout.actual 2>stderr.actual && test_cmp stderr.expect stderr.actual && test_must_be_empty stdout.actual ' @@ -120,12 +150,12 @@ do exit $code EOF - test_expect_code $code git hook run test-hook + test_expect_code $code git hook run --allow-unknown-hook-name test-hook ' done test_expect_success 'git hook run arg u ments without -- is not allowed' ' - test_expect_code 129 git hook run test-hook arg u ments + test_expect_code 129 git hook run --allow-unknown-hook-name test-hook arg u ments ' test_expect_success 'git hook run -- pass arguments' ' @@ -139,7 +169,7 @@ test_expect_success 'git hook run -- pass arguments' ' u ments EOF - git hook run test-hook -- arg "u ments" 2>actual && + git hook run --allow-unknown-hook-name test-hook -- arg "u ments" 2>actual && test_cmp expect actual ' @@ -148,12 +178,12 @@ test_expect_success 'git hook run: out-of-repo runs execute global hooks' ' test_config_global hook.global-hook.command "echo no repo no problems" --add && echo "global-hook" >expect && - nongit git hook list test-hook >actual && + nongit git hook list --allow-unknown-hook-name test-hook >actual && test_cmp expect actual && echo "no repo no problems" >expect && - nongit git hook run test-hook 2>actual && + nongit git hook run --allow-unknown-hook-name test-hook 2>actual && test_cmp expect actual ' @@ -178,11 +208,11 @@ test_expect_success 'git -c core.hooksPath= hook run' ' # Test various ways of specifying the path. See also # t1350-config-hooks-path.sh >actual && - git hook run test-hook -- ignored 2>>actual && - git -c core.hooksPath=my-hooks hook run test-hook -- one 2>>actual && - git -c core.hooksPath=my-hooks/ hook run test-hook -- two 2>>actual && - git -c core.hooksPath="$PWD/my-hooks" hook run test-hook -- three 2>>actual && - git -c core.hooksPath="$PWD/my-hooks/" hook run test-hook -- four 2>>actual && + git hook run --allow-unknown-hook-name test-hook -- ignored 2>>actual && + git -c core.hooksPath=my-hooks hook run --allow-unknown-hook-name test-hook -- one 2>>actual && + git -c core.hooksPath=my-hooks/ hook run --allow-unknown-hook-name test-hook -- two 2>>actual && + git -c core.hooksPath="$PWD/my-hooks" hook run --allow-unknown-hook-name test-hook -- three 2>>actual && + git -c core.hooksPath="$PWD/my-hooks/" hook run --allow-unknown-hook-name test-hook -- four 2>>actual && test_cmp expect actual ' @@ -262,7 +292,7 @@ test_expect_success 'hook can be configured for multiple events' ' # 'ghi' should be included in both 'pre-commit' and 'test-hook' git hook list pre-commit >actual && grep "ghi" actual && - git hook list test-hook >actual && + git hook list --allow-unknown-hook-name test-hook >actual && grep "ghi" actual ' @@ -336,15 +366,15 @@ test_expect_success 'stdin to multiple hooks' ' b3 EOF - git hook run --to-stdin=input test-hook 2>actual && + git hook run --allow-unknown-hook-name --to-stdin=input test-hook 2>actual && test_cmp expected actual ' test_expect_success 'rejects hooks with no commands configured' ' test_config hook.broken.event "test-hook" && - test_must_fail git hook list test-hook 2>actual && + test_must_fail git hook list --allow-unknown-hook-name test-hook 2>actual && test_grep "hook.broken.command" actual && - test_must_fail git hook run test-hook 2>actual && + test_must_fail git hook run --allow-unknown-hook-name test-hook 2>actual && test_grep "hook.broken.command" actual ' @@ -353,7 +383,7 @@ test_expect_success 'disabled hook is not run' ' test_config hook.skipped.command "echo \"Should not run\"" && test_config hook.skipped.enabled false && - git hook run --ignore-missing test-hook 2>actual && + git hook run --allow-unknown-hook-name --ignore-missing test-hook 2>actual && test_must_be_empty actual ' @@ -403,7 +433,7 @@ test_expect_success 'globally disabled hook can be re-enabled locally' ' test_config hook.global-hook.enabled true && echo "global-hook ran" >expected && - git hook run test-hook 2>actual && + git hook run --allow-unknown-hook-name test-hook 2>actual && test_cmp expected actual ' @@ -463,7 +493,7 @@ test_expect_success 'git hook run a hook with a bad shebang' ' test_expect_code 1 git \ -c core.hooksPath=bad-hooks \ - hook run test-hook >out 2>err && + hook run --allow-unknown-hook-name test-hook >out 2>err && test_must_be_empty out && # TODO: We should emit the same (or at least a more similar) @@ -487,7 +517,7 @@ test_expect_success 'stdin to hooks' ' EOF echo hello >input && - git hook run --to-stdin=input test-hook 2>actual && + git hook run --allow-unknown-hook-name --to-stdin=input test-hook 2>actual && test_cmp expect actual ' -- cgit v1.2.3 From 2760ee49834953c0860fa5d7983a6af4d27cb6a9 Mon Sep 17 00:00:00 2001 From: Siddharth Asthana Date: Thu, 26 Mar 2026 01:53:52 +0530 Subject: replay: add --revert mode to reverse commit changes Add a `--revert ` mode to git replay that undoes the changes introduced by the specified commits. Like --onto and --advance, --revert is a standalone mode: it takes a branch argument and updates that branch with the newly created revert commits. At GitLab, we need this in Gitaly for reverting commits directly on bare repositories without requiring a working tree checkout. The approach is the same as sequencer.c's do_pick_commit() -- cherry-pick and revert are just the same three-way merge with swapped arguments: - Cherry-pick: merge(ancestor=parent, ours=current, theirs=commit) - Revert: merge(ancestor=commit, ours=current, theirs=parent) We swap the base and pickme trees passed to merge_incore_nonrecursive() to reverse the diff direction. Reverts are processed newest-first (matching git revert behavior) to reduce conflicts by peeling off changes from the top. Each revert builds on the result of the previous one via the last_commit fallback in the main replay loop, rather than relying on the parent-mapping used for cherry-pick. Revert commit messages follow the usual git revert conventions: prefixed with "Revert" (or "Reapply" when reverting a revert), and including "This reverts commit .". The author is set to the current user rather than preserving the original author, matching git revert behavior. Helped-by: Christian Couder Helped-by: Patrick Steinhardt Helped-by: Elijah Newren Helped-by: Phillip Wood Helped-by: Johannes Schindelin Helped-by: Junio C Hamano Helped-by: Toon Claes Signed-off-by: Siddharth Asthana Signed-off-by: Junio C Hamano --- Documentation/git-replay.adoc | 52 +++++++++++--- builtin/replay.c | 36 +++++++--- replay.c | 161 +++++++++++++++++++++++++++++++----------- replay.h | 11 ++- t/t3650-replay-basics.sh | 111 +++++++++++++++++++++++++++-- 5 files changed, 302 insertions(+), 69 deletions(-) (limited to 'builtin') diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index 8d696ce3ab..997097e420 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -9,7 +9,7 @@ git-replay - EXPERIMENTAL: Replay commits on a new base, works with bare repos t SYNOPSIS -------- [verse] -(EXPERIMENTAL!) 'git replay' ([--contained] --onto | --advance ) [--ref-action[=]] +(EXPERIMENTAL!) 'git replay' ([--contained] --onto | --advance | --revert ) [--ref-action[=]] DESCRIPTION ----------- @@ -42,6 +42,25 @@ The history is replayed on top of the and is updated to point at the tip of the resulting history. This is different from `--onto`, which uses the target only as a starting point without updating it. +--revert :: + Starting point at which to create the reverted commits; must be a + branch name. ++ +When `--revert` is specified, the commits in the revision range are reverted +(their changes are undone) and the reverted commits are created on top of +. The is then updated to point at the new commits. This is +the same as running `git revert ` but does not update the +working tree. ++ +The commit messages follow `git revert` conventions: they are prefixed with +"Revert" and include "This reverts commit ." When reverting a commit +whose message starts with "Revert", the new message uses "Reapply" instead. +Unlike cherry-pick which preserves the original author, revert commits use +the current user as the author, matching the behavior of `git revert`. ++ +This option is mutually exclusive with `--onto` and `--advance`. It is also +incompatible with `--contained` (which is a modifier for `--onto` only). + --contained:: Update all branches that point at commits in . Requires `--onto`. @@ -60,10 +79,11 @@ The default mode can be configured via the `replay.refAction` configuration vari :: Range of commits to replay; see "Specifying Ranges" in - linkgit:git-rev-parse[1]. In `--advance ` mode, the - range should have a single tip, so that it's clear to which tip the - advanced should point. Any commits in the range whose - changes are already present in the branch the commits are being + linkgit:git-rev-parse[1]. In `--advance ` or + `--revert ` mode, the range should have a single tip, + so that it's clear to which tip the advanced or reverted + should point. Any commits in the range whose changes + are already present in the branch the commits are being replayed onto will be dropped. :git-replay: 1 @@ -84,9 +104,10 @@ When using `--ref-action=print`, the output is usable as input to update refs/heads/branch3 ${NEW_branch3_HASH} ${OLD_branch3_HASH} where the number of refs updated depends on the arguments passed and -the shape of the history being replayed. When using `--advance`, the -number of refs updated is always one, but for `--onto`, it can be one -or more (rebasing multiple branches simultaneously is supported). +the shape of the history being replayed. When using `--advance` or +`--revert`, the number of refs updated is always one, but for `--onto`, +it can be one or more (rebasing multiple branches simultaneously is +supported). There is no stderr output on conflicts; see the <> section below. @@ -152,6 +173,21 @@ all commits they have since `base`, playing them on top of `origin/main`. These three branches may have commits on top of `base` that they have in common, but that does not need to be the case. +To revert commits on a branch: + +------------ +$ git replay --revert main topic~2..topic +------------ + +This reverts the last two commits from `topic`, creating revert commits on +top of `main`, and updates `main` to point at the result. This is useful when +commits from `topic` were previously merged or cherry-picked into `main` and +need to be undone. + +NOTE: For reverting an entire merge request as a single commit (rather than +commit-by-commit), consider using `git merge-tree --merge-base $TIP HEAD $BASE` +which can avoid unnecessary merge conflicts. + GIT --- Part of the linkgit:git[1] suite diff --git a/builtin/replay.c b/builtin/replay.c index 2cdde830a8..a0879b020f 100644 --- a/builtin/replay.c +++ b/builtin/replay.c @@ -79,11 +79,12 @@ int cmd_replay(int argc, struct ref_transaction *transaction = NULL; struct strbuf transaction_err = STRBUF_INIT; struct strbuf reflog_msg = STRBUF_INIT; + int desired_reverse; int ret = 0; const char *const replay_usage[] = { N_("(EXPERIMENTAL!) git replay " - "([--contained] --onto | --advance ) " + "([--contained] --onto | --advance | --revert ) " "[--ref-action[=]] "), NULL }; @@ -96,6 +97,9 @@ int cmd_replay(int argc, N_("replay onto given commit")), OPT_BOOL(0, "contained", &opts.contained, N_("update all branches that point at commits in ")), + OPT_STRING(0, "revert", &opts.revert, + N_("branch"), + N_("revert commits onto given branch")), OPT_STRING(0, "ref-action", &ref_action, N_("mode"), N_("control ref update behavior (update|print)")), @@ -105,19 +109,31 @@ int cmd_replay(int argc, argc = parse_options(argc, argv, prefix, replay_options, replay_usage, PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT); - if (!opts.onto && !opts.advance) { - error(_("option --onto or --advance is mandatory")); + /* Exactly one mode must be specified */ + if (!opts.onto && !opts.advance && !opts.revert) { + error(_("exactly one of --onto, --advance, or --revert is required")); usage_with_options(replay_usage, replay_options); } + die_for_incompatible_opt3(!!opts.onto, "--onto", + !!opts.advance, "--advance", + !!opts.revert, "--revert"); die_for_incompatible_opt2(!!opts.advance, "--advance", opts.contained, "--contained"); - die_for_incompatible_opt2(!!opts.advance, "--advance", - !!opts.onto, "--onto"); + die_for_incompatible_opt2(!!opts.revert, "--revert", + opts.contained, "--contained"); /* Parse ref action mode from command line or config */ ref_mode = get_ref_action_mode(repo, ref_action); + /* + * Cherry-pick/rebase need oldest-first ordering so that each + * replayed commit can build on its already-replayed parent. + * Revert needs newest-first ordering (like git revert) to + * reduce conflicts by peeling off changes from the top. + */ + desired_reverse = !opts.revert; + repo_init_revisions(repo, &revs, prefix); /* @@ -129,7 +145,7 @@ int cmd_replay(int argc, * some options changing these values if we think they could * be useful. */ - revs.reverse = 1; + revs.reverse = desired_reverse; revs.sort_order = REV_SORT_IN_GRAPH_ORDER; revs.topo_order = 1; revs.simplify_history = 0; @@ -144,11 +160,11 @@ int cmd_replay(int argc, * Detect and warn if we override some user specified rev * walking options. */ - if (revs.reverse != 1) { + if (revs.reverse != desired_reverse) { warning(_("some rev walking options will be overridden as " "'%s' bit in 'struct rev_info' will be forced"), "reverse"); - revs.reverse = 1; + revs.reverse = desired_reverse; } if (revs.sort_order != REV_SORT_IN_GRAPH_ORDER) { warning(_("some rev walking options will be overridden as " @@ -174,7 +190,9 @@ int cmd_replay(int argc, goto cleanup; /* Build reflog message */ - if (opts.advance) { + if (opts.revert) { + strbuf_addf(&reflog_msg, "replay --revert %s", opts.revert); + } else if (opts.advance) { strbuf_addf(&reflog_msg, "replay --advance %s", opts.advance); } else { struct object_id oid; diff --git a/replay.c b/replay.c index f97d652f33..199066f6b3 100644 --- a/replay.c +++ b/replay.c @@ -8,9 +8,15 @@ #include "refs.h" #include "replay.h" #include "revision.h" +#include "sequencer.h" #include "strmap.h" #include "tree.h" +enum replay_mode { + REPLAY_MODE_PICK, + REPLAY_MODE_REVERT, +}; + static const char *short_commit_name(struct repository *repo, struct commit *commit) { @@ -44,15 +50,37 @@ static char *get_author(const char *message) return NULL; } +static void generate_revert_message(struct strbuf *msg, + struct commit *commit, + struct repository *repo) +{ + const char *out_enc = get_commit_output_encoding(); + const char *message = repo_logmsg_reencode(repo, commit, NULL, out_enc); + const char *subject_start; + int subject_len; + char *subject; + + subject_len = find_commit_subject(message, &subject_start); + subject = xmemdupz(subject_start, subject_len); + + sequencer_format_revert_message(repo, subject, commit, + commit->parents ? commit->parents->item : NULL, + false, msg); + + free(subject); + repo_unuse_commit_buffer(repo, commit, message); +} + static struct commit *create_commit(struct repository *repo, struct tree *tree, struct commit *based_on, - struct commit *parent) + struct commit *parent, + enum replay_mode mode) { struct object_id ret; struct object *obj = NULL; struct commit_list *parents = NULL; - char *author; + char *author = NULL; char *sign_commit = NULL; /* FIXME: cli users might want to sign again */ struct commit_extra_header *extra = NULL; struct strbuf msg = STRBUF_INIT; @@ -64,9 +92,16 @@ static struct commit *create_commit(struct repository *repo, commit_list_insert(parent, &parents); extra = read_commit_extra_headers(based_on, exclude_gpgsig); - find_commit_subject(message, &orig_message); - strbuf_addstr(&msg, orig_message); - author = get_author(message); + if (mode == REPLAY_MODE_REVERT) { + generate_revert_message(&msg, based_on, repo); + /* For revert, use current user as author (NULL = use default) */ + } else if (mode == REPLAY_MODE_PICK) { + find_commit_subject(message, &orig_message); + strbuf_addstr(&msg, orig_message); + author = get_author(message); + } else { + BUG("unexpected replay mode %d", mode); + } reset_ident_date(); if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents, &ret, author, NULL, sign_commit, extra)) { @@ -147,11 +182,35 @@ static void get_ref_information(struct repository *repo, } } +static void set_up_branch_mode(struct repository *repo, + char **branch_name, + const char *option_name, + struct ref_info *rinfo, + struct commit **onto) +{ + struct object_id oid; + char *fullname = NULL; + + if (repo_dwim_ref(repo, *branch_name, strlen(*branch_name), + &oid, &fullname, 0) == 1) { + free(*branch_name); + *branch_name = fullname; + } else { + die(_("argument to %s must be a reference"), option_name); + } + *onto = peel_committish(repo, *branch_name, option_name); + if (rinfo->positive_refexprs > 1) + die(_("'%s' cannot be used with multiple revision ranges " + "because the ordering would be ill-defined"), + option_name); +} + static void set_up_replay_mode(struct repository *repo, struct rev_cmdline_info *cmd_info, const char *onto_name, bool *detached_head, char **advance_name, + char **revert_name, struct commit **onto, struct strset **update_refs) { @@ -166,9 +225,6 @@ static void set_up_replay_mode(struct repository *repo, if (!rinfo.positive_refexprs) die(_("need some commits to replay")); - if (!onto_name == !*advance_name) - BUG("one and only one of onto_name and *advance_name must be given"); - if (onto_name) { *onto = peel_committish(repo, onto_name, "--onto"); if (rinfo.positive_refexprs < @@ -177,23 +233,12 @@ static void set_up_replay_mode(struct repository *repo, *update_refs = xcalloc(1, sizeof(**update_refs)); **update_refs = rinfo.positive_refs; memset(&rinfo.positive_refs, 0, sizeof(**update_refs)); + } else if (*advance_name) { + set_up_branch_mode(repo, advance_name, "--advance", &rinfo, onto); + } else if (*revert_name) { + set_up_branch_mode(repo, revert_name, "--revert", &rinfo, onto); } else { - struct object_id oid; - char *fullname = NULL; - - if (!*advance_name) - BUG("expected either onto_name or *advance_name in this function"); - - if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name), - &oid, &fullname, 0) == 1) { - free(*advance_name); - *advance_name = fullname; - } else { - die(_("argument to --advance must be a reference")); - } - *onto = peel_committish(repo, *advance_name, "--advance"); - if (rinfo.positive_refexprs > 1) - die(_("cannot advance target with multiple sources because ordering would be ill-defined")); + BUG("expected one of onto_name, *advance_name, or *revert_name"); } strset_clear(&rinfo.negative_refs); strset_clear(&rinfo.positive_refs); @@ -214,7 +259,8 @@ static struct commit *pick_regular_commit(struct repository *repo, kh_oid_map_t *replayed_commits, struct commit *onto, struct merge_options *merge_opt, - struct merge_result *result) + struct merge_result *result, + enum replay_mode mode) { struct commit *base, *replayed_base; struct tree *pickme_tree, *base_tree, *replayed_base_tree; @@ -226,25 +272,45 @@ static struct commit *pick_regular_commit(struct repository *repo, pickme_tree = repo_get_commit_tree(repo, pickme); base_tree = repo_get_commit_tree(repo, base); - merge_opt->branch1 = short_commit_name(repo, replayed_base); - merge_opt->branch2 = short_commit_name(repo, pickme); - merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2); - - merge_incore_nonrecursive(merge_opt, - base_tree, - replayed_base_tree, - pickme_tree, - result); - - free((char*)merge_opt->ancestor); + if (mode == REPLAY_MODE_PICK) { + /* Cherry-pick: normal order */ + merge_opt->branch1 = short_commit_name(repo, replayed_base); + merge_opt->branch2 = short_commit_name(repo, pickme); + merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2); + + merge_incore_nonrecursive(merge_opt, + base_tree, + replayed_base_tree, + pickme_tree, + result); + + free((char *)merge_opt->ancestor); + } else if (mode == REPLAY_MODE_REVERT) { + /* Revert: swap base and pickme to reverse the diff */ + const char *pickme_name = short_commit_name(repo, pickme); + merge_opt->branch1 = short_commit_name(repo, replayed_base); + merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); + merge_opt->ancestor = pickme_name; + + merge_incore_nonrecursive(merge_opt, + pickme_tree, + replayed_base_tree, + base_tree, + result); + + free((char *)merge_opt->branch2); + } else { + BUG("unexpected replay mode %d", mode); + } merge_opt->ancestor = NULL; + merge_opt->branch2 = NULL; if (!result->clean) return NULL; /* Drop commits that become empty */ if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) && !oideq(&pickme_tree->object.oid, &base_tree->object.oid)) return replayed_base; - return create_commit(repo, result->tree, pickme, replayed_base); + return create_commit(repo, result->tree, pickme, replayed_base, mode); } void replay_result_release(struct replay_result *result) @@ -281,11 +347,16 @@ int replay_revisions(struct rev_info *revs, }; bool detached_head; char *advance; + char *revert; + enum replay_mode mode = REPLAY_MODE_PICK; int ret; advance = xstrdup_or_null(opts->advance); + revert = xstrdup_or_null(opts->revert); + if (revert) + mode = REPLAY_MODE_REVERT; set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, - &detached_head, &advance, &onto, &update_refs); + &detached_head, &advance, &revert, &onto, &update_refs); /* FIXME: Should allow replaying commits with the first as a root commit */ @@ -309,7 +380,8 @@ int replay_revisions(struct rev_info *revs, die(_("replaying merge commits is not supported yet!")); last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - onto, &merge_opt, &result); + mode == REPLAY_MODE_REVERT ? last_commit : onto, + &merge_opt, &result, mode); if (!last_commit) break; @@ -321,7 +393,7 @@ int replay_revisions(struct rev_info *revs, kh_value(replayed_commits, pos) = last_commit; /* Update any necessary branches */ - if (advance) + if (advance || revert) continue; for (decoration = get_name_decoration(&commit->object); @@ -355,11 +427,13 @@ int replay_revisions(struct rev_info *revs, goto out; } - /* In --advance mode, advance the target ref */ - if (advance) - replay_result_queue_update(out, advance, + /* In --advance or --revert mode, update the target ref */ + if (advance || revert) { + const char *ref = advance ? advance : revert; + replay_result_queue_update(out, ref, &onto->object.oid, &last_commit->object.oid); + } ret = 0; @@ -371,5 +445,6 @@ out: kh_destroy_oid_map(replayed_commits); merge_finalize(&merge_opt, &result); free(advance); + free(revert); return ret; } diff --git a/replay.h b/replay.h index d8407dc7f7..e916a5f975 100644 --- a/replay.h +++ b/replay.h @@ -13,7 +13,7 @@ struct replay_revisions_options { /* * Starting point at which to create the new commits; must be a branch * name. The branch will be updated to point to the rewritten commits. - * This option is mutually exclusive with `onto`. + * This option is mutually exclusive with `onto` and `revert`. */ const char *advance; @@ -22,7 +22,14 @@ struct replay_revisions_options { * committish. References pointing at decendants of `onto` will be * updated to point to the new commits. */ - const char *onto; + const char *onto; + + /* + * Starting point at which to create revert commits; must be a branch + * name. The branch will be updated to point to the revert commits. + * This option is mutually exclusive with `onto` and `advance`. + */ + const char *revert; /* * Update branches that point at commits in the given revision range. diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh index a03f8f9293..217f6fb292 100755 --- a/t/t3650-replay-basics.sh +++ b/t/t3650-replay-basics.sh @@ -74,8 +74,8 @@ test_expect_success '--onto with invalid commit-ish' ' test_cmp expect actual ' -test_expect_success 'option --onto or --advance is mandatory' ' - echo "error: option --onto or --advance is mandatory" >expect && +test_expect_success 'exactly one of --onto, --advance, or --revert is required' ' + echo "error: exactly one of --onto, --advance, or --revert is required" >expect && test_might_fail git replay -h >>expect && test_must_fail git replay topic1..topic2 2>actual && test_cmp expect actual @@ -87,16 +87,14 @@ test_expect_success 'no base or negative ref gives no-replaying down to root err test_cmp expect actual ' -test_expect_success 'options --advance and --contained cannot be used together' ' - printf "fatal: options ${SQ}--advance${SQ} " >expect && - printf "and ${SQ}--contained${SQ} cannot be used together\n" >>expect && +test_expect_success '--advance and --contained cannot be used together' ' test_must_fail git replay --advance=main --contained \ topic1..topic2 2>actual && - test_cmp expect actual + test_grep "cannot be used together" actual ' test_expect_success 'cannot advance target ... ordering would be ill-defined' ' - echo "fatal: cannot advance target with multiple sources because ordering would be ill-defined" >expect && + echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect && test_must_fail git replay --advance=main main topic1 topic2 2>actual && test_cmp expect actual ' @@ -398,4 +396,103 @@ test_expect_success 'invalid replay.refAction value' ' test_grep "invalid.*replay.refAction.*value" error ' +test_expect_success 'argument to --revert must be a reference' ' + echo "fatal: argument to --revert must be a reference" >expect && + oid=$(git rev-parse main) && + test_must_fail git replay --revert=$oid topic1..topic2 2>actual && + test_cmp expect actual +' + +test_expect_success 'cannot revert with multiple sources' ' + echo "fatal: ${SQ}--revert${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect && + test_must_fail git replay --revert main main topic1 topic2 2>actual && + test_cmp expect actual +' + +test_expect_success 'using replay --revert to revert commits' ' + # Reuse existing topic4 branch (has commits I and J on top of main) + START=$(git rev-parse topic4) && + test_when_finished "git branch -f topic4 $START" && + + # Revert commits I and J + git replay --revert topic4 topic4~2..topic4 && + + # Verify the revert commits were created (newest-first ordering + # means J is reverted first, then I on top) + git log --format=%s -4 topic4 >actual && + cat >expect <<-\EOF && + Revert "I" + Revert "J" + J + I + EOF + test_cmp expect actual && + + # Verify commit message format includes hash (tip is Revert "I") + test_commit_message topic4 <<-EOF && + Revert "I" + + This reverts commit $(git rev-parse I). + EOF + + # Verify reflog message + git reflog topic4 -1 --format=%gs >reflog-msg && + echo "replay --revert topic4" >expect-reflog && + test_cmp expect-reflog reflog-msg +' + +test_expect_success 'using replay --revert in bare repo' ' + # Reuse existing topic4 in bare repo + START=$(git -C bare rev-parse topic4) && + test_when_finished "git -C bare update-ref refs/heads/topic4 $START" && + + # Revert commit J in bare repo + git -C bare replay --revert topic4 topic4~1..topic4 && + + # Verify revert was created + git -C bare log -1 --format=%s topic4 >actual && + echo "Revert \"J\"" >expect && + test_cmp expect actual +' + +test_expect_success 'revert of revert uses Reapply' ' + # Use topic4 and first revert J, then revert the revert + START=$(git rev-parse topic4) && + test_when_finished "git branch -f topic4 $START" && + + # First revert J + git replay --revert topic4 topic4~1..topic4 && + REVERT_J=$(git rev-parse topic4) && + + # Now revert the revert - should become Reapply + git replay --revert topic4 topic4~1..topic4 && + + # Verify Reapply prefix and message format + test_commit_message topic4 <<-EOF + Reapply "J" + + This reverts commit $REVERT_J. + EOF +' + +test_expect_success 'git replay --revert with conflict' ' + # conflict branch has C.conflict which conflicts with topic1s C + test_expect_code 1 git replay --revert conflict B..topic1 +' + +test_expect_success 'git replay --revert incompatible with --contained' ' + test_must_fail git replay --revert topic4 --contained topic4~1..topic4 2>error && + test_grep "cannot be used together" error +' + +test_expect_success 'git replay --revert incompatible with --onto' ' + test_must_fail git replay --revert topic4 --onto main topic4~1..topic4 2>error && + test_grep "cannot be used together" error +' + +test_expect_success 'git replay --revert incompatible with --advance' ' + test_must_fail git replay --revert topic4 --advance main topic4~1..topic4 2>error && + test_grep "cannot be used together" error +' + test_done -- cgit v1.2.3 From 8bad0e07e1eddff2268bc9be3368c9b5fee47915 Mon Sep 17 00:00:00 2001 From: Phillip Wood Date: Thu, 26 Mar 2026 14:16:58 +0000 Subject: worktree add: stop reading ".git/HEAD" The function can_use_local_refs() prints a warning if there are no local branches and HEAD is invalid or points to an unborn branch. As part of the warning it prints the contents of ".git/HEAD". In a repository using the reftable backend HEAD is not stored in the filesystem so reading that file is pointless. In a repository using the files backend it is unclear how useful printing it is - it would be better to diagnose the problem for the user. For now, simplify the warning by not printing the file contents and adjust the relevant test case accordingly. Also fixup the test case to use test_grep so that anyone trying to debug a test failure in the future is not met by a wall of silence. Signed-off-by: Phillip Wood Signed-off-by: Junio C Hamano --- builtin/worktree.c | 21 ++------------------- t/t2400-worktree-add.sh | 28 ++++++++++++---------------- 2 files changed, 14 insertions(+), 35 deletions(-) (limited to 'builtin') diff --git a/builtin/worktree.c b/builtin/worktree.c index bc2d0d645b..9170b2e898 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -692,25 +692,8 @@ static int can_use_local_refs(const struct add_opts *opts) if (refs_head_ref(get_main_ref_store(the_repository), first_valid_ref, NULL)) { return 1; } else if (refs_for_each_branch_ref(get_main_ref_store(the_repository), first_valid_ref, NULL)) { - if (!opts->quiet) { - struct strbuf path = STRBUF_INIT; - struct strbuf contents = STRBUF_INIT; - char *wt_gitdir = get_worktree_git_dir(NULL); - - strbuf_add_real_path(&path, wt_gitdir); - strbuf_addstr(&path, "/HEAD"); - strbuf_read_file(&contents, path.buf, 64); - strbuf_stripspace(&contents, NULL); - strbuf_strip_suffix(&contents, "\n"); - - warning(_("HEAD points to an invalid (or orphaned) reference.\n" - "HEAD path: '%s'\n" - "HEAD contents: '%s'"), - path.buf, contents.buf); - strbuf_release(&path); - strbuf_release(&contents); - free(wt_gitdir); - } + if (!opts->quiet) + warning(_("HEAD points to an invalid (or orphaned) reference.\n")); return 1; } return 0; diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh index 023e1301c8..58b4445cc4 100755 --- a/t/t2400-worktree-add.sh +++ b/t/t2400-worktree-add.sh @@ -987,7 +987,7 @@ test_dwim_orphan () { then test_must_be_empty actual else - grep "$info_text" actual + test_grep "$info_text" actual fi elif [ "$outcome" = "no_infer" ] then @@ -996,39 +996,35 @@ test_dwim_orphan () { then test_must_be_empty actual else - ! grep "$info_text" actual + test_grep ! "$info_text" actual fi elif [ "$outcome" = "fetch_error" ] then test_must_fail git $dashc_args worktree add $args 2>actual && - grep "$fetch_error_text" actual + test_grep "$fetch_error_text" actual elif [ "$outcome" = "fatal_orphan_bad_combo" ] then test_must_fail git $dashc_args worktree add $args 2>actual && if [ $use_quiet -eq 1 ] then - ! grep "$info_text" actual + test_grep ! "$info_text" actual else - grep "$info_text" actual + test_grep "$info_text" actual fi && - grep "$bad_combo_regex" actual + test_grep "$bad_combo_regex" actual elif [ "$outcome" = "warn_bad_head" ] then test_must_fail git $dashc_args worktree add $args 2>actual && if [ $use_quiet -eq 1 ] then - grep "$invalid_ref_regex" actual && - ! grep "$orphan_hint" actual + test_grep "$invalid_ref_regex" actual && + test_grep ! "$orphan_hint" actual else - headpath=$(git $dashc_args rev-parse --path-format=absolute --git-path HEAD) && - headcontents=$(cat "$headpath") && - grep "HEAD points to an invalid (or orphaned) reference" actual && - grep "HEAD path: .$headpath." actual && - grep "HEAD contents: .$headcontents." actual && - grep "$orphan_hint" actual && - ! grep "$info_text" actual + test_grep "HEAD points to an invalid (or orphaned) reference" actual && + test_grep "$orphan_hint" actual && + test_grep ! "$info_text" actual fi && - grep "$invalid_ref_regex" actual + test_grep "$invalid_ref_regex" actual else # Unreachable false -- cgit v1.2.3 From 302aff09223f81d9bd0bb496a3aea9f279d1991f Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Thu, 26 Mar 2026 15:14:51 +0000 Subject: backfill: accept revision arguments The existing implementation of 'git backfill' only includes downloading missing blobs reachable from HEAD. Advanced uses may desire more general commit limiting options, such as '--all' for all references, specifying a commit range via negative references, or specifying a recency of use such as with '--since='. All of these options are available if we use setup_revisions() to parse the unknown arguments with the revision machinery. This opens up a large number of possibilities, only a small set of which are tested here. For documentation, we avoid duplicating the option documentation and instead link to the documentation of 'git rev-list'. Note that these arguments currently allow specifying a pathspec, which modifies the commit history checks but does not limit the paths used in the backfill logic. This will be updated in a future change. Signed-off-by: Derrick Stolee Signed-off-by: Junio C Hamano --- Documentation/git-backfill.adoc | 5 +- builtin/backfill.c | 19 +++-- t/t5620-backfill.sh | 156 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 7 deletions(-) (limited to 'builtin') diff --git a/Documentation/git-backfill.adoc b/Documentation/git-backfill.adoc index b8394dcf22..246ab417c2 100644 --- a/Documentation/git-backfill.adoc +++ b/Documentation/git-backfill.adoc @@ -63,9 +63,12 @@ OPTIONS current sparse-checkout. If the sparse-checkout feature is enabled, then `--sparse` is assumed and can be disabled with `--no-sparse`. +You may also specify the commit limiting options from linkgit:git-rev-list[1]. + SEE ALSO -------- -linkgit:git-clone[1]. +linkgit:git-clone[1], +linkgit:git-rev-list[1] GIT --- diff --git a/builtin/backfill.c b/builtin/backfill.c index e9a33e81be..b98b0b591f 100644 --- a/builtin/backfill.c +++ b/builtin/backfill.c @@ -35,6 +35,7 @@ struct backfill_context { struct oid_array current_batch; size_t min_batch_size; int sparse; + struct rev_info revs; }; static void backfill_context_clear(struct backfill_context *ctx) @@ -79,7 +80,6 @@ static int fill_missing_blobs(const char *path UNUSED, static int do_backfill(struct backfill_context *ctx) { - struct rev_info revs; struct path_walk_info info = PATH_WALK_INFO_INIT; int ret; @@ -91,13 +91,14 @@ static int do_backfill(struct backfill_context *ctx) } } - repo_init_revisions(ctx->repo, &revs, ""); - handle_revision_arg("HEAD", &revs, 0, 0); + /* Walk from HEAD if otherwise unspecified. */ + if (!ctx->revs.pending.nr) + add_head_to_pending(&ctx->revs); info.blobs = 1; info.tags = info.commits = info.trees = 0; - info.revs = &revs; + info.revs = &ctx->revs; info.path_fn = fill_missing_blobs; info.path_fn_data = ctx; @@ -108,7 +109,6 @@ static int do_backfill(struct backfill_context *ctx) download_batch(ctx); path_walk_info_clear(&info); - release_revisions(&revs); return ret; } @@ -120,6 +120,7 @@ int cmd_backfill(int argc, const char **argv, const char *prefix, struct reposit .current_batch = OID_ARRAY_INIT, .min_batch_size = 50000, .sparse = 0, + .revs = REV_INFO_INIT, }; struct option options[] = { OPT_UNSIGNED(0, "min-batch-size", &ctx.min_batch_size, @@ -134,7 +135,12 @@ int cmd_backfill(int argc, const char **argv, const char *prefix, struct reposit builtin_backfill_usage, options); argc = parse_options(argc, argv, prefix, options, builtin_backfill_usage, - 0); + PARSE_OPT_KEEP_UNKNOWN_OPT | + PARSE_OPT_KEEP_ARGV0 | + PARSE_OPT_KEEP_DASHDASH); + + repo_init_revisions(repo, &ctx.revs, prefix); + argc = setup_revisions(argc, argv, &ctx.revs, NULL); repo_config(repo, git_default_config, NULL); @@ -143,5 +149,6 @@ int cmd_backfill(int argc, const char **argv, const char *prefix, struct reposit result = do_backfill(&ctx); backfill_context_clear(&ctx); + release_revisions(&ctx.revs); return result; } diff --git a/t/t5620-backfill.sh b/t/t5620-backfill.sh index 1331949be4..db66d8b614 100755 --- a/t/t5620-backfill.sh +++ b/t/t5620-backfill.sh @@ -224,6 +224,162 @@ test_expect_success 'backfill --sparse without cone mode (negative)' ' test_line_count = 12 missing ' +test_expect_success 'backfill with revision range' ' + test_when_finished rm -rf backfill-revs && + git clone --no-checkout --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv.bare" backfill-revs && + + # No blobs yet + git -C backfill-revs rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 48 missing && + + git -C backfill-revs backfill HEAD~2..HEAD && + + # 30 objects downloaded. + git -C backfill-revs rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 18 missing +' + +test_expect_success 'backfill with revisions over stdin' ' + test_when_finished rm -rf backfill-revs && + git clone --no-checkout --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv.bare" backfill-revs && + + # No blobs yet + git -C backfill-revs rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 48 missing && + + cat >in <<-EOF && + HEAD + ^HEAD~2 + EOF + + git -C backfill-revs backfill --stdin missing && + test_line_count = 18 missing +' + +test_expect_success 'backfill with prefix pathspec' ' + test_when_finished rm -rf backfill-path && + git clone --bare --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv.bare" backfill-path && + + # No blobs yet + git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 48 missing && + + # TODO: The pathspec should limit the downloaded blobs to + # only those matching the prefix "d/f", but currently all + # blobs are downloaded. + git -C backfill-path backfill HEAD -- d/f && + + git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 0 missing +' + +test_expect_success 'backfill with multiple pathspecs' ' + test_when_finished rm -rf backfill-path && + git clone --bare --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv.bare" backfill-path && + + # No blobs yet + git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 48 missing && + + # TODO: The pathspecs should limit the downloaded blobs to + # only those matching "d/f" or "a", but currently all blobs + # are downloaded. + git -C backfill-path backfill HEAD -- d/f a && + + git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 0 missing +' + +test_expect_success 'backfill with wildcard pathspec' ' + test_when_finished rm -rf backfill-path && + git clone --bare --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv.bare" backfill-path && + + # No blobs yet + git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 48 missing && + + # TODO: The wildcard pathspec should limit downloaded blobs, + # but currently all blobs are downloaded. + git -C backfill-path backfill HEAD -- "d/file.*.txt" && + + git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 0 missing +' + +test_expect_success 'backfill with --all' ' + test_when_finished rm -rf backfill-all && + git clone --no-checkout --filter=blob:none \ + "file://$(pwd)/srv-revs.bare" backfill-all && + + # All blobs from all refs are missing + git -C backfill-all rev-list --quiet --objects --all --missing=print >missing && + test_line_count = 54 missing && + + # Backfill from HEAD gets main blobs only + git -C backfill-all backfill HEAD && + + # Other branch blobs still missing + git -C backfill-all rev-list --quiet --objects --all --missing=print >missing && + test_line_count = 2 missing && + + # Backfill with --all gets everything + git -C backfill-all backfill --all && + + git -C backfill-all rev-list --quiet --objects --all --missing=print >missing && + test_line_count = 0 missing +' + +test_expect_success 'backfill with --first-parent' ' + test_when_finished rm -rf backfill-fp && + git clone --no-checkout --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv-revs.bare" backfill-fp && + + git -C backfill-fp rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 52 missing && + + # --first-parent skips the side branch commits, so + # s/file.{1,2}.txt v1 blobs (only in side commit 1) are missed. + git -C backfill-fp backfill --first-parent HEAD && + + git -C backfill-fp rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 2 missing +' + +test_expect_success 'backfill with --since' ' + test_when_finished rm -rf backfill-since && + git clone --no-checkout --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv-revs.bare" backfill-since && + + git -C backfill-since rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 52 missing && + + # Use a cutoff between commits 4 and 5 (between v1 and v2 + # iterations). Commits 5-8 still carry v1 of files 2-4 in + # their trees, but v1 of file.1.txt is only in commits 1-4. + SINCE=$(git -C backfill-since log --first-parent --reverse \ + --format=%ct HEAD~1 | sed -n 5p) && + git -C backfill-since backfill --since="@$((SINCE - 1))" HEAD && + + # 6 missing: v1 of file.1.txt in all 6 directories + git -C backfill-since rev-list --quiet --objects --missing=print HEAD >missing && + test_line_count = 6 missing +' + . "$TEST_DIRECTORY"/lib-httpd.sh start_httpd -- cgit v1.2.3 From 46d1f4cf4dcb8aaf799f78410af829e149086f36 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Thu, 26 Mar 2026 15:14:54 +0000 Subject: t5620: test backfill's unknown argument handling Before the recent changes to parse rev-list arguments inside of 'git backfill', the builtin would take arbitrary arguments without complaint (and ignore them). This was noticed and a patch was sent [1] which motivates this change. [1] https://lore.kernel.org/git/20260321031643.5185-1-r.siddharth.shrimali@gmail.com/ Note that the revision machinery can output an "ambiguous argument" warning if a value not starting with '--' is found and doesn't make sense as a reference or a pathspec. For unrecognized arguments starting with '--' we need to add logic into builtin/backfill.c to catch leftover arguments. Reported-by: Siddharth Shrimali Signed-off-by: Derrick Stolee Signed-off-by: Junio C Hamano --- builtin/backfill.c | 3 +++ t/t5620-backfill.sh | 8 ++++++++ 2 files changed, 11 insertions(+) (limited to 'builtin') diff --git a/builtin/backfill.c b/builtin/backfill.c index b98b0b591f..2c5ce56fb7 100644 --- a/builtin/backfill.c +++ b/builtin/backfill.c @@ -142,6 +142,9 @@ int cmd_backfill(int argc, const char **argv, const char *prefix, struct reposit repo_init_revisions(repo, &ctx.revs, prefix); argc = setup_revisions(argc, argv, &ctx.revs, NULL); + if (argc > 1) + die(_("unrecognized argument: %s"), argv[1]); + repo_config(repo, git_default_config, NULL); if (ctx.sparse < 0) diff --git a/t/t5620-backfill.sh b/t/t5620-backfill.sh index c6f54ee91c..2c347a91fe 100755 --- a/t/t5620-backfill.sh +++ b/t/t5620-backfill.sh @@ -7,6 +7,14 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME . ./test-lib.sh +test_expect_success 'backfill rejects unexpected arguments' ' + test_must_fail git backfill unexpected-arg 2>err && + test_grep "ambiguous argument .*unexpected-arg" err && + + test_must_fail git backfill --all --unexpected-arg --first-parent 2>err && + test_grep "unrecognized argument: --unexpected-arg" err +' + # We create objects in the 'src' repo. test_expect_success 'setup repo for object creation' ' echo "{print \$1}" >print_1.awk && -- cgit v1.2.3 From 0284046ad01e22b5e794a864a96925791f75353c Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Fri, 27 Mar 2026 20:48:10 +0100 Subject: format-patch: removing unconditional wrapping Using format-patch with --commit-list-format different than shortlog, causes the commit entry lines to wrap if they get longer than MAIL_DEFAULT_WRAP (72 characters). While this might be sensible for many when sending changes through email, it forces this decision of wrapping on the user, reducing the control granularity of --commit-list-format. Teach generate_commit_list_cover() to respect commit entry line lengths and place this wrapping rule on the "modern" preset format instead. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- Documentation/git-format-patch.adoc | 2 +- builtin/log.c | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) (limited to 'builtin') diff --git a/Documentation/git-format-patch.adoc b/Documentation/git-format-patch.adoc index cbbb292cb5..5662382450 100644 --- a/Documentation/git-format-patch.adoc +++ b/Documentation/git-format-patch.adoc @@ -327,7 +327,7 @@ feeding the result to `git send-email`. Specify the format in which to generate the commit list of the patch series. The accepted values for format-spec are `shortlog`, `modern` or a format-string prefixed with `log:`. E.g. `log: %s (%an)`. - `modern` is the same as `log:[%(count)/%(total)] %s`. + `modern` is the same as `log:%w(72)[%(count)/%(total)] %s`. The `log:` prefix can be omitted if the format-string has a `%` in it (expecting that it is part of `%`). Defaults to the `format.commitListFormat` configuration variable, if diff --git a/builtin/log.c b/builtin/log.c index ad7b7215fe..8c0939dd42 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -1365,7 +1365,6 @@ static void generate_commit_list_cover(FILE *cover_file, const char *format, struct commit **list, int n) { struct strbuf commit_line = STRBUF_INIT; - struct strbuf wrapped_line = STRBUF_INIT; struct pretty_print_context ctx = {0}; struct rev_info rev = REV_INFO_INIT; @@ -1375,16 +1374,12 @@ static void generate_commit_list_cover(FILE *cover_file, const char *format, rev.nr = i; repo_format_commit_message(the_repository, list[n - i], format, &commit_line, &ctx); - strbuf_add_wrapped_text(&wrapped_line, commit_line.buf, 0, 0, - MAIL_DEFAULT_WRAP); - fprintf(cover_file, "%s\n", wrapped_line.buf); + fprintf(cover_file, "%s\n", commit_line.buf); strbuf_reset(&commit_line); - strbuf_reset(&wrapped_line); } fprintf(cover_file, "\n"); strbuf_release(&commit_line); - strbuf_release(&wrapped_line); } static void make_cover_letter(struct rev_info *rev, int use_separate_file, @@ -1446,7 +1441,7 @@ static void make_cover_letter(struct rev_info *rev, int use_separate_file, else if (!strcmp(format, "shortlog")) generate_shortlog_cover_letter(&log, rev, list, nr); else if (!strcmp(format, "modern")) - generate_commit_list_cover(rev->diffopt.file, "[%(count)/%(total)] %s", + generate_commit_list_cover(rev->diffopt.file, "%w(72)[%(count)/%(total)] %s", list, nr); else if (strchr(format, '%')) generate_commit_list_cover(rev->diffopt.file, format, list, nr); -- cgit v1.2.3