From 04949538203ba7aeb298a607280715d4f26dbe0a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 5 Mar 2026 15:34:46 -0800 Subject: [PATCH 001/608] sideband: mask control characters The output of `git clone` is a vital component for understanding what has happened when things go wrong. However, these logs are partially under the control of the remote server (via the "sideband", which typically contains what the remote `git pack-objects` process sends to `stderr`), and is currently not sanitized by Git. This makes Git susceptible to ANSI escape sequence injection (see CWE-150, https://cwe.mitre.org/data/definitions/150.html), which allows attackers to corrupt terminal state, to hide information, and even to insert characters into the input buffer (i.e. as if the user had typed those characters). To plug this vulnerability, disallow any control character in the sideband, replacing them instead with the common `^` (e.g. `^[` for `\x1b`, `^A` for `\x01`). There is likely a need for more fine-grained controls instead of using a "heavy hammer" like this, which will be introduced subsequently. Helped-by: Phillip Wood Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- sideband.c | 17 +++++++++++++++-- t/t5409-colorize-remote-messages.sh | 12 ++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/sideband.c b/sideband.c index ea7c25211ef7e1..c1bbadccac682b 100644 --- a/sideband.c +++ b/sideband.c @@ -66,6 +66,19 @@ void list_config_color_sideband_slots(struct string_list *list, const char *pref list_config_item(list, prefix, keywords[i].keyword); } +static void strbuf_add_sanitized(struct strbuf *dest, const char *src, int n) +{ + strbuf_grow(dest, n); + for (; n && *src; src++, n--) { + if (!iscntrl(*src) || *src == '\t' || *src == '\n') { + strbuf_addch(dest, *src); + } else { + strbuf_addch(dest, '^'); + strbuf_addch(dest, *src == 0x7f ? '?' : 0x40 + *src); + } + } +} + /* * Optionally highlight one keyword in remote output if it appears at the start * of the line. This should be called for a single line only, which is @@ -81,7 +94,7 @@ static void maybe_colorize_sideband(struct strbuf *dest, const char *src, int n) int i; if (!want_color_stderr(use_sideband_colors())) { - strbuf_add(dest, src, n); + strbuf_add_sanitized(dest, src, n); return; } @@ -114,7 +127,7 @@ static void maybe_colorize_sideband(struct strbuf *dest, const char *src, int n) } } - strbuf_add(dest, src, n); + strbuf_add_sanitized(dest, src, n); } diff --git a/t/t5409-colorize-remote-messages.sh b/t/t5409-colorize-remote-messages.sh index fa5de4500a4f50..aa5b57057148e0 100755 --- a/t/t5409-colorize-remote-messages.sh +++ b/t/t5409-colorize-remote-messages.sh @@ -98,4 +98,16 @@ test_expect_success 'fallback to color.ui' ' grep "error: error" decoded ' +test_expect_success 'disallow (color) control sequences in sideband' ' + write_script .git/color-me-surprised <<-\EOF && + printf "error: Have you \\033[31mread\\033[m this?\\n" >&2 + exec "$@" + EOF + test_config_global uploadPack.packObjectsHook ./color-me-surprised && + test_commit need-at-least-one-commit && + git clone --no-local . throw-away 2>stderr && + test_decode_color decoded && + test_grep ! RED decoded +' + test_done From 9ed1625a581a35d7ec2d851258cf4c7fc08c1ed7 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 5 Mar 2026 15:34:47 -0800 Subject: [PATCH 002/608] sideband: introduce an "escape hatch" to allow control characters The preceding commit fixed the vulnerability whereas sideband messages (that are under the control of the remote server) could contain ANSI escape sequences that would be sent to the terminal verbatim. However, this fix may not be desirable under all circumstances, e.g. when remote servers deliberately add coloring to their messages to increase their urgency. To help with those use cases, give users a way to opt-out of the protections: `sideband.allowControlCharacters`. Suggested-by: brian m. carlson Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- Documentation/config.adoc | 2 ++ Documentation/config/sideband.adoc | 5 +++++ sideband.c | 10 ++++++++++ t/t5409-colorize-remote-messages.sh | 8 +++++++- 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 Documentation/config/sideband.adoc diff --git a/Documentation/config.adoc b/Documentation/config.adoc index 62eebe7c54501c..dcea3c0c15e2a9 100644 --- a/Documentation/config.adoc +++ b/Documentation/config.adoc @@ -523,6 +523,8 @@ include::config/sequencer.adoc[] include::config/showbranch.adoc[] +include::config/sideband.adoc[] + include::config/sparse.adoc[] include::config/splitindex.adoc[] diff --git a/Documentation/config/sideband.adoc b/Documentation/config/sideband.adoc new file mode 100644 index 00000000000000..3fb5045cd79581 --- /dev/null +++ b/Documentation/config/sideband.adoc @@ -0,0 +1,5 @@ +sideband.allowControlCharacters:: + By default, control characters that are delivered via the sideband + are masked, to prevent potentially unwanted ANSI escape sequences + from being sent to the terminal. Use this config setting to override + this behavior. diff --git a/sideband.c b/sideband.c index c1bbadccac682b..682f1cbbedb9b8 100644 --- a/sideband.c +++ b/sideband.c @@ -26,6 +26,8 @@ static struct keyword_entry keywords[] = { { "error", GIT_COLOR_BOLD_RED }, }; +static int allow_control_characters; + /* Returns a color setting (GIT_COLOR_NEVER, etc). */ static enum git_colorbool use_sideband_colors(void) { @@ -39,6 +41,9 @@ static enum git_colorbool use_sideband_colors(void) if (use_sideband_colors_cached != GIT_COLOR_UNKNOWN) return use_sideband_colors_cached; + repo_config_get_bool(the_repository, "sideband.allowcontrolcharacters", + &allow_control_characters); + if (!repo_config_get_string_tmp(the_repository, key, &value)) use_sideband_colors_cached = git_config_colorbool(key, value); else if (!repo_config_get_string_tmp(the_repository, "color.ui", &value)) @@ -68,6 +73,11 @@ void list_config_color_sideband_slots(struct string_list *list, const char *pref static void strbuf_add_sanitized(struct strbuf *dest, const char *src, int n) { + if (allow_control_characters) { + strbuf_add(dest, src, n); + return; + } + strbuf_grow(dest, n); for (; n && *src; src++, n--) { if (!iscntrl(*src) || *src == '\t' || *src == '\n') { diff --git a/t/t5409-colorize-remote-messages.sh b/t/t5409-colorize-remote-messages.sh index aa5b57057148e0..9caee9a07f1556 100755 --- a/t/t5409-colorize-remote-messages.sh +++ b/t/t5409-colorize-remote-messages.sh @@ -105,9 +105,15 @@ test_expect_success 'disallow (color) control sequences in sideband' ' EOF test_config_global uploadPack.packObjectsHook ./color-me-surprised && test_commit need-at-least-one-commit && + git clone --no-local . throw-away 2>stderr && test_decode_color decoded && - test_grep ! RED decoded + test_grep ! RED decoded && + + rm -rf throw-away && + git -c sideband.allowControlCharacters clone --no-local . throw-away 2>stderr && + test_decode_color decoded && + test_grep RED decoded ' test_done From 12f0fda905b4af3a15c125f96808e49ddbe39742 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 5 Mar 2026 15:34:48 -0800 Subject: [PATCH 003/608] sideband: do allow ANSI color sequences by default The preceding two commits introduced special handling of the sideband channel to neutralize ANSI escape sequences before sending the payload to the terminal, and `sideband.allowControlCharacters` to override that behavior. However, as reported by brian m. carlson, some `pre-receive` hooks that are actively used in practice want to color their messages and therefore rely on the fact that Git passes them through to the terminal, even though they have no way to determine whether the receiving side can actually handle Escape sequences (think e.g. about the practice recommended by Git that third-party applications wishing to use Git functionality parse the output of Git commands). In contrast to other ANSI escape sequences, it is highly unlikely that coloring sequences can be essential tools in attack vectors that mislead Git users e.g. by hiding crucial information. Therefore we can have both: Continue to allow ANSI coloring sequences to be passed to the terminal by default, and neutralize all other ANSI Escape sequences. Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- Documentation/config/sideband.adoc | 18 ++++++-- sideband.c | 66 +++++++++++++++++++++++++++-- t/t5409-colorize-remote-messages.sh | 16 ++++++- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/Documentation/config/sideband.adoc b/Documentation/config/sideband.adoc index 3fb5045cd79581..b55c73726fe2c7 100644 --- a/Documentation/config/sideband.adoc +++ b/Documentation/config/sideband.adoc @@ -1,5 +1,17 @@ sideband.allowControlCharacters:: By default, control characters that are delivered via the sideband - are masked, to prevent potentially unwanted ANSI escape sequences - from being sent to the terminal. Use this config setting to override - this behavior. + are masked, except ANSI color sequences. This prevents potentially + unwanted ANSI escape sequences from being sent to the terminal. Use + this config setting to override this behavior: ++ +-- + `default`:: + `color`:: + Allow ANSI color sequences, line feeds and horizontal tabs, + but mask all other control characters. This is the default. + `false`:: + Mask all control characters other than line feeds and + horizontal tabs. + `true`:: + Allow all control characters to be sent to the terminal. +-- diff --git a/sideband.c b/sideband.c index 682f1cbbedb9b8..eeba6fa2ca8dd6 100644 --- a/sideband.c +++ b/sideband.c @@ -26,7 +26,12 @@ static struct keyword_entry keywords[] = { { "error", GIT_COLOR_BOLD_RED }, }; -static int allow_control_characters; +static enum { + ALLOW_NO_CONTROL_CHARACTERS = 0, + ALLOW_ANSI_COLOR_SEQUENCES = 1<<0, + ALLOW_DEFAULT_ANSI_SEQUENCES = ALLOW_ANSI_COLOR_SEQUENCES, + ALLOW_ALL_CONTROL_CHARACTERS = 1<<1, +} allow_control_characters = ALLOW_ANSI_COLOR_SEQUENCES; /* Returns a color setting (GIT_COLOR_NEVER, etc). */ static enum git_colorbool use_sideband_colors(void) @@ -41,8 +46,26 @@ static enum git_colorbool use_sideband_colors(void) if (use_sideband_colors_cached != GIT_COLOR_UNKNOWN) return use_sideband_colors_cached; - repo_config_get_bool(the_repository, "sideband.allowcontrolcharacters", - &allow_control_characters); + switch (repo_config_get_maybe_bool(the_repository, "sideband.allowcontrolcharacters", &i)) { + case 0: /* Boolean value */ + allow_control_characters = i ? ALLOW_ALL_CONTROL_CHARACTERS : + ALLOW_NO_CONTROL_CHARACTERS; + break; + case -1: /* non-Boolean value */ + if (repo_config_get_string_tmp(the_repository, "sideband.allowcontrolcharacters", + &value)) + ; /* huh? `get_maybe_bool()` returned -1 */ + else if (!strcmp(value, "default")) + allow_control_characters = ALLOW_DEFAULT_ANSI_SEQUENCES; + else if (!strcmp(value, "color")) + allow_control_characters = ALLOW_ANSI_COLOR_SEQUENCES; + else + warning(_("unrecognized value for `sideband." + "allowControlCharacters`: '%s'"), value); + break; + default: + break; /* not configured */ + } if (!repo_config_get_string_tmp(the_repository, key, &value)) use_sideband_colors_cached = git_config_colorbool(key, value); @@ -71,9 +94,41 @@ void list_config_color_sideband_slots(struct string_list *list, const char *pref list_config_item(list, prefix, keywords[i].keyword); } +static int handle_ansi_color_sequence(struct strbuf *dest, const char *src, int n) +{ + int i; + + /* + * Valid ANSI color sequences are of the form + * + * ESC [ [ [; ]*] m + * + * These are part of the Select Graphic Rendition sequences which + * contain more than just color sequences, for more details see + * https://en.wikipedia.org/wiki/ANSI_escape_code#SGR. + */ + + if (allow_control_characters != ALLOW_ANSI_COLOR_SEQUENCES || + n < 3 || src[0] != '\x1b' || src[1] != '[') + return 0; + + for (i = 2; i < n; i++) { + if (src[i] == 'm') { + strbuf_add(dest, src, i + 1); + return i; + } + if (!isdigit(src[i]) && src[i] != ';') + break; + } + + return 0; +} + static void strbuf_add_sanitized(struct strbuf *dest, const char *src, int n) { - if (allow_control_characters) { + int i; + + if (allow_control_characters == ALLOW_ALL_CONTROL_CHARACTERS) { strbuf_add(dest, src, n); return; } @@ -82,6 +137,9 @@ static void strbuf_add_sanitized(struct strbuf *dest, const char *src, int n) for (; n && *src; src++, n--) { if (!iscntrl(*src) || *src == '\t' || *src == '\n') { strbuf_addch(dest, *src); + } else if ((i = handle_ansi_color_sequence(dest, src, n))) { + src += i; + n -= i; } else { strbuf_addch(dest, '^'); strbuf_addch(dest, *src == 0x7f ? '?' : 0x40 + *src); diff --git a/t/t5409-colorize-remote-messages.sh b/t/t5409-colorize-remote-messages.sh index 9caee9a07f1556..e5092d3b426cd3 100755 --- a/t/t5409-colorize-remote-messages.sh +++ b/t/t5409-colorize-remote-messages.sh @@ -100,7 +100,7 @@ test_expect_success 'fallback to color.ui' ' test_expect_success 'disallow (color) control sequences in sideband' ' write_script .git/color-me-surprised <<-\EOF && - printf "error: Have you \\033[31mread\\033[m this?\\n" >&2 + printf "error: Have you \\033[31mread\\033[m this?\\a\\n" >&2 exec "$@" EOF test_config_global uploadPack.packObjectsHook ./color-me-surprised && @@ -108,12 +108,24 @@ test_expect_success 'disallow (color) control sequences in sideband' ' git clone --no-local . throw-away 2>stderr && test_decode_color decoded && + test_grep RED decoded && + test_grep "\\^G" stderr && + tr -dc "\\007" actual && + test_must_be_empty actual && + + rm -rf throw-away && + git -c sideband.allowControlCharacters=false \ + clone --no-local . throw-away 2>stderr && + test_decode_color decoded && test_grep ! RED decoded && + test_grep "\\^G" stderr && rm -rf throw-away && git -c sideband.allowControlCharacters clone --no-local . throw-away 2>stderr && test_decode_color decoded && - test_grep RED decoded + test_grep RED decoded && + tr -dc "\\007" actual && + test_file_not_empty actual ' test_done From 128914438a0d2d55ae34314a0881f55a797024d5 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 5 Mar 2026 15:34:49 -0800 Subject: [PATCH 004/608] sideband: add options to allow more control sequences to be passed through Even though control sequences that erase characters are quite juicy for attack scenarios, where attackers are eager to hide traces of suspicious activities, during the review of the side band sanitizing patch series concerns were raised that there might be some legimitate scenarios where Git server's `pre-receive` hooks use those sequences in a benign way. Control sequences to move the cursor can likewise be used to hide tracks by overwriting characters, and have been equally pointed out as having legitimate users. Let's add options to let users opt into passing through those ANSI Escape sequences: `sideband.allowControlCharacters` now supports also `cursor` and `erase`, and it parses the value as a comma-separated list. Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- Documentation/config/sideband.adoc | 9 ++- sideband.c | 91 ++++++++++++++++++++++++----- t/t5409-colorize-remote-messages.sh | 38 ++++++++++++ 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/Documentation/config/sideband.adoc b/Documentation/config/sideband.adoc index b55c73726fe2c7..2bf04262840b02 100644 --- a/Documentation/config/sideband.adoc +++ b/Documentation/config/sideband.adoc @@ -2,13 +2,20 @@ sideband.allowControlCharacters:: By default, control characters that are delivered via the sideband are masked, except ANSI color sequences. This prevents potentially unwanted ANSI escape sequences from being sent to the terminal. Use - this config setting to override this behavior: + this config setting to override this behavior (the value can be + a comma-separated list of the following keywords): + -- `default`:: `color`:: Allow ANSI color sequences, line feeds and horizontal tabs, but mask all other control characters. This is the default. + `cursor:`: + Allow control sequences that move the cursor. This is + disabled by default. + `erase`:: + Allow control sequences that erase charactrs. This is + disabled by default. `false`:: Mask all control characters other than line feeds and horizontal tabs. diff --git a/sideband.c b/sideband.c index eeba6fa2ca8dd6..0b420ca3193888 100644 --- a/sideband.c +++ b/sideband.c @@ -29,9 +29,43 @@ static struct keyword_entry keywords[] = { static enum { ALLOW_NO_CONTROL_CHARACTERS = 0, ALLOW_ANSI_COLOR_SEQUENCES = 1<<0, + ALLOW_ANSI_CURSOR_MOVEMENTS = 1<<1, + ALLOW_ANSI_ERASE = 1<<2, ALLOW_DEFAULT_ANSI_SEQUENCES = ALLOW_ANSI_COLOR_SEQUENCES, - ALLOW_ALL_CONTROL_CHARACTERS = 1<<1, -} allow_control_characters = ALLOW_ANSI_COLOR_SEQUENCES; + ALLOW_ALL_CONTROL_CHARACTERS = 1<<3, +} allow_control_characters = ALLOW_DEFAULT_ANSI_SEQUENCES; + +static inline int skip_prefix_in_csv(const char *value, const char *prefix, + const char **out) +{ + if (!skip_prefix(value, prefix, &value) || + (*value && *value != ',')) + return 0; + *out = value + !!*value; + return 1; +} + +static void parse_allow_control_characters(const char *value) +{ + allow_control_characters = ALLOW_NO_CONTROL_CHARACTERS; + while (*value) { + if (skip_prefix_in_csv(value, "default", &value)) + allow_control_characters |= ALLOW_DEFAULT_ANSI_SEQUENCES; + else if (skip_prefix_in_csv(value, "color", &value)) + allow_control_characters |= ALLOW_ANSI_COLOR_SEQUENCES; + else if (skip_prefix_in_csv(value, "cursor", &value)) + allow_control_characters |= ALLOW_ANSI_CURSOR_MOVEMENTS; + else if (skip_prefix_in_csv(value, "erase", &value)) + allow_control_characters |= ALLOW_ANSI_ERASE; + else if (skip_prefix_in_csv(value, "true", &value)) + allow_control_characters = ALLOW_ALL_CONTROL_CHARACTERS; + else if (skip_prefix_in_csv(value, "false", &value)) + allow_control_characters = ALLOW_NO_CONTROL_CHARACTERS; + else + warning(_("unrecognized value for `sideband." + "allowControlCharacters`: '%s'"), value); + } +} /* Returns a color setting (GIT_COLOR_NEVER, etc). */ static enum git_colorbool use_sideband_colors(void) @@ -55,13 +89,8 @@ static enum git_colorbool use_sideband_colors(void) if (repo_config_get_string_tmp(the_repository, "sideband.allowcontrolcharacters", &value)) ; /* huh? `get_maybe_bool()` returned -1 */ - else if (!strcmp(value, "default")) - allow_control_characters = ALLOW_DEFAULT_ANSI_SEQUENCES; - else if (!strcmp(value, "color")) - allow_control_characters = ALLOW_ANSI_COLOR_SEQUENCES; else - warning(_("unrecognized value for `sideband." - "allowControlCharacters`: '%s'"), value); + parse_allow_control_characters(value); break; default: break; /* not configured */ @@ -94,7 +123,7 @@ void list_config_color_sideband_slots(struct string_list *list, const char *pref list_config_item(list, prefix, keywords[i].keyword); } -static int handle_ansi_color_sequence(struct strbuf *dest, const char *src, int n) +static int handle_ansi_sequence(struct strbuf *dest, const char *src, int n) { int i; @@ -106,14 +135,47 @@ static int handle_ansi_color_sequence(struct strbuf *dest, const char *src, int * These are part of the Select Graphic Rendition sequences which * contain more than just color sequences, for more details see * https://en.wikipedia.org/wiki/ANSI_escape_code#SGR. + * + * The cursor movement sequences are: + * + * ESC [ n A - Cursor up n lines (CUU) + * ESC [ n B - Cursor down n lines (CUD) + * ESC [ n C - Cursor forward n columns (CUF) + * ESC [ n D - Cursor back n columns (CUB) + * ESC [ n E - Cursor next line, beginning (CNL) + * ESC [ n F - Cursor previous line, beginning (CPL) + * ESC [ n G - Cursor to column n (CHA) + * ESC [ n ; m H - Cursor position (row n, col m) (CUP) + * ESC [ n ; m f - Same as H (HVP) + * + * The sequences to erase characters are: + * + * + * ESC [ 0 J - Clear from cursor to end of screen (ED) + * ESC [ 1 J - Clear from cursor to beginning of screen (ED) + * ESC [ 2 J - Clear entire screen (ED) + * ESC [ 3 J - Clear entire screen + scrollback (ED) - xterm extension + * ESC [ 0 K - Clear from cursor to end of line (EL) + * ESC [ 1 K - Clear from cursor to beginning of line (EL) + * ESC [ 2 K - Clear entire line (EL) + * ESC [ n M - Delete n lines (DL) + * ESC [ n P - Delete n characters (DCH) + * ESC [ n X - Erase n characters (ECH) + * + * For a comprehensive list of common ANSI Escape sequences, see + * https://www.xfree86.org/current/ctlseqs.html */ - if (allow_control_characters != ALLOW_ANSI_COLOR_SEQUENCES || - n < 3 || src[0] != '\x1b' || src[1] != '[') + if (n < 3 || src[0] != '\x1b' || src[1] != '[') return 0; for (i = 2; i < n; i++) { - if (src[i] == 'm') { + if (((allow_control_characters & ALLOW_ANSI_COLOR_SEQUENCES) && + src[i] == 'm') || + ((allow_control_characters & ALLOW_ANSI_CURSOR_MOVEMENTS) && + strchr("ABCDEFGHf", src[i])) || + ((allow_control_characters & ALLOW_ANSI_ERASE) && + strchr("JKMPX", src[i]))) { strbuf_add(dest, src, i + 1); return i; } @@ -128,7 +190,7 @@ static void strbuf_add_sanitized(struct strbuf *dest, const char *src, int n) { int i; - if (allow_control_characters == ALLOW_ALL_CONTROL_CHARACTERS) { + if ((allow_control_characters & ALLOW_ALL_CONTROL_CHARACTERS)) { strbuf_add(dest, src, n); return; } @@ -137,7 +199,8 @@ static void strbuf_add_sanitized(struct strbuf *dest, const char *src, int n) for (; n && *src; src++, n--) { if (!iscntrl(*src) || *src == '\t' || *src == '\n') { strbuf_addch(dest, *src); - } else if ((i = handle_ansi_color_sequence(dest, src, n))) { + } else if (allow_control_characters != ALLOW_NO_CONTROL_CHARACTERS && + (i = handle_ansi_sequence(dest, src, n))) { src += i; n -= i; } else { diff --git a/t/t5409-colorize-remote-messages.sh b/t/t5409-colorize-remote-messages.sh index e5092d3b426cd3..896e790bf955cd 100755 --- a/t/t5409-colorize-remote-messages.sh +++ b/t/t5409-colorize-remote-messages.sh @@ -128,4 +128,42 @@ test_expect_success 'disallow (color) control sequences in sideband' ' test_file_not_empty actual ' +test_decode_csi() { + awk '{ + while (match($0, /\033/) != 0) { + printf "%sCSI ", substr($0, 1, RSTART-1); + $0 = substr($0, RSTART + RLENGTH, length($0) - RSTART - RLENGTH + 1); + } + print + }' +} + +test_expect_success 'control sequences in sideband allowed by default' ' + write_script .git/color-me-surprised <<-\EOF && + printf "error: \\033[31mcolor\\033[m\\033[Goverwrite\\033[Gerase\\033[K\\033?25l\\n" >&2 + exec "$@" + EOF + test_config_global uploadPack.packObjectsHook ./color-me-surprised && + test_commit need-at-least-one-commit-at-least && + + rm -rf throw-away && + git clone --no-local . throw-away 2>stderr && + test_decode_color color-decoded && + test_decode_csi decoded && + test_grep ! "CSI \\[K" decoded && + test_grep ! "CSI \\[G" decoded && + test_grep "\\^\\[?25l" decoded && + + rm -rf throw-away && + git -c sideband.allowControlCharacters=erase,cursor,color \ + clone --no-local . throw-away 2>stderr && + test_decode_color color-decoded && + test_decode_csi decoded && + test_grep "RED" decoded && + test_grep "CSI \\[K" decoded && + test_grep "CSI \\[G" decoded && + test_grep ! "\\^\\[\\[K" decoded && + test_grep ! "\\^\\[\\[G" decoded +' + test_done From 602c83f0efed46c2e86a36273673bf8776ded04e Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 5 Mar 2026 15:34:50 -0800 Subject: [PATCH 005/608] sideband: offer to configure sanitizing on a per-URL basis The main objection against sanitizing the sideband that was raised during the review of the sideband sanitizing patches, first on the git-security mailing list, then on the public mailing list, was that there are some setups where server-side `pre-receive` hooks want to error out, giving colorful messages to the users on the client side (if they are not redirecting the output into a file, that is). To avoid breaking such setups, the default chosen by the sideband sanitizing patches is to pass through ANSI color sequences. Still, there might be some use case out there where that is not enough. Therefore the `sideband.allowControlCharacters` config setting allows for configuring levels of sanitizing. As Junio Hamano pointed out, to keep users safe by default, we need to be able to scope this to some servers because while a user may trust their company's Git server, the same might not apply to other Git servers. To allow for this, let's imitate the way `http..*` offers to scope config settings to certain URLs, by letting users override the `sideband.allowControlCharacters` setting via `sideband..allowControlCharacters`. Suggested-by: Junio Hamano Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- Documentation/config/sideband.adoc | 4 ++ sideband.c | 81 ++++++++++++++++++++--------- sideband.h | 14 +++++ t/t5409-colorize-remote-messages.sh | 24 +++++++++ transport.c | 3 ++ 5 files changed, 102 insertions(+), 24 deletions(-) diff --git a/Documentation/config/sideband.adoc b/Documentation/config/sideband.adoc index 2bf04262840b02..32088bbf2f0a40 100644 --- a/Documentation/config/sideband.adoc +++ b/Documentation/config/sideband.adoc @@ -22,3 +22,7 @@ sideband.allowControlCharacters:: `true`:: Allow all control characters to be sent to the terminal. -- + +sideband..*:: + Apply the `sideband.*` option selectively to specific URLs. The + same URL matching logic applies as for `http..*` settings. diff --git a/sideband.c b/sideband.c index 0b420ca3193888..a90db9e2880cba 100644 --- a/sideband.c +++ b/sideband.c @@ -10,6 +10,7 @@ #include "help.h" #include "pkt-line.h" #include "write-or-die.h" +#include "urlmatch.h" struct keyword_entry { /* @@ -27,13 +28,14 @@ static struct keyword_entry keywords[] = { }; static enum { - ALLOW_NO_CONTROL_CHARACTERS = 0, - ALLOW_ANSI_COLOR_SEQUENCES = 1<<0, - ALLOW_ANSI_CURSOR_MOVEMENTS = 1<<1, - ALLOW_ANSI_ERASE = 1<<2, - ALLOW_DEFAULT_ANSI_SEQUENCES = ALLOW_ANSI_COLOR_SEQUENCES, - ALLOW_ALL_CONTROL_CHARACTERS = 1<<3, -} allow_control_characters = ALLOW_DEFAULT_ANSI_SEQUENCES; + ALLOW_CONTROL_SEQUENCES_UNSET = -1, + ALLOW_NO_CONTROL_CHARACTERS = 0, + ALLOW_ANSI_COLOR_SEQUENCES = 1<<0, + ALLOW_ANSI_CURSOR_MOVEMENTS = 1<<1, + ALLOW_ANSI_ERASE = 1<<2, + ALLOW_DEFAULT_ANSI_SEQUENCES = ALLOW_ANSI_COLOR_SEQUENCES, + ALLOW_ALL_CONTROL_CHARACTERS = 1<<3, +} allow_control_characters = ALLOW_CONTROL_SEQUENCES_UNSET; static inline int skip_prefix_in_csv(const char *value, const char *prefix, const char **out) @@ -45,8 +47,19 @@ static inline int skip_prefix_in_csv(const char *value, const char *prefix, return 1; } -static void parse_allow_control_characters(const char *value) +int sideband_allow_control_characters_config(const char *var, const char *value) { + switch (git_parse_maybe_bool(value)) { + case 0: + allow_control_characters = ALLOW_NO_CONTROL_CHARACTERS; + return 0; + case 1: + allow_control_characters = ALLOW_ALL_CONTROL_CHARACTERS; + return 0; + default: + break; + } + allow_control_characters = ALLOW_NO_CONTROL_CHARACTERS; while (*value) { if (skip_prefix_in_csv(value, "default", &value)) @@ -62,9 +75,37 @@ static void parse_allow_control_characters(const char *value) else if (skip_prefix_in_csv(value, "false", &value)) allow_control_characters = ALLOW_NO_CONTROL_CHARACTERS; else - warning(_("unrecognized value for `sideband." - "allowControlCharacters`: '%s'"), value); + warning(_("unrecognized value for '%s': '%s'"), var, value); } + return 0; +} + +static int sideband_config_callback(const char *var, const char *value, + const struct config_context *ctx UNUSED, + void *data UNUSED) +{ + if (!strcmp(var, "sideband.allowcontrolcharacters")) + return sideband_allow_control_characters_config(var, value); + + return 0; +} + +void sideband_apply_url_config(const char *url) +{ + struct urlmatch_config config = URLMATCH_CONFIG_INIT; + char *normalized_url; + + if (!url) + BUG("must not call sideband_apply_url_config(NULL)"); + + config.section = "sideband"; + config.collect_fn = sideband_config_callback; + + normalized_url = url_normalize(url, &config.url); + repo_config(the_repository, urlmatch_config_entry, &config); + free(normalized_url); + string_list_clear(&config.vars, 1); + urlmatch_config_release(&config); } /* Returns a color setting (GIT_COLOR_NEVER, etc). */ @@ -80,20 +121,12 @@ static enum git_colorbool use_sideband_colors(void) if (use_sideband_colors_cached != GIT_COLOR_UNKNOWN) return use_sideband_colors_cached; - switch (repo_config_get_maybe_bool(the_repository, "sideband.allowcontrolcharacters", &i)) { - case 0: /* Boolean value */ - allow_control_characters = i ? ALLOW_ALL_CONTROL_CHARACTERS : - ALLOW_NO_CONTROL_CHARACTERS; - break; - case -1: /* non-Boolean value */ - if (repo_config_get_string_tmp(the_repository, "sideband.allowcontrolcharacters", - &value)) - ; /* huh? `get_maybe_bool()` returned -1 */ - else - parse_allow_control_characters(value); - break; - default: - break; /* not configured */ + if (allow_control_characters == ALLOW_CONTROL_SEQUENCES_UNSET) { + if (!repo_config_get_value(the_repository, "sideband.allowcontrolcharacters", &value)) + sideband_allow_control_characters_config("sideband.allowcontrolcharacters", value); + + if (allow_control_characters == ALLOW_CONTROL_SEQUENCES_UNSET) + allow_control_characters = ALLOW_DEFAULT_ANSI_SEQUENCES; } if (!repo_config_get_string_tmp(the_repository, key, &value)) diff --git a/sideband.h b/sideband.h index 5a25331be55d30..d15fa4015fa0a3 100644 --- a/sideband.h +++ b/sideband.h @@ -30,4 +30,18 @@ int demultiplex_sideband(const char *me, int status, void send_sideband(int fd, int band, const char *data, ssize_t sz, int packet_max); +/* + * Apply sideband configuration for the given URL. This should be called + * when a transport is created to allow URL-specific configuration of + * sideband behavior (e.g., sideband..allowControlCharacters). + */ +void sideband_apply_url_config(const char *url); + +/* + * Parse and set the sideband allow control characters configuration. + * The var parameter should be the key name (without section prefix). + * Returns 0 if the variable was recognized and handled, non-zero otherwise. + */ +int sideband_allow_control_characters_config(const char *var, const char *value); + #endif diff --git a/t/t5409-colorize-remote-messages.sh b/t/t5409-colorize-remote-messages.sh index 896e790bf955cd..3010913bb113e4 100755 --- a/t/t5409-colorize-remote-messages.sh +++ b/t/t5409-colorize-remote-messages.sh @@ -166,4 +166,28 @@ test_expect_success 'control sequences in sideband allowed by default' ' test_grep ! "\\^\\[\\[G" decoded ' +test_expect_success 'allow all control sequences for a specific URL' ' + write_script .git/eraser <<-\EOF && + printf "error: Ohai!\\r\\033[K" >&2 + exec "$@" + EOF + test_config_global uploadPack.packObjectsHook ./eraser && + test_commit one-more-please && + + rm -rf throw-away && + git clone --no-local . throw-away 2>stderr && + test_decode_color color-decoded && + test_decode_csi decoded && + test_grep ! "CSI \\[K" decoded && + test_grep "\\^\\[\\[K" decoded && + + rm -rf throw-away && + git -c "sideband.file://.allowControlCharacters=true" \ + clone --no-local "file://$PWD" throw-away 2>stderr && + test_decode_color color-decoded && + test_decode_csi decoded && + test_grep "CSI \\[K" decoded && + test_grep ! "\\^\\[\\[K" decoded +' + test_done diff --git a/transport.c b/transport.c index c7f06a7382e605..1602065953a54e 100644 --- a/transport.c +++ b/transport.c @@ -29,6 +29,7 @@ #include "object-name.h" #include "color.h" #include "bundle-uri.h" +#include "sideband.h" static enum git_colorbool transport_use_color = GIT_COLOR_UNKNOWN; static char transport_colors[][COLOR_MAXLEN] = { @@ -1245,6 +1246,8 @@ struct transport *transport_get(struct remote *remote, const char *url) ret->hash_algo = &hash_algos[GIT_HASH_SHA1_LEGACY]; + sideband_apply_url_config(ret->url); + return ret; } From 826cc4722088a02d0ae240c1267b5b74d476b153 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Thu, 5 Mar 2026 15:34:51 -0800 Subject: [PATCH 006/608] sideband: drop 'default' configuration The topic so far allows users to tweak the configuration variable sideband.allowControlCharacters to override the hardcoded default, but among which there is the value called 'default'. The plan [*] of the series is to loosen the setting by a later commit in the series and schedule it to tighten at the Git 3.0 boundary for end users, at which point, the meaning of this 'default' value will change. Which is a dubious design. A user expresses their preference by setting configuration variable in order to guard against sudden change brought in by changes to the hardcoded default behaviour, and letting them set it to 'default' that will change at the Git 3.0 boundary defeats its purpose. If a user wants to say "I am easy and can go with whatever hardcoded default Git implementors choose for me", they simply leave the configuration variable unspecified. Let's remove it from the state before Git 3.0 so that those users who set it to 'default' will not see the behaviour changed under their feet all of sudden. Signed-off-by: Junio C Hamano --- Documentation/config/sideband.adoc | 1 - sideband.c | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Documentation/config/sideband.adoc b/Documentation/config/sideband.adoc index 32088bbf2f0a40..96fade7f5fee39 100644 --- a/Documentation/config/sideband.adoc +++ b/Documentation/config/sideband.adoc @@ -6,7 +6,6 @@ sideband.allowControlCharacters:: a comma-separated list of the following keywords): + -- - `default`:: `color`:: Allow ANSI color sequences, line feeds and horizontal tabs, but mask all other control characters. This is the default. diff --git a/sideband.c b/sideband.c index a90db9e2880cba..04282a568edd90 100644 --- a/sideband.c +++ b/sideband.c @@ -33,8 +33,8 @@ static enum { ALLOW_ANSI_COLOR_SEQUENCES = 1<<0, ALLOW_ANSI_CURSOR_MOVEMENTS = 1<<1, ALLOW_ANSI_ERASE = 1<<2, - ALLOW_DEFAULT_ANSI_SEQUENCES = ALLOW_ANSI_COLOR_SEQUENCES, ALLOW_ALL_CONTROL_CHARACTERS = 1<<3, + ALLOW_DEFAULT_ANSI_SEQUENCES = ALLOW_ANSI_COLOR_SEQUENCES } allow_control_characters = ALLOW_CONTROL_SEQUENCES_UNSET; static inline int skip_prefix_in_csv(const char *value, const char *prefix, @@ -62,9 +62,7 @@ int sideband_allow_control_characters_config(const char *var, const char *value) allow_control_characters = ALLOW_NO_CONTROL_CHARACTERS; while (*value) { - if (skip_prefix_in_csv(value, "default", &value)) - allow_control_characters |= ALLOW_DEFAULT_ANSI_SEQUENCES; - else if (skip_prefix_in_csv(value, "color", &value)) + if (skip_prefix_in_csv(value, "color", &value)) allow_control_characters |= ALLOW_ANSI_COLOR_SEQUENCES; else if (skip_prefix_in_csv(value, "cursor", &value)) allow_control_characters |= ALLOW_ANSI_CURSOR_MOVEMENTS; From b2faaaec1193c64f7366e26a569ca6e231cbd478 Mon Sep 17 00:00:00 2001 From: Pablo Sabater Date: Sat, 28 Mar 2026 01:11:11 +0100 Subject: [PATCH 007/608] graph: limit the graph width to a hard-coded max Repositories that have many active branches at the same time produce wide graphs. A lane consists of two columns, the edge and the padding (or another edge), each branch takes a lane in the graph and there is no way to limit how many can be shown. Limit the graph engine to draw at most 15 lanes. Lanes over the limit are not rendered. On the commit line, if the commit lives on a visible lane, show the normal commit mark and stop rendering. If the commit lives on the first hidden lane, show the "*" commit mark so it is known that this commit lives in the first hidden lane. Commits on deeper lanes aren't rendered, but the commit subject will always remain. For merges, the post-merge lane is only needed when the commit or the first parent lives on a visible lane (to draw the connection between them), when both are on hidden lanes, post-merge carries no useful information, skip it and go to collapsing or padding state. Also fix a pre-existing indentation issue. The hard-coded limit will be replaced by a user-facing option on a subsequent commit. Signed-off-by: Pablo Sabater Signed-off-by: Junio C Hamano --- graph.c | 161 +++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 136 insertions(+), 25 deletions(-) diff --git a/graph.c b/graph.c index 26f6fbf000aef5..70458cf323e168 100644 --- a/graph.c +++ b/graph.c @@ -82,6 +82,8 @@ static void graph_show_line_prefix(const struct diff_options *diffopt) static const char **column_colors; static unsigned short column_colors_max; +static unsigned int max_lanes = 15; + static void parse_graph_colors_config(struct strvec *colors, const char *string) { const char *end, *start; @@ -317,6 +319,11 @@ struct git_graph { struct strbuf prefix_buf; }; +static inline int graph_needs_truncation(int lane) +{ + return lane >= max_lanes; +} + static const char *diff_output_prefix_callback(struct diff_options *opt, void *data) { struct git_graph *graph = data; @@ -607,7 +614,7 @@ static void graph_update_columns(struct git_graph *graph) { struct commit_list *parent; int max_new_columns; - int i, seen_this, is_commit_in_columns; + int i, seen_this, is_commit_in_columns, max; /* * Swap graph->columns with graph->new_columns @@ -696,6 +703,14 @@ static void graph_update_columns(struct git_graph *graph) } } + /* + * Cap to the hard-coded limit. + * Allow commits from merges to align to the merged lane. + */ + max = max_lanes * 2 + 2; + if (graph->width > max) + graph->width = max; + /* * Shrink mapping_size to be the minimum necessary */ @@ -846,6 +861,8 @@ static void graph_output_padding_line(struct git_graph *graph, * Output a padding row, that leaves all branch lines unchanged */ for (i = 0; i < graph->num_new_columns; i++) { + if (graph_needs_truncation(i)) + break; graph_line_write_column(line, &graph->new_columns[i], '|'); graph_line_addch(line, ' '); } @@ -903,6 +920,8 @@ static void graph_output_pre_commit_line(struct git_graph *graph, seen_this = 1; graph_line_write_column(line, col, '|'); graph_line_addchars(line, ' ', graph->expansion_row); + } else if (seen_this && graph_needs_truncation(i)) { + break; } else if (seen_this && (graph->expansion_row == 0)) { /* * This is the first line of the pre-commit output. @@ -994,6 +1013,14 @@ static void graph_draw_octopus_merge(struct git_graph *graph, struct graph_line col = &graph->new_columns[j]; graph_line_write_column(line, col, '-'); + + /* + * Commit is at commit_index, each iteration move one lane to + * the right from the commit. + */ + if (graph_needs_truncation(graph->commit_index + 1 + i)) + break; + graph_line_write_column(line, col, (i == dashed_parents - 1) ? '.' : '-'); } @@ -1028,8 +1055,16 @@ static void graph_output_commit_line(struct git_graph *graph, struct graph_line seen_this = 1; graph_output_commit_char(graph, line); + if (graph_needs_truncation(i)) { + graph_line_addch(line, ' '); + break; + } + if (graph->num_parents > 2) graph_draw_octopus_merge(graph, line); + } else if (graph_needs_truncation(i)) { + seen_this = 1; + break; } else if (seen_this && (graph->edges_added > 1)) { graph_line_write_column(line, col, '\\'); } else if (seen_this && (graph->edges_added == 1)) { @@ -1065,13 +1100,46 @@ static void graph_output_commit_line(struct git_graph *graph, struct graph_line /* * Update graph->state + * + * If the commit is a merge and the first parent is in a visible lane, + * then the GRAPH_POST_MERGE is needed to draw the merge lane. + * + * If the commit is over the truncation limit, but the first parent is on + * a visible lane, then we still need the merge lane but truncated. + * + * If both commit and first parent are over the truncation limit, then + * there's no need to draw the merge lane because it would work as a + * padding lane. */ - if (graph->num_parents > 1) - graph_update_state(graph, GRAPH_POST_MERGE); - else if (graph_is_mapping_correct(graph)) + if (graph->num_parents > 1) { + if (!graph_needs_truncation(graph->commit_index)) { + graph_update_state(graph, GRAPH_POST_MERGE); + } else { + struct commit_list *p = first_interesting_parent(graph); + int lane; + + /* + * graph->num_parents are found using first_interesting_parent + * and next_interesting_parent so it can't be a scenario + * where num_parents > 1 and there are no interesting parents + */ + if (!p) + BUG("num_parents > 1 but no interesting parent"); + + lane = graph_find_new_column_by_commit(graph, p->item); + + if (!graph_needs_truncation(lane)) + graph_update_state(graph, GRAPH_POST_MERGE); + else if (graph_is_mapping_correct(graph)) + graph_update_state(graph, GRAPH_PADDING); + else + graph_update_state(graph, GRAPH_COLLAPSING); + } + } else if (graph_is_mapping_correct(graph)) { graph_update_state(graph, GRAPH_PADDING); - else + } else { graph_update_state(graph, GRAPH_COLLAPSING); + } } static const char merge_chars[] = {'/', '|', '\\'}; @@ -1109,6 +1177,7 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l int par_column; int idx = graph->merge_layout; char c; + int truncated = 0; seen_this = 1; for (j = 0; j < graph->num_parents; j++) { @@ -1117,23 +1186,53 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l c = merge_chars[idx]; graph_line_write_column(line, &graph->new_columns[par_column], c); + + /* + * j counts parents, it needs to be halved to be + * comparable with i. Don't truncate if there are + * no more lanes to print (end of the lane) + */ + if (graph_needs_truncation(j / 2 + i) && + j / 2 + i <= graph->num_columns) { + if ((j + i * 2) % 2 != 0) + graph_line_addch(line, ' '); + truncated = 1; + break; + } + if (idx == 2) { - if (graph->edges_added > 0 || j < graph->num_parents - 1) + /* + * Check if the next lane needs truncation + * to avoid having the padding doubled + */ + if (graph_needs_truncation((j + 1) / 2 + i) && + j < graph->num_parents - 1) { + truncated = 1; + break; + } else if (graph->edges_added > 0 || j < graph->num_parents - 1) graph_line_addch(line, ' '); } else { idx++; } parents = next_interesting_parent(graph, parents); } + if (truncated) + break; if (graph->edges_added == 0) graph_line_addch(line, ' '); - + } else if (graph_needs_truncation(i)) { + break; } else if (seen_this) { if (graph->edges_added > 0) graph_line_write_column(line, col, '\\'); else graph_line_write_column(line, col, '|'); - graph_line_addch(line, ' '); + /* + * If it's between two lanes and next would be truncated, + * don't add space padding. + */ + if (!graph_needs_truncation(i + 1)) + graph_line_addch(line, ' '); } else { graph_line_write_column(line, col, '|'); if (graph->merge_layout != 0 || i != graph->commit_index - 1) { @@ -1164,6 +1263,7 @@ static void graph_output_collapsing_line(struct git_graph *graph, struct graph_l short used_horizontal = 0; int horizontal_edge = -1; int horizontal_edge_target = -1; + int truncated = 0; /* * Swap the mapping and old_mapping arrays @@ -1279,26 +1379,34 @@ static void graph_output_collapsing_line(struct git_graph *graph, struct graph_l */ for (i = 0; i < graph->mapping_size; i++) { int target = graph->mapping[i]; - if (target < 0) - graph_line_addch(line, ' '); - else if (target * 2 == i) - graph_line_write_column(line, &graph->new_columns[target], '|'); - else if (target == horizontal_edge_target && - i != horizontal_edge - 1) { - /* - * Set the mappings for all but the - * first segment to -1 so that they - * won't continue into the next line. - */ - if (i != (target * 2)+3) - graph->mapping[i] = -1; - used_horizontal = 1; - graph_line_write_column(line, &graph->new_columns[target], '_'); + + if (!truncated && graph_needs_truncation(i / 2)) { + truncated = 1; + } + + if (target < 0) { + if (!truncated) + graph_line_addch(line, ' '); + } else if (target * 2 == i) { + if (!truncated) + graph_line_write_column(line, &graph->new_columns[target], '|'); + } else if (target == horizontal_edge_target && + i != horizontal_edge - 1) { + /* + * Set the mappings for all but the + * first segment to -1 so that they + * won't continue into the next line. + */ + if (i != (target * 2)+3) + graph->mapping[i] = -1; + used_horizontal = 1; + if (!truncated) + graph_line_write_column(line, &graph->new_columns[target], '_'); } else { if (used_horizontal && i < horizontal_edge) graph->mapping[i] = -1; - graph_line_write_column(line, &graph->new_columns[target], '/'); - + if (!truncated) + graph_line_write_column(line, &graph->new_columns[target], '/'); } } @@ -1372,6 +1480,9 @@ static void graph_padding_line(struct git_graph *graph, struct strbuf *sb) for (i = 0; i < graph->num_columns; i++) { struct column *col = &graph->columns[i]; + if (graph_needs_truncation(i)) + break; + graph_line_write_column(&line, col, '|'); if (col->commit == graph->commit && graph->num_parents > 2) { From f756a3c78d4d88af1701996394650e6df6f66170 Mon Sep 17 00:00:00 2001 From: Pablo Sabater Date: Sat, 28 Mar 2026 01:11:12 +0100 Subject: [PATCH 008/608] graph: add --graph-lane-limit option Replace the hard-coded lane limit with a user-facing option '--graph-lane-limit='. It caps the number of visible lanes to n. This option requires '--graph', without it, limiting the graph has no meaning, in this case error out. Zero and negative values are valid inputs but silently ignored treating them as "no limit", the same as not using the option. This follows what '--max-parents' does with negative values. The default is 0, same as not being used. Signed-off-by: Pablo Sabater Signed-off-by: Junio C Hamano --- Documentation/rev-list-options.adoc | 5 + graph.c | 53 +++++----- revision.c | 6 ++ revision.h | 1 + t/t4215-log-skewed-merges.sh | 144 ++++++++++++++++++++++++++++ 5 files changed, 186 insertions(+), 23 deletions(-) diff --git a/Documentation/rev-list-options.adoc b/Documentation/rev-list-options.adoc index 2d195a147456ea..d530e744f6c19d 100644 --- a/Documentation/rev-list-options.adoc +++ b/Documentation/rev-list-options.adoc @@ -1259,6 +1259,11 @@ This implies the `--topo-order` option by default, but the in between them in that case. If __ is specified, it is the string that will be shown instead of the default one. +`--graph-lane-limit=`:: + When `--graph` is used, limit the number of graph lanes to be shown. + Lanes over the limit are not shown. By default it is set to 0 + (no limit), zero and negative values are ignored and treated as no limit. + ifdef::git-rev-list[] `--count`:: Print a number stating how many commits would have been diff --git a/graph.c b/graph.c index 70458cf323e168..ee1f9e2d2d943a 100644 --- a/graph.c +++ b/graph.c @@ -82,8 +82,6 @@ static void graph_show_line_prefix(const struct diff_options *diffopt) static const char **column_colors; static unsigned short column_colors_max; -static unsigned int max_lanes = 15; - static void parse_graph_colors_config(struct strvec *colors, const char *string) { const char *end, *start; @@ -319,9 +317,13 @@ struct git_graph { struct strbuf prefix_buf; }; -static inline int graph_needs_truncation(int lane) +static inline int graph_needs_truncation(struct git_graph *graph, int lane) { - return lane >= max_lanes; + int max = graph->revs->graph_max_lanes; + /* + * Ignore values <= 0, meaning no limit. + */ + return max > 0 && lane >= max; } static const char *diff_output_prefix_callback(struct diff_options *opt, void *data) @@ -614,7 +616,7 @@ static void graph_update_columns(struct git_graph *graph) { struct commit_list *parent; int max_new_columns; - int i, seen_this, is_commit_in_columns, max; + int i, seen_this, is_commit_in_columns; /* * Swap graph->columns with graph->new_columns @@ -704,12 +706,17 @@ static void graph_update_columns(struct git_graph *graph) } /* - * Cap to the hard-coded limit. - * Allow commits from merges to align to the merged lane. + * If graph_max_lanes is set, cap the width */ - max = max_lanes * 2 + 2; - if (graph->width > max) - graph->width = max; + if (graph->revs->graph_max_lanes > 0) { + /* + * Width is column index while a lane is half that. + * Allow commits from merges to align to the merged lane. + */ + int max_width = graph->revs->graph_max_lanes * 2 + 2; + if (graph->width > max_width) + graph->width = max_width; + } /* * Shrink mapping_size to be the minimum necessary @@ -861,7 +868,7 @@ static void graph_output_padding_line(struct git_graph *graph, * Output a padding row, that leaves all branch lines unchanged */ for (i = 0; i < graph->num_new_columns; i++) { - if (graph_needs_truncation(i)) + if (graph_needs_truncation(graph, i)) break; graph_line_write_column(line, &graph->new_columns[i], '|'); graph_line_addch(line, ' '); @@ -920,7 +927,7 @@ static void graph_output_pre_commit_line(struct git_graph *graph, seen_this = 1; graph_line_write_column(line, col, '|'); graph_line_addchars(line, ' ', graph->expansion_row); - } else if (seen_this && graph_needs_truncation(i)) { + } else if (seen_this && graph_needs_truncation(graph, i)) { break; } else if (seen_this && (graph->expansion_row == 0)) { /* @@ -1018,7 +1025,7 @@ static void graph_draw_octopus_merge(struct git_graph *graph, struct graph_line * Commit is at commit_index, each iteration move one lane to * the right from the commit. */ - if (graph_needs_truncation(graph->commit_index + 1 + i)) + if (graph_needs_truncation(graph, graph->commit_index + 1 + i)) break; graph_line_write_column(line, col, (i == dashed_parents - 1) ? '.' : '-'); @@ -1055,14 +1062,14 @@ static void graph_output_commit_line(struct git_graph *graph, struct graph_line seen_this = 1; graph_output_commit_char(graph, line); - if (graph_needs_truncation(i)) { + if (graph_needs_truncation(graph, i)) { graph_line_addch(line, ' '); break; } if (graph->num_parents > 2) graph_draw_octopus_merge(graph, line); - } else if (graph_needs_truncation(i)) { + } else if (graph_needs_truncation(graph, i)) { seen_this = 1; break; } else if (seen_this && (graph->edges_added > 1)) { @@ -1112,7 +1119,7 @@ static void graph_output_commit_line(struct git_graph *graph, struct graph_line * padding lane. */ if (graph->num_parents > 1) { - if (!graph_needs_truncation(graph->commit_index)) { + if (!graph_needs_truncation(graph, graph->commit_index)) { graph_update_state(graph, GRAPH_POST_MERGE); } else { struct commit_list *p = first_interesting_parent(graph); @@ -1128,7 +1135,7 @@ static void graph_output_commit_line(struct git_graph *graph, struct graph_line lane = graph_find_new_column_by_commit(graph, p->item); - if (!graph_needs_truncation(lane)) + if (!graph_needs_truncation(graph, lane)) graph_update_state(graph, GRAPH_POST_MERGE); else if (graph_is_mapping_correct(graph)) graph_update_state(graph, GRAPH_PADDING); @@ -1192,7 +1199,7 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l * comparable with i. Don't truncate if there are * no more lanes to print (end of the lane) */ - if (graph_needs_truncation(j / 2 + i) && + if (graph_needs_truncation(graph, j / 2 + i) && j / 2 + i <= graph->num_columns) { if ((j + i * 2) % 2 != 0) graph_line_addch(line, ' '); @@ -1205,7 +1212,7 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l * Check if the next lane needs truncation * to avoid having the padding doubled */ - if (graph_needs_truncation((j + 1) / 2 + i) && + if (graph_needs_truncation(graph, (j + 1) / 2 + i) && j < graph->num_parents - 1) { truncated = 1; break; @@ -1220,7 +1227,7 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l break; if (graph->edges_added == 0) graph_line_addch(line, ' '); - } else if (graph_needs_truncation(i)) { + } else if (graph_needs_truncation(graph, i)) { break; } else if (seen_this) { if (graph->edges_added > 0) @@ -1231,7 +1238,7 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l * If it's between two lanes and next would be truncated, * don't add space padding. */ - if (!graph_needs_truncation(i + 1)) + if (!graph_needs_truncation(graph, i + 1)) graph_line_addch(line, ' '); } else { graph_line_write_column(line, col, '|'); @@ -1380,7 +1387,7 @@ static void graph_output_collapsing_line(struct git_graph *graph, struct graph_l for (i = 0; i < graph->mapping_size; i++) { int target = graph->mapping[i]; - if (!truncated && graph_needs_truncation(i / 2)) { + if (!truncated && graph_needs_truncation(graph, i / 2)) { truncated = 1; } @@ -1480,7 +1487,7 @@ static void graph_padding_line(struct git_graph *graph, struct strbuf *sb) for (i = 0; i < graph->num_columns; i++) { struct column *col = &graph->columns[i]; - if (graph_needs_truncation(i)) + if (graph_needs_truncation(graph, i)) break; graph_line_write_column(&line, col, '|'); diff --git a/revision.c b/revision.c index 31808e3df055c7..81b67682a87a92 100644 --- a/revision.c +++ b/revision.c @@ -2605,6 +2605,8 @@ static int handle_revision_opt(struct rev_info *revs, int argc, const char **arg } else if (!strcmp(arg, "--no-graph")) { graph_clear(revs->graph); revs->graph = NULL; + } else if (skip_prefix(arg, "--graph-lane-limit=", &optarg)) { + revs->graph_max_lanes = parse_count(optarg); } else if (!strcmp(arg, "--encode-email-headers")) { revs->encode_email_headers = 1; } else if (!strcmp(arg, "--no-encode-email-headers")) { @@ -3172,6 +3174,10 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s if (revs->no_walk && revs->graph) die(_("options '%s' and '%s' cannot be used together"), "--no-walk", "--graph"); + + if (revs->graph_max_lanes > 0 && !revs->graph) + die(_("the option '%s' requires '%s'"), "--graph-lane-limit", "--graph"); + if (!revs->reflog_info && revs->grep_filter.use_reflog_filter) die(_("the option '%s' requires '%s'"), "--grep-reflog", "--walk-reflogs"); diff --git a/revision.h b/revision.h index 69242ecb189a52..874ccce62571e4 100644 --- a/revision.h +++ b/revision.h @@ -304,6 +304,7 @@ struct rev_info { /* Display history graph */ struct git_graph *graph; + int graph_max_lanes; /* special limits */ int skip_count; diff --git a/t/t4215-log-skewed-merges.sh b/t/t4215-log-skewed-merges.sh index 28d0779a8c599e..d7524e93669874 100755 --- a/t/t4215-log-skewed-merges.sh +++ b/t/t4215-log-skewed-merges.sh @@ -370,4 +370,148 @@ test_expect_success 'log --graph with multiple tips' ' EOF ' +test_expect_success 'log --graph --graph-lane-limit=2 limited to two lanes' ' + check_graph --graph-lane-limit=2 M_7 <<-\EOF + *-. 7_M4 + |\ \ + | | * 7_G + | | * 7_F + | * 7_E + | * 7_D + * | 7_C + | |/ + |/| + * | 7_B + |/ + * 7_A + EOF +' + +test_expect_success 'log --graph --graph-lane-limit=1 truncate mid octopus merge' ' + check_graph --graph-lane-limit=1 M_7 <<-\EOF + *- 7_M4 + |\ + | 7_G + | 7_F + | * 7_E + | * 7_D + * 7_C + | + |/ + * 7_B + |/ + * 7_A + EOF +' + +test_expect_success 'log --graph --graph-lane-limit=3 limited to three lanes' ' + check_graph --graph-lane-limit=3 M_1 M_3 M_5 M_7 <<-\EOF + * 7_M1 + |\ + | | * 7_M2 + | | |\ + | | | * 7_H + | | | 7_M3 + | | | 7_J + | | | 7_I + | | | 7_M4 + | |_|_ + |/| | + | | |_ + | |/| + | | | + | | |/ + | | * 7_G + | | | + | | |/ + | | * 7_F + | * | 7_E + | | |/ + | |/| + | * | 7_D + | | |/ + | |/| + * | | 7_C + | |/ + |/| + * | 7_B + |/ + * 7_A + EOF +' + +test_expect_success 'log --graph --graph-lane-limit=6 check if it only shows first of 3 parent merge' ' + check_graph --graph-lane-limit=6 M_1 M_3 M_5 M_7 <<-\EOF + * 7_M1 + |\ + | | * 7_M2 + | | |\ + | | | * 7_H + | | | | * 7_M3 + | | | | |\ + | | | | | * 7_J + | | | | * | 7_I + | | | | | | * 7_M4 + | |_|_|_|_|/ + |/| | | | |/ + | | |_|_|/| + | |/| | | |/ + | | | |_|/| + | | |/| | | + | | * | | | 7_G + | | | |_|/ + | | |/| | + | | * | | 7_F + | * | | | 7_E + | | |/ / + | |/| | + | * | | 7_D + | | |/ + | |/| + * | | 7_C + | |/ + |/| + * | 7_B + |/ + * 7_A + EOF +' + +test_expect_success 'log --graph --graph-lane-limit=7 check if it shows all 3 parent merge' ' + check_graph --graph-lane-limit=7 M_1 M_3 M_5 M_7 <<-\EOF + * 7_M1 + |\ + | | * 7_M2 + | | |\ + | | | * 7_H + | | | | * 7_M3 + | | | | |\ + | | | | | * 7_J + | | | | * | 7_I + | | | | | | * 7_M4 + | |_|_|_|_|/|\ + |/| | | | |/ / + | | |_|_|/| / + | |/| | | |/ + | | | |_|/| + | | |/| | | + | | * | | | 7_G + | | | |_|/ + | | |/| | + | | * | | 7_F + | * | | | 7_E + | | |/ / + | |/| | + | * | | 7_D + | | |/ + | |/| + * | | 7_C + | |/ + |/| + * | 7_B + |/ + * 7_A + EOF +' + test_done From 9bab3ce5553b2333b8f8ee1aff27a9fe6a938f65 Mon Sep 17 00:00:00 2001 From: Pablo Sabater Date: Sat, 28 Mar 2026 01:11:13 +0100 Subject: [PATCH 009/608] graph: add truncation mark to capped lanes When lanes are hidden by --graph-lane-limit, show a "~" truncation mark, so users know that there are lanes being truncated. The "~" is chosen because it is not used elsewhere in the graph and it is discrete. Signed-off-by: Pablo Sabater Signed-off-by: Junio C Hamano --- Documentation/rev-list-options.adoc | 5 ++- graph.c | 22 +++++++--- t/t4215-log-skewed-merges.sh | 64 ++++++++++++++--------------- 3 files changed, 52 insertions(+), 39 deletions(-) diff --git a/Documentation/rev-list-options.adoc b/Documentation/rev-list-options.adoc index d530e744f6c19d..94a7b1c065dba8 100644 --- a/Documentation/rev-list-options.adoc +++ b/Documentation/rev-list-options.adoc @@ -1261,8 +1261,9 @@ This implies the `--topo-order` option by default, but the `--graph-lane-limit=`:: When `--graph` is used, limit the number of graph lanes to be shown. - Lanes over the limit are not shown. By default it is set to 0 - (no limit), zero and negative values are ignored and treated as no limit. + Lanes over the limit are replaced with a truncation mark '~'. + By default it is set to 0 (no limit), zero and negative values + are ignored and treated as no limit. ifdef::git-rev-list[] `--count`:: diff --git a/graph.c b/graph.c index ee1f9e2d2d943a..842282685f6cef 100644 --- a/graph.c +++ b/graph.c @@ -706,11 +706,11 @@ static void graph_update_columns(struct git_graph *graph) } /* - * If graph_max_lanes is set, cap the width + * If graph_max_lanes is set, cap the width */ if (graph->revs->graph_max_lanes > 0) { /* - * Width is column index while a lane is half that. + * width of "| " per lanes plus truncation mark "~ ". * Allow commits from merges to align to the merged lane. */ int max_width = graph->revs->graph_max_lanes * 2 + 2; @@ -868,8 +868,10 @@ static void graph_output_padding_line(struct git_graph *graph, * Output a padding row, that leaves all branch lines unchanged */ for (i = 0; i < graph->num_new_columns; i++) { - if (graph_needs_truncation(graph, i)) + if (graph_needs_truncation(graph, i)) { + graph_line_addstr(line, "~ "); break; + } graph_line_write_column(line, &graph->new_columns[i], '|'); graph_line_addch(line, ' '); } @@ -928,6 +930,7 @@ static void graph_output_pre_commit_line(struct git_graph *graph, graph_line_write_column(line, col, '|'); graph_line_addchars(line, ' ', graph->expansion_row); } else if (seen_this && graph_needs_truncation(graph, i)) { + graph_line_addstr(line, "~ "); break; } else if (seen_this && (graph->expansion_row == 0)) { /* @@ -1025,8 +1028,10 @@ static void graph_draw_octopus_merge(struct git_graph *graph, struct graph_line * Commit is at commit_index, each iteration move one lane to * the right from the commit. */ - if (graph_needs_truncation(graph, graph->commit_index + 1 + i)) + if (graph_needs_truncation(graph, graph->commit_index + 1 + i)) { + graph_line_addstr(line, "~ "); break; + } graph_line_write_column(line, col, (i == dashed_parents - 1) ? '.' : '-'); } @@ -1070,6 +1075,7 @@ static void graph_output_commit_line(struct git_graph *graph, struct graph_line if (graph->num_parents > 2) graph_draw_octopus_merge(graph, line); } else if (graph_needs_truncation(graph, i)) { + graph_line_addstr(line, "~ "); seen_this = 1; break; } else if (seen_this && (graph->edges_added > 1)) { @@ -1203,6 +1209,7 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l j / 2 + i <= graph->num_columns) { if ((j + i * 2) % 2 != 0) graph_line_addch(line, ' '); + graph_line_addstr(line, "~ "); truncated = 1; break; } @@ -1214,6 +1221,7 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l */ if (graph_needs_truncation(graph, (j + 1) / 2 + i) && j < graph->num_parents - 1) { + graph_line_addstr(line, "~ "); truncated = 1; break; } else if (graph->edges_added > 0 || j < graph->num_parents - 1) @@ -1228,6 +1236,7 @@ static void graph_output_post_merge_line(struct git_graph *graph, struct graph_l if (graph->edges_added == 0) graph_line_addch(line, ' '); } else if (graph_needs_truncation(graph, i)) { + graph_line_addstr(line, "~ "); break; } else if (seen_this) { if (graph->edges_added > 0) @@ -1388,6 +1397,7 @@ static void graph_output_collapsing_line(struct git_graph *graph, struct graph_l int target = graph->mapping[i]; if (!truncated && graph_needs_truncation(graph, i / 2)) { + graph_line_addstr(line, "~ "); truncated = 1; } @@ -1487,8 +1497,10 @@ static void graph_padding_line(struct git_graph *graph, struct strbuf *sb) for (i = 0; i < graph->num_columns; i++) { struct column *col = &graph->columns[i]; - if (graph_needs_truncation(graph, i)) + if (graph_needs_truncation(graph, i)) { + graph_line_addstr(&line, "~ "); break; + } graph_line_write_column(&line, col, '|'); diff --git a/t/t4215-log-skewed-merges.sh b/t/t4215-log-skewed-merges.sh index d7524e93669874..1612f05f1b39ce 100755 --- a/t/t4215-log-skewed-merges.sh +++ b/t/t4215-log-skewed-merges.sh @@ -376,9 +376,9 @@ test_expect_success 'log --graph --graph-lane-limit=2 limited to two lanes' ' |\ \ | | * 7_G | | * 7_F - | * 7_E - | * 7_D - * | 7_C + | * ~ 7_E + | * ~ 7_D + * | ~ 7_C | |/ |/| * | 7_B @@ -389,16 +389,16 @@ test_expect_success 'log --graph --graph-lane-limit=2 limited to two lanes' ' test_expect_success 'log --graph --graph-lane-limit=1 truncate mid octopus merge' ' check_graph --graph-lane-limit=1 M_7 <<-\EOF - *- 7_M4 - |\ - | 7_G - | 7_F + *-~ 7_M4 + |\~ + | ~ 7_G + | ~ 7_F | * 7_E | * 7_D - * 7_C - | - |/ - * 7_B + * ~ 7_C + | ~ + |/~ + * ~ 7_B |/ * 7_A EOF @@ -411,24 +411,24 @@ test_expect_success 'log --graph --graph-lane-limit=3 limited to three lanes' ' | | * 7_M2 | | |\ | | | * 7_H - | | | 7_M3 - | | | 7_J - | | | 7_I - | | | 7_M4 - | |_|_ - |/| | - | | |_ - | |/| - | | | - | | |/ - | | * 7_G - | | | - | | |/ - | | * 7_F - | * | 7_E - | | |/ - | |/| - | * | 7_D + | | | ~ 7_M3 + | | | ~ 7_J + | | | ~ 7_I + | | | ~ 7_M4 + | |_|_~ + |/| | ~ + | | |_~ + | |/| ~ + | | | ~ + | | |/~ + | | * ~ 7_G + | | | ~ + | | |/~ + | | * ~ 7_F + | * | ~ 7_E + | | |/~ + | |/| ~ + | * | ~ 7_D | | |/ | |/| * | | 7_C @@ -452,9 +452,9 @@ test_expect_success 'log --graph --graph-lane-limit=6 check if it only shows fir | | | | | * 7_J | | | | * | 7_I | | | | | | * 7_M4 - | |_|_|_|_|/ - |/| | | | |/ - | | |_|_|/| + | |_|_|_|_|/~ + |/| | | | |/~ + | | |_|_|/| ~ | |/| | | |/ | | | |_|/| | | |/| | | From 1d980196adfd79ae0936e681e8d98c57d9900785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-No=C3=ABl=20Avila?= Date: Sat, 4 Apr 2026 17:12:44 +0000 Subject: [PATCH 010/608] doc: convert git-difftool manual page to synopsis style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * convert commands to synopsis style * use __ for arguments * fix conditional text to sentence limits Signed-off-by: Jean-Noël Avila Signed-off-by: Junio C Hamano --- Documentation/config/difftool.adoc | 24 ++++----- Documentation/config/mergetool.adoc | 8 +-- Documentation/git-difftool.adoc | 80 ++++++++++++++--------------- 3 files changed, 56 insertions(+), 56 deletions(-) diff --git a/Documentation/config/difftool.adoc b/Documentation/config/difftool.adoc index 4f7d40ce242b78..1b8d48381357aa 100644 --- a/Documentation/config/difftool.adoc +++ b/Documentation/config/difftool.adoc @@ -1,43 +1,43 @@ -diff.tool:: +`diff.tool`:: Controls which diff tool is used by linkgit:git-difftool[1]. This variable overrides the value configured in `merge.tool`. The list below shows the valid built-in values. Any other value is treated as a custom diff tool and requires - that a corresponding difftool..cmd variable is defined. + that a corresponding `difftool..cmd` variable is defined. -diff.guitool:: +`diff.guitool`:: Controls which diff tool is used by linkgit:git-difftool[1] when - the -g/--gui flag is specified. This variable overrides the value + the `-g`/`--gui` flag is specified. This variable overrides the value configured in `merge.guitool`. The list below shows the valid built-in values. Any other value is treated as a custom diff tool - and requires that a corresponding difftool..cmd variable + and requires that a corresponding `difftool..cmd` variable is defined. include::{build_dir}/mergetools-diff.adoc[] -difftool..cmd:: +`difftool..cmd`:: Specify the command to invoke the specified diff tool. The specified command is evaluated in shell with the following - variables available: 'LOCAL' is set to the name of the temporary - file containing the contents of the diff pre-image and 'REMOTE' + variables available: `LOCAL` is set to the name of the temporary + file containing the contents of the diff pre-image and `REMOTE` is set to the name of the temporary file containing the contents of the diff post-image. + See the `--tool=` option in linkgit:git-difftool[1] for more details. -difftool..path:: +`difftool..path`:: Override the path for the given tool. This is useful in case your tool is not in the PATH. -difftool.trustExitCode:: +`difftool.trustExitCode`:: Exit difftool if the invoked diff tool returns a non-zero exit status. + See the `--trust-exit-code` option in linkgit:git-difftool[1] for more details. -difftool.prompt:: +`difftool.prompt`:: Prompt before each invocation of the diff tool. -difftool.guiDefault:: +`difftool.guiDefault`:: Set `true` to use the `diff.guitool` by default (equivalent to specifying the `--gui` argument), or `auto` to select `diff.guitool` or `diff.tool` depending on the presence of a `DISPLAY` environment variable value. The diff --git a/Documentation/config/mergetool.adoc b/Documentation/config/mergetool.adoc index 7064f5a462cb56..7afdcad92b3934 100644 --- a/Documentation/config/mergetool.adoc +++ b/Documentation/config/mergetool.adoc @@ -52,13 +52,13 @@ if `merge.tool` is configured as __), Git will consult `mergetool..layout` to determine the tool's layout. If the variant-specific configuration is not available, `vimdiff` ' s is used as - fallback. If that too is not available, a default layout with 4 windows - will be used. To configure the layout, see the 'BACKEND SPECIFIC HINTS' + fallback. If that too is not available, a default layout with 4 windows + will be used. ifdef::git-mergetool[] - section. +To configure the layout, see the 'BACKEND SPECIFIC HINTS' section. endif::[] ifndef::git-mergetool[] - section in linkgit:git-mergetool[1]. +To configure the layout, see the 'BACKEND SPECIFIC HINTS' section in linkgit:git-mergetool[1]. endif::[] `mergetool.hideResolved`:: diff --git a/Documentation/git-difftool.adoc b/Documentation/git-difftool.adoc index 064bc683471f21..dd7cacf95e35df 100644 --- a/Documentation/git-difftool.adoc +++ b/Documentation/git-difftool.adoc @@ -7,64 +7,64 @@ git-difftool - Show changes using common diff tools SYNOPSIS -------- -[verse] -'git difftool' [] [ []] [--] [...] +[synopsis] +git difftool [] [ []] [--] [...] DESCRIPTION ----------- -'git difftool' is a Git command that allows you to compare and edit files -between revisions using common diff tools. 'git difftool' is a frontend -to 'git diff' and accepts the same options and arguments. See +`git difftool` is a Git command that allows you to compare and edit files +between revisions using common diff tools. `git difftool` is a frontend +to `git diff` and accepts the same options and arguments. See linkgit:git-diff[1]. OPTIONS ------- --d:: ---dir-diff:: +`-d`:: +`--dir-diff`:: Copy the modified files to a temporary location and perform a directory diff on them. This mode never prompts before launching the diff tool. --y:: ---no-prompt:: +`-y`:: +`--no-prompt`:: Do not prompt before launching a diff tool. ---prompt:: +`--prompt`:: Prompt before each invocation of the diff tool. This is the default behaviour; the option is provided to override any configuration settings. ---rotate-to=:: - Start showing the diff for the given path, +`--rotate-to=`:: + Start showing the diff for __, the paths before it will move to the end and output. ---skip-to=:: - Start showing the diff for the given path, skipping all +`--skip-to=`:: + Start showing the diff for __, skipping all the paths before it. --t :: ---tool=:: - Use the diff tool specified by . Valid values include +`-t `:: +`--tool=`:: + Use the diff tool specified by __. Valid values include emerge, kompare, meld, and vimdiff. Run `git difftool --tool-help` - for the list of valid settings. + for the list of valid __ settings. + -If a diff tool is not specified, 'git difftool' +If a diff tool is not specified, `git difftool` will use the configuration variable `diff.tool`. If the -configuration variable `diff.tool` is not set, 'git difftool' +configuration variable `diff.tool` is not set, `git difftool` will pick a suitable default. + You can explicitly provide a full path to the tool by setting the configuration variable `difftool..path`. For example, you can configure the absolute path to kdiff3 by setting -`difftool.kdiff3.path`. Otherwise, 'git difftool' assumes the +`difftool.kdiff3.path`. Otherwise, `git difftool` assumes the tool is available in PATH. + Instead of running one of the known diff tools, -'git difftool' can be customized to run an alternative program +`git difftool` can be customized to run an alternative program by specifying the command line to invoke in a configuration variable `difftool..cmd`. + -When 'git difftool' is invoked with this tool (either through the +When `git difftool` is invoked with this tool (either through the `-t` or `--tool` option or the `diff.tool` configuration variable) the configured command line will be invoked with the following variables available: `$LOCAL` is set to the name of the temporary @@ -74,30 +74,30 @@ of the diff post-image. `$MERGED` is the name of the file which is being compared. `$BASE` is provided for compatibility with custom merge tool commands and has the same value as `$MERGED`. ---tool-help:: +`--tool-help`:: Print a list of diff tools that may be used with `--tool`. ---symlinks:: ---no-symlinks:: - 'git difftool''s default behavior is to create symlinks to the +`--symlinks`:: +`--no-symlinks`:: + `git difftool`'s default behavior is to create symlinks to the working tree when run in `--dir-diff` mode and the right-hand side of the comparison yields the same content as the file in the working tree. + -Specifying `--no-symlinks` instructs 'git difftool' to create copies +Specifying `--no-symlinks` instructs `git difftool` to create copies instead. `--no-symlinks` is the default on Windows. --x :: ---extcmd=:: +`-x `:: +`--extcmd=`:: Specify a custom command for viewing diffs. - 'git-difftool' ignores the configured defaults and runs + `git-difftool` ignores the configured defaults and runs ` $LOCAL $REMOTE` when this option is specified. Additionally, `$BASE` is set in the environment. --g:: ---gui:: ---no-gui:: - When 'git-difftool' is invoked with the `-g` or `--gui` option +`-g`:: +`--gui`:: +`--no-gui`:: + When `git-difftool` is invoked with the `-g` or `--gui` option the default diff tool will be read from the configured `diff.guitool` variable instead of `diff.tool`. This may be selected automatically using the configuration variable @@ -106,20 +106,20 @@ instead. `--no-symlinks` is the default on Windows. fallback in the order of `merge.guitool`, `diff.tool`, `merge.tool` until a tool is found. ---trust-exit-code:: ---no-trust-exit-code:: +`--trust-exit-code`:: +`--no-trust-exit-code`:: Errors reported by the diff tool are ignored by default. - Use `--trust-exit-code` to make 'git-difftool' exit when an + Use `--trust-exit-code` to make `git-difftool` exit when an invoked diff tool returns a non-zero exit code. + -'git-difftool' will forward the exit code of the invoked tool when +`git-difftool` will forward the exit code of the invoked tool when `--trust-exit-code` is used. See linkgit:git-diff[1] for the full list of supported options. CONFIGURATION ------------- -'git difftool' falls back to 'git mergetool' config variables when the +`git difftool` falls back to `git mergetool` config variables when the difftool equivalents have not been defined. include::includes/cmd-config-section-rest.adoc[] From 5594be68eaa0fc9c87f7a50be09b85762415f070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-No=C3=ABl=20Avila?= Date: Sat, 4 Apr 2026 17:12:45 +0000 Subject: [PATCH 011/608] doc: convert git-range-diff manual page to synopsis style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * convert commands and options to synopsis style * use __ for arguments * small style fixes Signed-off-by: Jean-Noël Avila Signed-off-by: Junio C Hamano --- Documentation/git-range-diff.adoc | 50 +++++++++++++++---------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Documentation/git-range-diff.adoc b/Documentation/git-range-diff.adoc index b5e85d37f1bee7..880557084533fb 100644 --- a/Documentation/git-range-diff.adoc +++ b/Documentation/git-range-diff.adoc @@ -7,8 +7,8 @@ git-range-diff - Compare two commit ranges (e.g. two versions of a branch) SYNOPSIS -------- -[verse] -'git range-diff' [--color=[]] [--no-color] [] +[synopsis] +git range-diff [--color=[]] [--no-color] [] [--no-dual-color] [--creation-factor=] [--left-only | --right-only] [--diff-merges=] [--remerge-diff] @@ -21,14 +21,14 @@ DESCRIPTION This command shows the differences between two versions of a patch series, or more generally, two commit ranges (ignoring merge commits). -In the presence of `` arguments, these commit ranges are limited +In the presence of __ arguments, these commit ranges are limited accordingly. To that end, it first finds pairs of commits from both commit ranges that correspond with each other. Two commits are said to correspond when the diff between their patches (i.e. the author information, the commit message and the commit diff) is reasonably small compared to the -patches' size. See ``Algorithm`` below for details. +patches' size. See 'Algorithm' below for details. Finally, the list of matching commits is shown in the order of the second commit range, with unmatched commits being inserted just after @@ -37,7 +37,7 @@ all of their ancestors have been shown. There are three ways to specify the commit ranges: - ` `: Either commit range can be of the form - `..`, `^!` or `^-`. See `SPECIFYING RANGES` + `..`, `^!` or `^-`. See 'SPECIFYING RANGES' in linkgit:gitrevisions[7] for more details. - `...`. This is equivalent to @@ -48,7 +48,7 @@ There are three ways to specify the commit ranges: OPTIONS ------- ---no-dual-color:: +`--no-dual-color`:: When the commit diffs differ, `git range-diff` recreates the original diffs' coloring, and adds outer -/+ diff markers with the *background* being red/green to make it easier to see e.g. @@ -56,33 +56,33 @@ OPTIONS + Additionally, the commit diff lines that are only present in the first commit range are shown "dimmed" (this can be overridden using the `color.diff.` -config setting where `` is one of `contextDimmed`, `oldDimmed` and +config setting where __ is one of `contextDimmed`, `oldDimmed` and `newDimmed`), and the commit diff lines that are only present in the second commit range are shown in bold (which can be overridden using the config -settings `color.diff.` with `` being one of `contextBold`, +settings `color.diff.` with __ being one of `contextBold`, `oldBold` or `newBold`). + This is known to `range-diff` as "dual coloring". Use `--no-dual-color` to revert to color all lines according to the outer diff markers (and completely ignore the inner diff when it comes to color). ---creation-factor=:: - Set the creation/deletion cost fudge factor to ``. +`--creation-factor=`:: + Set the creation/deletion cost fudge factor to __. Defaults to 60. Try a larger value if `git range-diff` erroneously considers a large change a total rewrite (deletion of one commit and addition of another), and a smaller one in the reverse case. - See the ``Algorithm`` section below for an explanation of why this is + See the 'Algorithm' section below for an explanation of why this is needed. ---left-only:: +`--left-only`:: Suppress commits that are missing from the first specified range - (or the "left range" when using the `...` format). + (or the "left range" when using the `...` form). ---right-only:: +`--right-only`:: Suppress commits that are missing from the second specified range - (or the "right range" when using the `...` format). + (or the "right range" when using the `...` form). ---diff-merges=:: +`--diff-merges=`:: Instead of ignoring merge commits, generate diffs for them using the corresponding `--diff-merges=` option of linkgit:git-log[1], and include them in the comparison. @@ -93,30 +93,30 @@ have produced. In other words, if a merge commit is the result of a non-conflicting `git merge`, the `remerge` mode will represent it with an empty diff. ---remerge-diff:: +`--remerge-diff`:: Convenience option, equivalent to `--diff-merges=remerge`. ---notes[=]:: ---no-notes:: +`--notes[=]`:: +`--no-notes`:: This flag is passed to the `git log` program (see linkgit:git-log[1]) that generates the patches. - :: +` `:: Compare the commits specified by the two ranges, where - `` is considered an older version of ``. + __ is considered an older version of __. -...:: +`...`:: Equivalent to passing `..` and `..`. - :: +` `:: Equivalent to passing `..` and `..`. - Note that `` does not need to be the exact branch point + Note that __ does not need to be the exact branch point of the branches. Example: after rebasing a branch `my-topic`, `git range-diff my-topic@{u} my-topic@{1} my-topic` would show the differences introduced by the rebase. `git range-diff` also accepts the regular diff options (see -linkgit:git-diff[1]), most notably the `--color=[]` and +linkgit:git-diff[1]), most notably the `--color[=]` and `--no-color` options. These options are used when generating the "diff between patches", i.e. to compare the author, commit message and diff of corresponding old/new commits. There is currently no means to tweak most of the From f4c1b8e3fe855355f3e4c84d3e1a50b9957bd240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-No=C3=ABl=20Avila?= Date: Sat, 4 Apr 2026 17:12:46 +0000 Subject: [PATCH 012/608] doc: convert git-shortlog manual page to synopsis style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * convert commands and options to synopsis style * use __ for arguments * small style fixes Signed-off-by: Jean-Noël Avila Signed-off-by: Junio C Hamano --- Documentation/git-shortlog.adoc | 60 ++++++++++++++++----------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/Documentation/git-shortlog.adoc b/Documentation/git-shortlog.adoc index a11b57c1cd7b2d..e067d39b3880a8 100644 --- a/Documentation/git-shortlog.adoc +++ b/Documentation/git-shortlog.adoc @@ -3,63 +3,63 @@ git-shortlog(1) NAME ---- -git-shortlog - Summarize 'git log' output +git-shortlog - Summarize `git log` output SYNOPSIS -------- -[verse] -'git shortlog' [] [] [[--] ...] -git log --pretty=short | 'git shortlog' [] +[synopsis] +git shortlog [] [] [[--] ...] +git log --pretty=short | git shortlog [] DESCRIPTION ----------- -Summarizes 'git log' output in a format suitable for inclusion +Summarizes `git log` output in a format suitable for inclusion in release announcements. Each commit will be grouped by author and title. Additionally, "[PATCH]" will be stripped from the commit description. If no revisions are passed on the command line and either standard input -is not a terminal or there is no current branch, 'git shortlog' will +is not a terminal or there is no current branch, `git shortlog` will output a summary of the log read from standard input, without reference to the current repository. OPTIONS ------- --n:: ---numbered:: +`-n`:: +`--numbered`:: Sort output according to the number of commits per author instead of author alphabetic order. --s:: ---summary:: +`-s`:: +`--summary`:: Suppress commit description and provide a commit count summary only. --e:: ---email:: +`-e`:: +`--email`:: Show the email address of each author. ---format[=]:: +`--format[=]`:: Instead of the commit subject, use some other information to - describe each commit. '' can be any string accepted - by the `--format` option of 'git log', such as '* [%h] %s'. - (See the "PRETTY FORMATS" section of linkgit:git-log[1].) + describe each commit. __ can be any string accepted + by the `--format` option of `git log`, such as '* [%h] %s'. + (See the 'PRETTY FORMATS' section of linkgit:git-log[1].) + Each pretty-printed commit will be rewrapped before it is shown. ---date=:: +`--date=`:: Show dates formatted according to the given date string. (See - the `--date` option in the "Commit Formatting" section of + the `--date` option in the 'Commit Formatting' section of linkgit:git-log[1]). Useful with `--group=format:`. ---group=:: - Group commits based on ``. If no `--group` option is - specified, the default is `author`. `` is one of: +`--group=`:: + Group commits based on __. If no `--group` option is + specified, the default is `author`. __ is one of: + -- - `author`, commits are grouped by author - `committer`, commits are grouped by committer (the same as `-c`) - - `trailer:`, the `` is interpreted as a case-insensitive + - `trailer:`, the __ is interpreted as a case-insensitive commit message trailer (see linkgit:git-interpret-trailers[1]). For example, if your project uses `Reviewed-by` trailers, you might want to see who has been reviewing with @@ -76,7 +76,7 @@ unless the `--email` option is specified. If the value cannot be parsed as an identity, it will be taken literally and completely. - `format:`, any string accepted by the `--format` option of - 'git log'. (See the "PRETTY FORMATS" section of + `git log`. (See the 'PRETTY FORMATS' section of linkgit:git-log[1].) -- + @@ -85,11 +85,11 @@ value (but again, only once per unique value in that commit). For example, `git shortlog --group=author --group=trailer:co-authored-by` counts both authors and co-authors. --c:: ---committer:: +`-c`:: +`--committer`:: This is an alias for `--group=committer`. --w[[,[,]]]:: +`-w[[,[,]]]`:: Linewrap the output by wrapping each line at `width`. The first line of each entry is indented by `indent1` spaces, and the second and subsequent lines are indented by `indent2` spaces. `width`, @@ -98,16 +98,16 @@ counts both authors and co-authors. If width is `0` (zero) then indent the lines of the output without wrapping them. -:: +``:: Show only commits in the specified revision range. When no - is specified, it defaults to `HEAD` (i.e. the + __ is specified, it defaults to `HEAD` (i.e. the whole history leading to the current commit). `origin..HEAD` specifies all the commits reachable from the current commit (i.e. `HEAD`), but not from `origin`. For a complete list of - ways to spell , see the "Specifying Ranges" + ways to spell __, see the 'Specifying Ranges' section of linkgit:gitrevisions[7]. -[--] ...:: +`[--] ...`:: Consider only commits that are enough to explain how the files that match the specified paths came to be. + From 80f4b802e964559c65b08641c07a8acb95d0617e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-No=C3=ABl=20Avila?= Date: Sat, 4 Apr 2026 17:12:47 +0000 Subject: [PATCH 013/608] doc: convert git-describe manual page to synopsis style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * convert commands and options to synopsis style * use __ for arguments Signed-off-by: Jean-Noël Avila Signed-off-by: Junio C Hamano --- Documentation/git-describe.adoc | 96 ++++++++++++++++----------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/Documentation/git-describe.adoc b/Documentation/git-describe.adoc index 08ff715709ccd1..b2cb1e47e46c67 100644 --- a/Documentation/git-describe.adoc +++ b/Documentation/git-describe.adoc @@ -7,10 +7,10 @@ git-describe - Give an object a human readable name based on an available ref SYNOPSIS -------- -[verse] -'git describe' [--all] [--tags] [--contains] [--abbrev=] [...] -'git describe' [--all] [--tags] [--contains] [--abbrev=] --dirty[=] -'git describe' +[synopsis] +git describe [--all] [--tags] [--contains] [--abbrev=] [...] +git describe [--all] [--tags] [--contains] [--abbrev=] --dirty[=] +git describe DESCRIPTION ----------- @@ -22,70 +22,70 @@ abbreviated object name of the most recent commit. The result is a "human-readable" object name which can also be used to identify the commit to other git commands. -By default (without --all or --tags) `git describe` only shows +By default (without `--all` or `--tags`) `git describe` only shows annotated tags. For more information about creating annotated tags -see the -a and -s options to linkgit:git-tag[1]. +see the `-a` and `-s` options to linkgit:git-tag[1]. If the given object refers to a blob, it will be described as `:`, such that the blob can be found -at `` in the ``, which itself describes the +at __ in the __, which itself describes the first commit in which this blob occurs in a reverse revision walk -from HEAD. +from `HEAD`. OPTIONS ------- -...:: - Commit-ish object names to describe. Defaults to HEAD if omitted. +`...`:: + Commit-ish object names to describe. Defaults to `HEAD` if omitted. ---dirty[=]:: ---broken[=]:: +`--dirty[=]`:: +`--broken[=]`:: Describe the state of the working tree. When the working - tree matches HEAD, the output is the same as "git describe - HEAD". If the working tree has local modification "-dirty" + tree matches `HEAD`, the output is the same as `git describe HEAD`. + If the working tree has local modification, `-dirty` is appended to it. If a repository is corrupt and Git cannot determine if there is local modification, Git will - error out, unless `--broken' is given, which appends - the suffix "-broken" instead. + error out, unless `--broken` is given, which appends + the suffix `-broken` instead. ---all:: +`--all`:: Instead of using only the annotated tags, use any ref found in `refs/` namespace. This option enables matching any known branch, remote-tracking branch, or lightweight tag. ---tags:: +`--tags`:: Instead of using only the annotated tags, use any tag found in `refs/tags` namespace. This option enables matching a lightweight (non-annotated) tag. ---contains:: +`--contains`:: Instead of finding the tag that predates the commit, find the tag that comes after the commit, and thus contains it. - Automatically implies --tags. + Automatically implies `--tags`. ---abbrev=:: +`--abbrev=`:: Instead of using the default number of hexadecimal digits (which will vary according to the number of objects in the repository with - a default of 7) of the abbreviated object name, use digits, or - as many digits as needed to form a unique object name. An of 0 + a default of 7) of the abbreviated object name, use __ digits, or + as many digits as needed to form a unique object name. An __ of 0 will suppress long format, only showing the closest tag. ---candidates=:: +`--candidates=`:: Instead of considering only the 10 most recent tags as candidates to describe the input commit-ish consider - up to candidates. Increasing above 10 will take + up to __ candidates. Increasing __ above 10 will take slightly longer but may produce a more accurate result. - An of 0 will cause only exact matches to be output. + An __ of 0 will cause only exact matches to be output. ---exact-match:: +`--exact-match`:: Only output exact matches (a tag directly references the - supplied commit). This is a synonym for --candidates=0. + supplied commit). This is a synonym for `--candidates=0`. ---debug:: +`--debug`:: Verbosely display information about the searching strategy being employed to standard error. The tag name will still be printed to standard out. ---long:: +`--long`:: Always output the long format (the tag, the number of commits and the abbreviated commit name) even when it matches a tag. This is useful when you want to see parts of the commit object name @@ -94,8 +94,8 @@ OPTIONS describe such a commit as v1.2-0-gdeadbee (0th commit since tag v1.2 that points at object deadbee....). ---match :: - Only consider tags matching the given `glob(7)` pattern, +`--match `:: + Only consider tags matching the given `glob`(7) pattern, excluding the "refs/tags/" prefix. If used with `--all`, it also considers local branches and remote-tracking references matching the pattern, excluding respectively "refs/heads/" and "refs/remotes/" @@ -104,22 +104,22 @@ OPTIONS matching any of the patterns will be considered. Use `--no-match` to clear and reset the list of patterns. ---exclude :: - Do not consider tags matching the given `glob(7)` pattern, excluding +`--exclude `:: + Do not consider tags matching the given `glob`(7) pattern, excluding the "refs/tags/" prefix. If used with `--all`, it also does not consider local branches and remote-tracking references matching the pattern, - excluding respectively "refs/heads/" and "refs/remotes/" prefix; + excluding respectively "`refs/heads/`" and "`refs/remotes/`" prefix; references of other types are never considered. If given multiple times, a list of patterns will be accumulated and tags matching any of the - patterns will be excluded. When combined with --match a tag will be - considered when it matches at least one --match pattern and does not - match any of the --exclude patterns. Use `--no-exclude` to clear and + patterns will be excluded. When combined with `--match` a tag will be + considered when it matches at least one `--match` pattern and does not + match any of the `--exclude` patterns. Use `--no-exclude` to clear and reset the list of patterns. ---always:: +`--always`:: Show uniquely abbreviated commit object as fallback. ---first-parent:: +`--first-parent`:: Follow only the first parent commit upon seeing a merge commit. This is useful when you wish to not match tags on branches merged in the history of the target commit. @@ -139,8 +139,8 @@ an abbreviated object name for the commit itself ("2414721") at the end. The number of additional commits is the number -of commits which would be displayed by "git log v1.0.4..parent". -The hash suffix is "-g" + an unambiguous abbreviation for the tip commit +of commits which would be displayed by `git log v1.0.4..parent`. +The hash suffix is "`-g`" + an unambiguous abbreviation for the tip commit of parent (which was `2414721b194453f058079d897d13c4e377f92dc6`). The length of the abbreviation scales as the repository grows, using the approximate number of objects in the repository and a bit of math @@ -149,12 +149,12 @@ The "g" prefix stands for "git" and is used to allow describing the version of a software depending on the SCM the software is managed with. This is useful in an environment where people may use different SCMs. -Doing a 'git describe' on a tag-name will just show the tag name: +Doing a `git describe` on a tag-name will just show the tag name: [torvalds@g5 git]$ git describe v1.0.4 v1.0.4 -With --all, the command can use branch heads as references, so +With `--all`, the command can use branch heads as references, so the output shows the reference path as well: [torvalds@g5 git]$ git describe --all --abbrev=4 v1.0.5^2 @@ -163,7 +163,7 @@ the output shows the reference path as well: [torvalds@g5 git]$ git describe --all --abbrev=4 HEAD^ heads/lt/describe-7-g975b -With --abbrev set to 0, the command can be used to find the +With `--abbrev` set to 0, the command can be used to find the closest tagname without any suffix: [torvalds@g5 git]$ git describe --abbrev=0 v1.0.5^2 @@ -179,13 +179,13 @@ be sufficient to disambiguate these commits. SEARCH STRATEGY --------------- -For each commit-ish supplied, 'git describe' will first look for +For each commit-ish supplied, `git describe` will first look for a tag which tags exactly that commit. Annotated tags will always be preferred over lightweight tags, and tags with newer dates will always be preferred over tags with older dates. If an exact match is found, its name will be output and searching will stop. -If an exact match was not found, 'git describe' will walk back +If an exact match was not found, `git describe` will walk back through the commit history to locate an ancestor commit which has been tagged. The ancestor's tag will be output along with an abbreviation of the input commit-ish's SHA-1. If `--first-parent` was @@ -203,7 +203,7 @@ BUGS Tree objects as well as tag objects not pointing at commits, cannot be described. When describing blobs, the lightweight tags pointing at blobs are ignored, -but the blob is still described as : despite the lightweight +but the blob is still described as `:` despite the lightweight tag being favorable. GIT From fa1468a1f7c7765a6c7dd1faca4c9dc241d0538c Mon Sep 17 00:00:00 2001 From: Trieu Huynh Date: Tue, 7 Apr 2026 03:30:41 +0900 Subject: [PATCH 014/608] promisor-remote: fix promisor.quiet to use the correct repository fetch_objects() reads the promisor.quiet configuration from the_repository instead of the repo parameter it receives. This means that when git lazy-fetches objects for a non-main repository, eg. a submodule that is itself a partial clone opened via repo_submodule_init(). The submodule's own promisor.quiet setting is ignored and the superproject's setting is used instead. Fix by replacing the_repository with repo in the repo_config_get_bool() call. The practical trigger is git grep --recurse-submodules on a superproject where the submodule is a partial clone. Add a test where promisor.quiet is set only in a partial-clone submodule; a lazy fetch triggered by "git grep --recurse-submodules" must honor that setting. Signed-off-by: Trieu Huynh Signed-off-by: Junio C Hamano --- promisor-remote.c | 2 +- t/t0410-partial-clone.sh | 45 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/promisor-remote.c b/promisor-remote.c index 96fa215b06a924..225260b05f8d65 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -46,7 +46,7 @@ static int fetch_objects(struct repository *repo, "fetch", remote_name, "--no-tags", "--no-write-fetch-head", "--recurse-submodules=no", "--filter=blob:none", "--stdin", NULL); - if (!repo_config_get_bool(the_repository, "promisor.quiet", &quiet) && quiet) + if (!repo_config_get_bool(repo, "promisor.quiet", &quiet) && quiet) strvec_push(&child.args, "--quiet"); if (start_command(&child)) die(_("promisor-remote: unable to fork off fetch subprocess")); diff --git a/t/t0410-partial-clone.sh b/t/t0410-partial-clone.sh index 52e19728a3fca0..dff442da2090b5 100755 --- a/t/t0410-partial-clone.sh +++ b/t/t0410-partial-clone.sh @@ -717,7 +717,29 @@ test_expect_success 'setup for promisor.quiet tests' ' git -C server rm foo.t && git -C server commit -m remove && git -C server config uploadpack.allowanysha1inwant 1 && - git -C server config uploadpack.allowfilter 1 + git -C server config uploadpack.allowfilter 1 && + + # Setup for submodule repo test: superproject whose submodule is a + # partial clone, so that promisor.quiet is read via a non-main repo. + rm -rf sub-pc-src sub-pc-srv.bare super-src super-work && + git init sub-pc-src && + test_commit -C sub-pc-src initial file.txt "hello" && + + git clone --bare sub-pc-src sub-pc-srv.bare && + git -C sub-pc-srv.bare config uploadpack.allowfilter 1 && + git -C sub-pc-srv.bare config uploadpack.allowanysha1inwant 1 && + + git init super-src && + git -C super-src -c protocol.file.allow=always \ + submodule add "file://$(pwd)/sub-pc-srv.bare" sub && + git -C super-src commit -m "add submodule" && + + git -c protocol.file.allow=always clone super-src super-work && + git -C super-work -c protocol.file.allow=always \ + submodule update --init --filter=blob:none sub && + + # Allow file:// in the submodule so that lazy-fetch subprocesses work. + git -C super-work/sub config protocol.file.allow always ' test_expect_success TTY 'promisor.quiet=false shows progress messages' ' @@ -752,6 +774,27 @@ test_expect_success TTY 'promisor.quiet=unconfigured shows progress messages' ' grep "Receiving objects" err ' +test_expect_success 'promisor.quiet from submodule repo is honored' ' + rm -f pc-quiet-trace && + + # Set promisor.quiet only in the submodule, not the superproject. + git -C super-work/sub config promisor.quiet true && + + # Push a new commit+blob to the server; the blob stays missing in the + # partial-clone submodule until a lazy fetch is triggered. + test_commit -C sub-pc-src updated new-file.txt "world" && + git -C sub-pc-src push "$(pwd)/sub-pc-srv.bare" HEAD:master && + git -C super-work/sub -c protocol.file.allow=always fetch origin && + git -C super-work/sub reset --mixed origin/master && + + # grep descends into the submodule and triggers a lazy fetch for the + # missing blob; verify the fetch subprocess carries --quiet. + GIT_TRACE2_EVENT="$(pwd)/pc-quiet-trace" \ + git -C super-work grep --cached --recurse-submodules "world" \ + 2>/dev/null && + grep negotiationAlgorithm pc-quiet-trace | grep -e --quiet +' + . "$TEST_DIRECTORY"/lib-httpd.sh start_httpd From 521731213c905f0dfec6a55393f010d185492c85 Mon Sep 17 00:00:00 2001 From: David Lin Date: Mon, 6 Apr 2026 15:27:11 -0400 Subject: [PATCH 015/608] cache-tree: fix inverted object existence check in cache_tree_fully_valid The negation in front of the object existence check in cache_tree_fully_valid() was lost in 062b914c84 (treewide: convert users of `repo_has_object_file()` to `has_object()`, 2025-04-29), turning `!repo_has_object_file(...)` into `has_object(...)` instead of `!has_object(...)`. This makes cache_tree_fully_valid() always report the cache tree as invalid when objects exist (the common case), forcing callers like write_index_as_tree() to call cache_tree_update() on every invocation. An odb_has_object() check inside update_one() avoids a full tree rebuild, but the unnecessary call still pays the cost of opening an ODB transaction and, in partial clones, a promisor remote check. Restore the missing negation and add a test that verifies write-tree takes the cache-tree shortcut when the cache tree is valid. Helped-by: Derrick Stolee Signed-off-by: David Lin Signed-off-by: Junio C Hamano --- cache-tree.c | 2 +- t/t0090-cache-tree.sh | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cache-tree.c b/cache-tree.c index 66ef2becbe01a4..366b1d7dcd8081 100644 --- a/cache-tree.c +++ b/cache-tree.c @@ -239,7 +239,7 @@ int cache_tree_fully_valid(struct cache_tree *it) if (!it) return 0; if (it->entry_count < 0 || - odb_has_object(the_repository->objects, &it->oid, + !odb_has_object(the_repository->objects, &it->oid, HAS_OBJECT_RECHECK_PACKED | HAS_OBJECT_FETCH_PROMISOR)) return 0; for (i = 0; i < it->subtree_nr; i++) { diff --git a/t/t0090-cache-tree.sh b/t/t0090-cache-tree.sh index d901588294668c..0964718d7f33f5 100755 --- a/t/t0090-cache-tree.sh +++ b/t/t0090-cache-tree.sh @@ -278,4 +278,12 @@ test_expect_success 'switching trees does not invalidate shared index' ' ) ' +test_expect_success 'cache-tree is used by write-tree when valid' ' + test_commit use-valid && + + # write-tree with a valid cache-tree should skip cache_tree_update + GIT_TRACE2_PERF="$(pwd)/trace.output" git write-tree && + test_grep ! region_enter.*cache_tree.*update trace.output +' + test_done From 8808e61fd3e953c3534633b8b5adc5b243dd696f Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:34 +0200 Subject: [PATCH 016/608] promisor-remote: try accepted remotes before others in get_direct() When a server advertises promisor remotes and the client accepts some of them, those remotes carry the server's intent: 'fetch missing objects preferably from here', and the client agrees with that for the remotes it accepts. However promisor_remote_get_direct() actually iterates over all promisor remotes in list order, which is the order they appear in the config files (except perhaps for the one appearing in the `extensions.partialClone` config variable which is tried last). This means an existing, but not accepted, promisor remote, could be tried before the accepted ones, which does not reflect the intent of the agreement between client and server. If the client doesn't care about what the server suggests, it should accept nothing and rely on its remotes as they are already configured. To better reflect the agreement between client and server, let's make promisor_remote_get_direct() try the accepted promisor remotes before the non-accepted ones. Concretely, let's extract a try_promisor_remotes() helper and call it twice from promisor_remote_get_direct(): - first with an `accepted_only=true` argument to try only the accepted remotes, - then with `accepted_only=false` to fall back to any remaining remote. Ensuring that accepted remotes are preferred will be even more important if in the future a mechanism is developed to allow the client to auto-configure remotes that the server advertises. This will in particular avoid fetching from the server (which is already configured as a promisor remote) before trying the auto-configured remotes, as these new remotes would likely appear at the end of the config file, and as the server might not appear in the `extensions.partialClone` config variable. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- Documentation/gitprotocol-v2.adoc | 4 ++ promisor-remote.c | 44 ++++++++++++----- t/t5710-promisor-remote-capability.sh | 69 +++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/Documentation/gitprotocol-v2.adoc b/Documentation/gitprotocol-v2.adoc index f985cb4c474953..4fcb1a7bda1be7 100644 --- a/Documentation/gitprotocol-v2.adoc +++ b/Documentation/gitprotocol-v2.adoc @@ -848,6 +848,10 @@ advertised, it can reply with "promisor-remote=" where where `pr-name` is the urlencoded name of a promisor remote the server advertised and the client accepts. +The promisor remotes that the client accepted will be tried before the +other configured promisor remotes when the client attempts to fetch +missing objects. + Note that, everywhere in this document, the ';' and ',' characters MUST be encoded if they appear in `pr-name` or `field-value`. diff --git a/promisor-remote.c b/promisor-remote.c index 96fa215b06a924..7ce7d22f952e70 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -268,11 +268,35 @@ static int remove_fetched_oids(struct repository *repo, return remaining_nr; } +static int try_promisor_remotes(struct repository *repo, + struct object_id **remaining_oids, + int *remaining_nr, int *to_free, + bool accepted_only) +{ + struct promisor_remote *r = repo->promisor_remote_config->promisors; + + for (; r; r = r->next) { + if (accepted_only != r->accepted) + continue; + if (fetch_objects(repo, r->name, *remaining_oids, *remaining_nr) < 0) { + if (*remaining_nr == 1) + continue; + *remaining_nr = remove_fetched_oids(repo, remaining_oids, + *remaining_nr, *to_free); + if (*remaining_nr) { + *to_free = 1; + continue; + } + } + return 1; /* all fetched */ + } + return 0; +} + void promisor_remote_get_direct(struct repository *repo, const struct object_id *oids, int oid_nr) { - struct promisor_remote *r; struct object_id *remaining_oids = (struct object_id *)oids; int remaining_nr = oid_nr; int to_free = 0; @@ -283,19 +307,13 @@ void promisor_remote_get_direct(struct repository *repo, promisor_remote_init(repo); - for (r = repo->promisor_remote_config->promisors; r; r = r->next) { - if (fetch_objects(repo, r->name, remaining_oids, remaining_nr) < 0) { - if (remaining_nr == 1) - continue; - remaining_nr = remove_fetched_oids(repo, &remaining_oids, - remaining_nr, to_free); - if (remaining_nr) { - to_free = 1; - continue; - } - } + /* Try accepted remotes first (those the server told us to use) */ + if (try_promisor_remotes(repo, &remaining_oids, &remaining_nr, + &to_free, true)) + goto all_fetched; + if (try_promisor_remotes(repo, &remaining_oids, &remaining_nr, + &to_free, false)) goto all_fetched; - } for (i = 0; i < remaining_nr; i++) { if (is_promisor_object(repo, &remaining_oids[i])) diff --git a/t/t5710-promisor-remote-capability.sh b/t/t5710-promisor-remote-capability.sh index 357822c01a7530..bf0eed9f109742 100755 --- a/t/t5710-promisor-remote-capability.sh +++ b/t/t5710-promisor-remote-capability.sh @@ -166,6 +166,75 @@ test_expect_success "init + fetch with promisor.advertise set to 'true'" ' check_missing_objects server 1 "$oid" ' +test_expect_success "clone with two promisors but only one advertised" ' + git -C server config promisor.advertise true && + test_when_finished "rm -rf client unused_lop" && + + # Create a promisor that will be configured but not be used + git init --bare unused_lop && + + # Clone from server to create a client + GIT_TRACE="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git clone \ + -c remote.unused_lop.promisor=true \ + -c remote.unused_lop.fetch="+refs/heads/*:refs/remotes/unused_lop/*" \ + -c remote.unused_lop.url="file://$(pwd)/unused_lop" \ + -c remote.lop.promisor=true \ + -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ + -c remote.lop.url="file://$(pwd)/lop" \ + -c promisor.acceptfromserver=All \ + --no-local --filter="blob:limit=5k" server client && + + # Check that "unused_lop" appears before "lop" in the config + printf "remote.%s.promisor true\n" "unused_lop" "lop" "origin" >expect && + git -C client config get --all --show-names --regexp "^remote\..*\.promisor$" >actual && + test_cmp expect actual && + + # Check that "lop" was tried + test_grep " fetch lop " trace && + # Check that "unused_lop" was not contacted + # This means "lop", the accepted promisor, was tried first + test_grep ! " fetch unused_lop " trace && + + # Check that the largest object is still missing on the server + check_missing_objects server 1 "$oid" +' + +test_expect_success "init + fetch two promisors but only one advertised" ' + git -C server config promisor.advertise true && + test_when_finished "rm -rf client unused_lop" && + + # Create a promisor that will be configured but not be used + git init --bare unused_lop && + + mkdir client && + git -C client init && + git -C client config remote.unused_lop.promisor true && + git -C client config remote.unused_lop.fetch "+refs/heads/*:refs/remotes/unused_lop/*" && + git -C client config remote.unused_lop.url "file://$(pwd)/unused_lop" && + git -C client config remote.lop.promisor true && + git -C client config remote.lop.fetch "+refs/heads/*:refs/remotes/lop/*" && + git -C client config remote.lop.url "file://$(pwd)/lop" && + git -C client config remote.server.url "file://$(pwd)/server" && + git -C client config remote.server.fetch "+refs/heads/*:refs/remotes/server/*" && + git -C client config promisor.acceptfromserver All && + + # Check that "unused_lop" appears before "lop" in the config + printf "remote.%s.promisor true\n" "unused_lop" "lop" >expect && + git -C client config get --all --show-names --regexp "^remote\..*\.promisor$" >actual && + test_cmp expect actual && + + GIT_TRACE="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git -C client fetch --filter="blob:limit=5k" server && + + # Check that "lop" was tried + test_grep " fetch lop " trace && + # Check that "unused_lop" was not contacted + # This means "lop", the accepted promisor, was tried first + test_grep ! " fetch unused_lop " trace && + + # Check that the largest object is still missing on the server + check_missing_objects server 1 "$oid" +' + test_expect_success "clone with promisor.acceptfromserver set to 'KnownName'" ' git -C server config promisor.advertise true && test_when_finished "rm -rf client" && From 720b7c26c82ef212852897bedb0d38eee78cb531 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:35 +0200 Subject: [PATCH 017/608] promisor-remote: pass config entry to all_fields_match() directly The `in_list == 0` path of all_fields_match() looks up the remote in `config_info` by `advertised->name` repeatedly, even though every caller in should_accept_remote() has already performed this lookup and holds the result in `p`. To avoid this useless work, let's replace the `int in_list` parameter with a `struct promisor_info *config_entry` pointer: - When NULL (ACCEPT_ALL mode): scan the whole `config_info` list, as the old `in_list == 1` path did. - When non-NULL: match against that single config entry directly, avoiding the redundant string_list_lookup() call. This removes the hidden dependency on `advertised->name` inside all_fields_match(), which would be wrong if in the future auto-configured remotes are implemented, as the local config name may differ from the server's advertised name. While at it, let's also add a comment before all_fields_match() and match_field_against_config() to help understand how things work and help avoid similar issues. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- promisor-remote.c | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/promisor-remote.c b/promisor-remote.c index 7ce7d22f952e70..6c935f855af752 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -575,6 +575,12 @@ enum accept_promisor { ACCEPT_ALL }; +/* + * Check if a specific field and its advertised value match the local + * configuration of a given promisor remote. + * + * Returns 1 if they match, 0 otherwise. + */ static int match_field_against_config(const char *field, const char *value, struct promisor_info *config_info) { @@ -586,9 +592,18 @@ static int match_field_against_config(const char *field, const char *value, return 0; } +/* + * Check that the advertised fields match the local configuration. + * + * When 'config_entry' is NULL (ACCEPT_ALL mode), every checked field + * must match at least one remote in 'config_info'. + * + * When 'config_entry' points to a specific remote's config, the + * checked fields are compared against that single remote only. + */ static int all_fields_match(struct promisor_info *advertised, struct string_list *config_info, - int in_list) + struct promisor_info *config_entry) { struct string_list *fields = fields_checked(); struct string_list_item *item_checked; @@ -597,7 +612,6 @@ static int all_fields_match(struct promisor_info *advertised, int match = 0; const char *field = item_checked->string; const char *value = NULL; - struct string_list_item *item; if (!strcasecmp(field, promisor_field_filter)) value = advertised->filter; @@ -607,7 +621,11 @@ static int all_fields_match(struct promisor_info *advertised, if (!value) return 0; - if (in_list) { + if (config_entry) { + match = match_field_against_config(field, value, + config_entry); + } else { + struct string_list_item *item; for_each_string_list_item(item, config_info) { struct promisor_info *p = item->util; if (match_field_against_config(field, value, p)) { @@ -615,12 +633,6 @@ static int all_fields_match(struct promisor_info *advertised, break; } } - } else { - item = string_list_lookup(config_info, advertised->name); - if (item) { - struct promisor_info *p = item->util; - match = match_field_against_config(field, value, p); - } } if (!match) @@ -640,7 +652,7 @@ static int should_accept_remote(enum accept_promisor accept, const char *remote_url = advertised->url; if (accept == ACCEPT_ALL) - return all_fields_match(advertised, config_info, 1); + return all_fields_match(advertised, config_info, NULL); /* Get config info for that promisor remote */ item = string_list_lookup(config_info, remote_name); @@ -652,7 +664,7 @@ static int should_accept_remote(enum accept_promisor accept, p = item->util; if (accept == ACCEPT_KNOWN_NAME) - return all_fields_match(advertised, config_info, 0); + return all_fields_match(advertised, config_info, p); if (accept != ACCEPT_KNOWN_URL) BUG("Unhandled 'enum accept_promisor' value '%d'", accept); @@ -663,7 +675,7 @@ static int should_accept_remote(enum accept_promisor accept, } if (!strcmp(p->url, remote_url)) - return all_fields_match(advertised, config_info, 0); + return all_fields_match(advertised, config_info, p); warning(_("known remote named '%s' but with URL '%s' instead of '%s'"), remote_name, p->url, remote_url); From 4ed9283b36bc8652954578c3024a00b6e70f8960 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:36 +0200 Subject: [PATCH 018/608] promisor-remote: clarify that a remote is ignored In should_accept_remote() and parse_one_advertised_remote(), when a remote is ignored, we tell users why it is ignored in a warning, but we don't tell them that the remote is actually ignored. Let's clarify that, so users have a better idea of what's actually happening. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- promisor-remote.c | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/promisor-remote.c b/promisor-remote.c index 6c935f855af752..8e062ec16098ac 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -670,15 +670,16 @@ static int should_accept_remote(enum accept_promisor accept, BUG("Unhandled 'enum accept_promisor' value '%d'", accept); if (!remote_url || !*remote_url) { - warning(_("no or empty URL advertised for remote '%s'"), remote_name); + warning(_("no or empty URL advertised for remote '%s', " + "ignoring this remote"), remote_name); return 0; } if (!strcmp(p->url, remote_url)) return all_fields_match(advertised, config_info, p); - warning(_("known remote named '%s' but with URL '%s' instead of '%s'"), - remote_name, p->url, remote_url); + warning(_("known remote named '%s' but with URL '%s' instead of '%s', " + "ignoring this remote"), remote_name, p->url, remote_url); return 0; } @@ -722,8 +723,8 @@ static struct promisor_info *parse_one_advertised_remote(const char *remote_info string_list_clear(&elem_list, 0); if (!info->name || !info->url) { - warning(_("server advertised a promisor remote without a name or URL: %s"), - remote_info); + warning(_("server advertised a promisor remote without a name or URL: '%s', " + "ignoring this remote"), remote_info); promisor_info_free(info); return NULL; } From 3b4f0403d19738a26f0da58f4efc6f4e2473fcac Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:37 +0200 Subject: [PATCH 019/608] promisor-remote: reject empty name or URL in advertised remote In parse_one_advertised_remote(), we check for a NULL remote name and remote URL, but not for empty ones. An empty URL seems possible as url_percent_decode("") doesn't return NULL. In promisor_config_info_list(), we ignore remotes with empty URLs, so a Git server should not advertise remotes with empty URLs. It's possible that a buggy or malicious server would do it though. So let's tighten the check in parse_one_advertised_remote() to also reject empty strings at parse time. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- promisor-remote.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/promisor-remote.c b/promisor-remote.c index 8e062ec16098ac..8322349ae8ba87 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -722,7 +722,7 @@ static struct promisor_info *parse_one_advertised_remote(const char *remote_info string_list_clear(&elem_list, 0); - if (!info->name || !info->url) { + if (!info->name || !*info->name || !info->url || !*info->url) { warning(_("server advertised a promisor remote without a name or URL: '%s', " "ignoring this remote"), remote_info); promisor_info_free(info); From 64f0f6b88aea33546afd1271862b486fafe7e9cc Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:38 +0200 Subject: [PATCH 020/608] promisor-remote: refactor should_accept_remote() control flow A previous commit made sure we now reject empty URLs early at parse time. This makes the existing warning() in case a remote URL is NULL or empty very unlikely to be useful. In future work, we also plan to add URL-based acceptance logic into should_accept_remote(). To adapt to previous changes and prepare for upcoming changes, let's restructure the control flow in should_accept_remote(). Concretely, let's: - Replace the warning() in case of an empty URL with a BUG(), as a previous commit made sure empty URLs are rejected early at parse time. - Move that modified empty-URL check to the very top of the function, so that every acceptance mode, instead of only ACCEPT_KNOWN_URL, is covered. - Invert the URL comparison: instead of returning on match and warning on mismatch, return early on mismatch and let the match case fall through. This opens a single exit path at the bottom of the function for future commits to extend. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- promisor-remote.c | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/promisor-remote.c b/promisor-remote.c index 8322349ae8ba87..5860a3d3f36e09 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -651,6 +651,11 @@ static int should_accept_remote(enum accept_promisor accept, const char *remote_name = advertised->name; const char *remote_url = advertised->url; + if (!remote_url || !*remote_url) + BUG("no or empty URL advertised for remote '%s'; " + "this remote should have been rejected earlier", + remote_name); + if (accept == ACCEPT_ALL) return all_fields_match(advertised, config_info, NULL); @@ -669,19 +674,14 @@ static int should_accept_remote(enum accept_promisor accept, if (accept != ACCEPT_KNOWN_URL) BUG("Unhandled 'enum accept_promisor' value '%d'", accept); - if (!remote_url || !*remote_url) { - warning(_("no or empty URL advertised for remote '%s', " - "ignoring this remote"), remote_name); + if (strcmp(p->url, remote_url)) { + warning(_("known remote named '%s' but with URL '%s' instead of '%s', " + "ignoring this remote"), + remote_name, p->url, remote_url); return 0; } - if (!strcmp(p->url, remote_url)) - return all_fields_match(advertised, config_info, p); - - warning(_("known remote named '%s' but with URL '%s' instead of '%s', " - "ignoring this remote"), remote_name, p->url, remote_url); - - return 0; + return all_fields_match(advertised, config_info, p); } static int skip_field_name_prefix(const char *elem, const char *field_name, const char **value) From 16a4372a3df7579429b7bc23e984bd797a4b7b8d Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:39 +0200 Subject: [PATCH 021/608] promisor-remote: refactor has_control_char() In a future commit we are going to check if some strings contain control characters, so let's refactor the logic to do that in a new has_control_char() helper function. It cleans up the code a bit anyway. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- promisor-remote.c | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/promisor-remote.c b/promisor-remote.c index 5860a3d3f36e09..d60518f19c053e 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -642,6 +642,14 @@ static int all_fields_match(struct promisor_info *advertised, return 1; } +static bool has_control_char(const char *s) +{ + for (const char *c = s; *c; c++) + if (iscntrl(*c)) + return true; + return false; +} + static int should_accept_remote(enum accept_promisor accept, struct promisor_info *advertised, struct string_list *config_info) @@ -772,18 +780,14 @@ static bool valid_filter(const char *filter, const char *remote_name) return !res; } -/* Check that a token doesn't contain any control character */ static bool valid_token(const char *token, const char *remote_name) { - const char *c = token; - - for (; *c; c++) - if (iscntrl(*c)) { - warning(_("invalid token '%s' for remote '%s' " - "will not be stored"), - token, remote_name); - return false; - } + if (has_control_char(token)) { + warning(_("invalid token '%s' for remote '%s' " + "will not be stored"), + token, remote_name); + return false; + } return true; } From 7557a562434804d27f1417fe94c4081e2ee7e68b Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:40 +0200 Subject: [PATCH 022/608] promisor-remote: refactor accept_from_server() In future commits, we are going to add more logic to filter_promisor_remote() which is already doing a lot of things. Let's alleviate that by moving the logic that checks and validates the value of the `promisor.acceptFromServer` config variable into its own accept_from_server() helper function. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- promisor-remote.c | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/promisor-remote.c b/promisor-remote.c index d60518f19c053e..8d80ef6040534c 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -862,20 +862,12 @@ static bool promisor_store_advertised_fields(struct promisor_info *advertised, return reload_config; } -static void filter_promisor_remote(struct repository *repo, - struct strvec *accepted, - const char *info) +static enum accept_promisor accept_from_server(struct repository *repo) { const char *accept_str; enum accept_promisor accept = ACCEPT_NONE; - struct string_list config_info = STRING_LIST_INIT_NODUP; - struct string_list remote_info = STRING_LIST_INIT_DUP; - struct store_info *store_info = NULL; - struct string_list_item *item; - bool reload_config = false; - struct string_list accepted_filters = STRING_LIST_INIT_DUP; - if (!repo_config_get_string_tmp(the_repository, "promisor.acceptfromserver", &accept_str)) { + if (!repo_config_get_string_tmp(repo, "promisor.acceptfromserver", &accept_str)) { if (!*accept_str || !strcasecmp("None", accept_str)) accept = ACCEPT_NONE; else if (!strcasecmp("KnownUrl", accept_str)) @@ -889,6 +881,21 @@ static void filter_promisor_remote(struct repository *repo, accept_str, "promisor.acceptfromserver"); } + return accept; +} + +static void filter_promisor_remote(struct repository *repo, + struct strvec *accepted, + const char *info) +{ + struct string_list config_info = STRING_LIST_INIT_NODUP; + struct string_list remote_info = STRING_LIST_INIT_DUP; + struct store_info *store_info = NULL; + struct string_list_item *item; + bool reload_config = false; + struct string_list accepted_filters = STRING_LIST_INIT_DUP; + enum accept_promisor accept = accept_from_server(repo); + if (accept == ACCEPT_NONE) return; From e0f80d8876960442dd2645215c4fe5e1b1d80fc3 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:41 +0200 Subject: [PATCH 023/608] promisor-remote: keep accepted promisor_info structs alive In filter_promisor_remote(), the instances of `struct promisor_info` for accepted remotes are dismantled into separate parallel data structures (the 'accepted' strvec for server names, and 'accepted_filters' for filter strings) and then immediately freed. Instead, let's keep these instances on an 'accepted_remotes' list. This way the post-loop phase can iterate a single list to build the protocol reply, apply advertised filters, and mark remotes as accepted, rather than iterating three separate structures. This refactoring also prepares for a future commit that will add a 'local_name' member to 'struct promisor_info'. Since struct instances stay alive, downstream code will be able to simply read both names from them rather than needing yet another parallel strvec. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- promisor-remote.c | 42 +++++++++++++++++------------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/promisor-remote.c b/promisor-remote.c index 8d80ef6040534c..74e65e9dd0de48 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -890,10 +890,10 @@ static void filter_promisor_remote(struct repository *repo, { struct string_list config_info = STRING_LIST_INIT_NODUP; struct string_list remote_info = STRING_LIST_INIT_DUP; + struct string_list accepted_remotes = STRING_LIST_INIT_NODUP; struct store_info *store_info = NULL; struct string_list_item *item; bool reload_config = false; - struct string_list accepted_filters = STRING_LIST_INIT_DUP; enum accept_promisor accept = accept_from_server(repo); if (accept == ACCEPT_NONE) @@ -922,17 +922,10 @@ static void filter_promisor_remote(struct repository *repo, if (promisor_store_advertised_fields(advertised, store_info)) reload_config = true; - strvec_push(accepted, advertised->name); - - /* Capture advertised filters for accepted remotes */ - if (advertised->filter) { - struct string_list_item *i; - i = string_list_append(&accepted_filters, advertised->name); - i->util = xstrdup(advertised->filter); - } + string_list_append(&accepted_remotes, advertised->name)->util = advertised; + } else { + promisor_info_free(advertised); } - - promisor_info_free(advertised); } promisor_info_list_clear(&config_info); @@ -942,24 +935,23 @@ static void filter_promisor_remote(struct repository *repo, if (reload_config) repo_promisor_remote_reinit(repo); - /* Apply accepted remote filters to the stable repo state */ - for_each_string_list_item(item, &accepted_filters) { - struct promisor_remote *r = repo_promisor_remote_find(repo, item->string); - if (r) { - free(r->advertised_filter); - r->advertised_filter = item->util; - item->util = NULL; - } - } + /* Apply accepted remotes to the stable repo state */ + for_each_string_list_item(item, &accepted_remotes) { + struct promisor_info *info = item->util; + struct promisor_remote *r = repo_promisor_remote_find(repo, info->name); - string_list_clear(&accepted_filters, 1); + strvec_push(accepted, info->name); - /* Mark the remotes as accepted in the repository state */ - for (size_t i = 0; i < accepted->nr; i++) { - struct promisor_remote *r = repo_promisor_remote_find(repo, accepted->v[i]); - if (r) + if (r) { r->accepted = 1; + if (info->filter) { + free(r->advertised_filter); + r->advertised_filter = xstrdup(info->filter); + } + } } + + promisor_info_list_clear(&accepted_remotes); } void promisor_remote_reply(const char *info, char **accepted_out) From d56e483b03bfe46340af5cdbcddec8858661d2e9 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:42 +0200 Subject: [PATCH 024/608] promisor-remote: remove the 'accepted' strvec In a previous commit, filter_promisor_remote() was refactored to keep accepted 'struct promisor_info' instances alive instead of dismantling them into separate parallel data structures. Let's go one step further and replace the 'struct strvec *accepted' argument passed to filter_promisor_remote() with a 'struct string_list *accepted_remotes' argument. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- promisor-remote.c | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/promisor-remote.c b/promisor-remote.c index 74e65e9dd0de48..38fa05054227f6 100644 --- a/promisor-remote.c +++ b/promisor-remote.c @@ -885,12 +885,11 @@ static enum accept_promisor accept_from_server(struct repository *repo) } static void filter_promisor_remote(struct repository *repo, - struct strvec *accepted, + struct string_list *accepted_remotes, const char *info) { struct string_list config_info = STRING_LIST_INIT_NODUP; struct string_list remote_info = STRING_LIST_INIT_DUP; - struct string_list accepted_remotes = STRING_LIST_INIT_NODUP; struct store_info *store_info = NULL; struct string_list_item *item; bool reload_config = false; @@ -922,7 +921,7 @@ static void filter_promisor_remote(struct repository *repo, if (promisor_store_advertised_fields(advertised, store_info)) reload_config = true; - string_list_append(&accepted_remotes, advertised->name)->util = advertised; + string_list_append(accepted_remotes, advertised->name)->util = advertised; } else { promisor_info_free(advertised); } @@ -936,12 +935,10 @@ static void filter_promisor_remote(struct repository *repo, repo_promisor_remote_reinit(repo); /* Apply accepted remotes to the stable repo state */ - for_each_string_list_item(item, &accepted_remotes) { + for_each_string_list_item(item, accepted_remotes) { struct promisor_info *info = item->util; struct promisor_remote *r = repo_promisor_remote_find(repo, info->name); - strvec_push(accepted, info->name); - if (r) { r->accepted = 1; if (info->filter) { @@ -950,23 +947,23 @@ static void filter_promisor_remote(struct repository *repo, } } } - - promisor_info_list_clear(&accepted_remotes); } void promisor_remote_reply(const char *info, char **accepted_out) { - struct strvec accepted = STRVEC_INIT; + struct string_list accepted_remotes = STRING_LIST_INIT_NODUP; - filter_promisor_remote(the_repository, &accepted, info); + filter_promisor_remote(the_repository, &accepted_remotes, info); if (accepted_out) { - if (accepted.nr) { + if (accepted_remotes.nr) { struct strbuf reply = STRBUF_INIT; - for (size_t i = 0; i < accepted.nr; i++) { - if (i) + struct string_list_item *item; + + for_each_string_list_item(item, &accepted_remotes) { + if (reply.len) strbuf_addch(&reply, ';'); - strbuf_addstr_urlencode(&reply, accepted.v[i], allow_unsanitized); + strbuf_addstr_urlencode(&reply, item->string, allow_unsanitized); } *accepted_out = strbuf_detach(&reply, NULL); } else { @@ -974,7 +971,7 @@ void promisor_remote_reply(const char *info, char **accepted_out) } } - strvec_clear(&accepted); + promisor_info_list_clear(&accepted_remotes); } void mark_promisor_remotes_as_accepted(struct repository *r, const char *remotes) From 8eb863597f630efe08f96ed12f8defbe5a5f0b1d Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Tue, 7 Apr 2026 13:52:43 +0200 Subject: [PATCH 025/608] t5710: use proper file:// URIs for absolute paths In t5710, we frequently construct local file URIs using `file://$(pwd)`. On Unix-like systems, $(pwd) returns an absolute path starting with a slash (e.g., `/tmp/repo`), resulting in a valid 3-slash URI with an empty host (`file:///tmp/repo`). However, on Windows, $(pwd) returns a path starting with a drive letter (e.g., `D:/a/repo`). This results in a 2-slash URI (`file://D:/a/repo`). Standard URI parsers misinterpret this format, treating `D:` as the host rather than part of the absolute path. This is to be expected because RFC 8089 says that the `//` prefix with an empty local host must be followed by an absolute path starting with a slash. While this hasn't broken the existing tests (because the old `promisor.acceptFromServer` logic relies entirely on strict `strcmp()` without normalizing the URLs), it will break future commits that pass these URLs through `url_normalize()` or similar functions. To future-proof the tests and ensure cross-platform URI compliance, let's introduce a $TRASH_DIRECTORY_URL helper variable that explicitly guarantees a leading slash for the path component, ensuring valid 3-slash `file:///` URIs on all operating systems. While at it, let's also introduce $ENCODED_TRASH_DIRECTORY_URL to handle some common special characters in directory paths. To be extra safe, let's skip all the tests if there are uncommon special characters in the directory path. Then let's replace all instances of `file://$(pwd)` with $TRASH_DIRECTORY_URL across the test script, and let's simplify the `sendFields` and `checkFields` tests to use $ENCODED_TRASH_DIRECTORY_URL directly. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- t/t5710-promisor-remote-capability.sh | 79 +++++++++++++++++---------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/t/t5710-promisor-remote-capability.sh b/t/t5710-promisor-remote-capability.sh index bf0eed9f109742..b404ad9f0a9e3d 100755 --- a/t/t5710-promisor-remote-capability.sh +++ b/t/t5710-promisor-remote-capability.sh @@ -76,6 +76,31 @@ copy_to_lop () { cp "$path" "$path2" } +# On Windows, `pwd` returns a path like 'D:/foo/bar'. Prepend '/' to turn +# it into '/D:/foo/bar', which is what git expects in file:// URLs on Windows. +# On Unix, the path already starts with '/', so this is a no-op. +pwd_path=$(pwd) +case "$pwd_path" in +[a-zA-Z]:*) pwd_path="/$pwd_path" ;; +esac + +# Allowed characters: alphanumeric, standard path/URI (_ . ~ / : -), +# and those percent-encoded below (% space = , ;) +rest=$(printf "%s" "$pwd_path" | tr -d 'a-zA-Z0-9_.~/:% =,;-') +if test -n "$rest" +then + skip_all="PWD contains unsupported special characters" + test_done +fi + +TRASH_DIRECTORY_URL="file://$pwd_path" + +encoded_path=$(printf "%s" "$pwd_path" | + sed -e 's/%/%25/g' -e 's/ /%20/g' -e 's/=/%3D/g' \ + -e 's/;/%3B/g' -e 's/,/%2C/g') + +ENCODED_TRASH_DIRECTORY_URL="file://$encoded_path" + test_expect_success "setup for testing promisor remote advertisement" ' # Create another bare repo called "lop" (for Large Object Promisor) git init --bare lop && @@ -88,7 +113,7 @@ test_expect_success "setup for testing promisor remote advertisement" ' initialize_server 1 "$oid" && # Configure lop as promisor remote for server - git -C server remote add lop "file://$(pwd)/lop" && + git -C server remote add lop "$TRASH_DIRECTORY_URL/lop" && git -C server config remote.lop.promisor true && git -C lop config uploadpack.allowFilter true && @@ -104,7 +129,7 @@ test_expect_success "clone with promisor.advertise set to 'true'" ' # Clone from server to create a client GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=All \ --no-local --filter="blob:limit=5k" server client && @@ -119,7 +144,7 @@ test_expect_success "clone with promisor.advertise set to 'false'" ' # Clone from server to create a client GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=All \ --no-local --filter="blob:limit=5k" server client && @@ -137,7 +162,7 @@ test_expect_success "clone with promisor.acceptfromserver set to 'None'" ' # Clone from server to create a client GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=None \ --no-local --filter="blob:limit=5k" server client && @@ -156,8 +181,8 @@ test_expect_success "init + fetch with promisor.advertise set to 'true'" ' git -C client init && git -C client config remote.lop.promisor true && git -C client config remote.lop.fetch "+refs/heads/*:refs/remotes/lop/*" && - git -C client config remote.lop.url "file://$(pwd)/lop" && - git -C client config remote.server.url "file://$(pwd)/server" && + git -C client config remote.lop.url "$TRASH_DIRECTORY_URL/lop" && + git -C client config remote.server.url "$TRASH_DIRECTORY_URL/server" && git -C client config remote.server.fetch "+refs/heads/*:refs/remotes/server/*" && git -C client config promisor.acceptfromserver All && GIT_NO_LAZY_FETCH=0 git -C client fetch --filter="blob:limit=5k" server && @@ -177,10 +202,10 @@ test_expect_success "clone with two promisors but only one advertised" ' GIT_TRACE="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git clone \ -c remote.unused_lop.promisor=true \ -c remote.unused_lop.fetch="+refs/heads/*:refs/remotes/unused_lop/*" \ - -c remote.unused_lop.url="file://$(pwd)/unused_lop" \ + -c remote.unused_lop.url="$TRASH_DIRECTORY_URL/unused_lop" \ -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=All \ --no-local --filter="blob:limit=5k" server client && @@ -210,11 +235,11 @@ test_expect_success "init + fetch two promisors but only one advertised" ' git -C client init && git -C client config remote.unused_lop.promisor true && git -C client config remote.unused_lop.fetch "+refs/heads/*:refs/remotes/unused_lop/*" && - git -C client config remote.unused_lop.url "file://$(pwd)/unused_lop" && + git -C client config remote.unused_lop.url "$TRASH_DIRECTORY_URL/unused_lop" && git -C client config remote.lop.promisor true && git -C client config remote.lop.fetch "+refs/heads/*:refs/remotes/lop/*" && - git -C client config remote.lop.url "file://$(pwd)/lop" && - git -C client config remote.server.url "file://$(pwd)/server" && + git -C client config remote.lop.url "$TRASH_DIRECTORY_URL/lop" && + git -C client config remote.server.url "$TRASH_DIRECTORY_URL/server" && git -C client config remote.server.fetch "+refs/heads/*:refs/remotes/server/*" && git -C client config promisor.acceptfromserver All && @@ -242,7 +267,7 @@ test_expect_success "clone with promisor.acceptfromserver set to 'KnownName'" ' # Clone from server to create a client GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=KnownName \ --no-local --filter="blob:limit=5k" server client && @@ -257,7 +282,7 @@ test_expect_success "clone with 'KnownName' and different remote names" ' # Clone from server to create a client GIT_NO_LAZY_FETCH=0 git clone -c remote.serverTwo.promisor=true \ -c remote.serverTwo.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.serverTwo.url="file://$(pwd)/lop" \ + -c remote.serverTwo.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=KnownName \ --no-local --filter="blob:limit=5k" server client && @@ -294,7 +319,7 @@ test_expect_success "clone with promisor.acceptfromserver set to 'KnownUrl'" ' # Clone from server to create a client GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=KnownUrl \ --no-local --filter="blob:limit=5k" server client && @@ -311,7 +336,7 @@ test_expect_success "clone with 'KnownUrl' and different remote urls" ' # Clone from server to create a client GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/serverTwo" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/serverTwo" \ -c promisor.acceptfromserver=KnownUrl \ --no-local --filter="blob:limit=5k" server client && @@ -326,7 +351,7 @@ test_expect_success "clone with 'KnownUrl' and url not configured on the server" git -C server config promisor.advertise true && test_when_finished "rm -rf client" && - test_when_finished "git -C server config set remote.lop.url \"file://$(pwd)/lop\"" && + test_when_finished "git -C server config set remote.lop.url \"$TRASH_DIRECTORY_URL/lop\"" && git -C server config unset remote.lop.url && # Clone from server to create a client @@ -335,7 +360,7 @@ test_expect_success "clone with 'KnownUrl' and url not configured on the server" # missing, so the remote name will be used instead which will fail. test_must_fail env GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=KnownUrl \ --no-local --filter="blob:limit=5k" server client && @@ -347,7 +372,7 @@ test_expect_success "clone with 'KnownUrl' and empty url, so not advertised" ' git -C server config promisor.advertise true && test_when_finished "rm -rf client" && - test_when_finished "git -C server config set remote.lop.url \"file://$(pwd)/lop\"" && + test_when_finished "git -C server config set remote.lop.url \"$TRASH_DIRECTORY_URL/lop\"" && git -C server config set remote.lop.url "" && # Clone from server to create a client @@ -356,7 +381,7 @@ test_expect_success "clone with 'KnownUrl' and empty url, so not advertised" ' # so the remote name will be used instead which will fail. test_must_fail env GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=KnownUrl \ --no-local --filter="blob:limit=5k" server client && @@ -380,13 +405,12 @@ test_expect_success "clone with promisor.sendFields" ' GIT_TRACE_PACKET="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git clone \ -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=All \ --no-local --filter="blob:limit=5k" server client && # Check that fields are properly transmitted - ENCODED_URL=$(echo "file://$(pwd)/lop" | sed -e "s/ /%20/g") && - PR1="name=lop,url=$ENCODED_URL,partialCloneFilter=blob:none" && + PR1="name=lop,url=$ENCODED_TRASH_DIRECTORY_URL/lop,partialCloneFilter=blob:none" && PR2="name=otherLop,url=https://invalid.invalid,partialCloneFilter=blob:limit=10k,token=fooBar" && test_grep "clone< promisor-remote=$PR1;$PR2" trace && test_grep "clone> promisor-remote=lop;otherLop" trace && @@ -411,15 +435,14 @@ test_expect_success "clone with promisor.checkFields" ' GIT_TRACE_PACKET="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git clone \ -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c remote.lop.partialCloneFilter="blob:none" \ -c promisor.acceptfromserver=All \ -c promisor.checkFields=partialcloneFilter \ --no-local --filter="blob:limit=5k" server client && # Check that fields are properly transmitted - ENCODED_URL=$(echo "file://$(pwd)/lop" | sed -e "s/ /%20/g") && - PR1="name=lop,url=$ENCODED_URL,partialCloneFilter=blob:none" && + PR1="name=lop,url=$ENCODED_TRASH_DIRECTORY_URL/lop,partialCloneFilter=blob:none" && PR2="name=otherLop,url=https://invalid.invalid,partialCloneFilter=blob:limit=10k,token=fooBar" && test_grep "clone< promisor-remote=$PR1;$PR2" trace && test_grep "clone> promisor-remote=lop" trace && @@ -449,7 +472,7 @@ test_expect_success "clone with promisor.storeFields=partialCloneFilter" ' GIT_TRACE_PACKET="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git clone \ -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c remote.lop.token="fooYYY" \ -c remote.lop.partialCloneFilter="blob:none" \ -c promisor.acceptfromserver=All \ @@ -501,7 +524,7 @@ test_expect_success "clone and fetch with --filter=auto" ' GIT_TRACE_PACKET="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git clone \ -c remote.lop.promisor=true \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=All \ --no-local --filter=auto server client 2>err && @@ -558,7 +581,7 @@ test_expect_success "clone with promisor.advertise set to 'true' but don't delet # Clone from server to create a client GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \ -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \ - -c remote.lop.url="file://$(pwd)/lop" \ + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \ -c promisor.acceptfromserver=All \ --no-local --filter="blob:limit=5k" server client && From b886f0b5dc71030bc9dcf58376533cf8e1098e9a Mon Sep 17 00:00:00 2001 From: Shreyansh Paliwal Date: Sat, 4 Apr 2026 19:28:38 +0530 Subject: [PATCH 026/608] refs: add struct repository parameter in get_files_ref_lock_timeout_ms() get_files_ref_lock_timeout_ms() calls repo_config_get_int() using the_repository, as no repository instance is available in its scope. Add a struct repository parameter and use it instead of the_repository. Update all callers accordingly. In files-backend.c, lock_raw_ref() can obtain repository instance from the struct ref_transaction via transaction->ref_store->repo and pass it down. For create_reflock(), which is used as a callback, introduce a small wrapper struct to pass both struct lock_file and struct repository through the callback data. This reduces reliance on the_repository global, though the function still uses static variables and is not yet fully repository-scoped. This can be addressed in a follow-up change. Signed-off-by: Shreyansh Paliwal Acked-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- refs.c | 4 ++-- refs/files-backend.c | 19 +++++++++++++------ refs/refs-internal.h | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/refs.c b/refs.c index 5d1d28523d617f..4ab746a3cb555c 100644 --- a/refs.c +++ b/refs.c @@ -989,7 +989,7 @@ enum ref_worktree_type parse_worktree_ref(const char *maybe_worktree_ref, return REF_WORKTREE_SHARED; } -long get_files_ref_lock_timeout_ms(void) +long get_files_ref_lock_timeout_ms(struct repository *repo) { static int configured = 0; @@ -997,7 +997,7 @@ long get_files_ref_lock_timeout_ms(void) static int timeout_ms = 100; if (!configured) { - repo_config_get_int(the_repository, "core.filesreflocktimeout", &timeout_ms); + repo_config_get_int(repo, "core.filesreflocktimeout", &timeout_ms); configured = 1; } diff --git a/refs/files-backend.c b/refs/files-backend.c index 0537a72b2af9e0..10e4388d2ca01a 100644 --- a/refs/files-backend.c +++ b/refs/files-backend.c @@ -792,7 +792,7 @@ static enum ref_transaction_error lock_raw_ref(struct files_ref_store *refs, if (hold_lock_file_for_update_timeout( &lock->lk, ref_file.buf, LOCK_NO_DEREF, - get_files_ref_lock_timeout_ms()) < 0) { + get_files_ref_lock_timeout_ms(transaction->ref_store->repo)) < 0) { int myerr = errno; errno = 0; if (myerr == ENOENT && --attempts_remaining > 0) { @@ -1190,13 +1190,17 @@ static int remove_empty_directories(struct strbuf *path) return remove_dir_recursively(path, REMOVE_DIR_EMPTY_ONLY); } +struct create_reflock_cb { + struct lock_file *lk; + struct repository *repo; +}; + static int create_reflock(const char *path, void *cb) { - struct lock_file *lk = cb; - + struct create_reflock_cb *data = cb; return hold_lock_file_for_update_timeout( - lk, path, LOCK_NO_DEREF, - get_files_ref_lock_timeout_ms()) < 0 ? -1 : 0; + data->lk, path, LOCK_NO_DEREF, + get_files_ref_lock_timeout_ms(data->repo)) < 0 ? -1 : 0; } /* @@ -1208,6 +1212,7 @@ static struct ref_lock *lock_ref_oid_basic(struct files_ref_store *refs, { struct strbuf ref_file = STRBUF_INIT; struct ref_lock *lock; + struct create_reflock_cb cb_data; files_assert_main_repository(refs, "lock_ref_oid_basic"); assert(err); @@ -1229,8 +1234,10 @@ static struct ref_lock *lock_ref_oid_basic(struct files_ref_store *refs, lock->ref_name = xstrdup(refname); lock->count = 1; + cb_data.lk = &lock->lk; + cb_data.repo = refs->base.repo; - if (raceproof_create_file(ref_file.buf, create_reflock, &lock->lk)) { + if (raceproof_create_file(ref_file.buf, create_reflock, &cb_data)) { unable_to_lock_message(ref_file.buf, errno, err); goto error_return; } diff --git a/refs/refs-internal.h b/refs/refs-internal.h index d79e35fd269a6c..e4cfd9e19ee74f 100644 --- a/refs/refs-internal.h +++ b/refs/refs-internal.h @@ -43,7 +43,7 @@ struct ref_transaction; * Return the length of time to retry acquiring a loose reference lock * before giving up, in milliseconds: */ -long get_files_ref_lock_timeout_ms(void); +long get_files_ref_lock_timeout_ms(struct repository *repo); /* * Return true iff refname is minimally safe. "Safe" here means that From 9a03f165a41d708c672e18e69d43f69689981e7d Mon Sep 17 00:00:00 2001 From: Shreyansh Paliwal Date: Sat, 4 Apr 2026 19:28:39 +0530 Subject: [PATCH 027/608] refs: remove the_hash_algo global state refs.c uses the_hash_algo in multiple places, relying on global state for the object hash algorithm. Replace these uses with the appropriate repository-specific hash_algo. In transaction-related functions (ref_transaction_create, ref_transaction_delete, migrate_one_ref, and transaction_hook_feed_stdin), use transaction->ref_store->repo->hash_algo. In other cases, such as repo_get_submodule_ref_store(), use repo->hash_algo. Signed-off-by: Shreyansh Paliwal Acked-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- refs.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/refs.c b/refs.c index 4ab746a3cb555c..d13ca9a37c63e3 100644 --- a/refs.c +++ b/refs.c @@ -1472,7 +1472,7 @@ int ref_transaction_create(struct ref_transaction *transaction, return 1; } return ref_transaction_update(transaction, refname, new_oid, - null_oid(the_hash_algo), new_target, NULL, flags, + null_oid(transaction->ref_store->repo->hash_algo), new_target, NULL, flags, msg, err); } @@ -1491,7 +1491,7 @@ int ref_transaction_delete(struct ref_transaction *transaction, if (old_target && !(flags & REF_NO_DEREF)) BUG("delete cannot operate on symrefs with deref mode"); return ref_transaction_update(transaction, refname, - null_oid(the_hash_algo), old_oid, + null_oid(transaction->ref_store->repo->hash_algo), old_oid, NULL, old_target, flags, msg, err); } @@ -2379,7 +2379,7 @@ struct ref_store *repo_get_submodule_ref_store(struct repository *repo, subrepo = xmalloc(sizeof(*subrepo)); if (repo_submodule_init(subrepo, repo, submodule, - null_oid(the_hash_algo))) { + null_oid(repo->hash_algo))) { free(subrepo); goto done; } @@ -2571,14 +2571,14 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_ strbuf_reset(buf); if (!(update->flags & REF_HAVE_OLD)) - strbuf_addf(buf, "%s ", oid_to_hex(null_oid(the_hash_algo))); + strbuf_addf(buf, "%s ", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo))); else if (update->old_target) strbuf_addf(buf, "ref:%s ", update->old_target); else strbuf_addf(buf, "%s ", oid_to_hex(&update->old_oid)); if (!(update->flags & REF_HAVE_NEW)) - strbuf_addf(buf, "%s ", oid_to_hex(null_oid(the_hash_algo))); + strbuf_addf(buf, "%s ", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo))); else if (update->new_target) strbuf_addf(buf, "ref:%s ", update->new_target); else @@ -3146,6 +3146,7 @@ struct migration_data { static int migrate_one_ref(const struct reference *ref, void *cb_data) { struct migration_data *data = cb_data; + const struct git_hash_algo *hash_algo = data->transaction->ref_store->repo->hash_algo; struct strbuf symref_target = STRBUF_INIT; int ret; @@ -3154,7 +3155,7 @@ static int migrate_one_ref(const struct reference *ref, void *cb_data) if (ret < 0) goto done; - ret = ref_transaction_update(data->transaction, ref->name, NULL, null_oid(the_hash_algo), + ret = ref_transaction_update(data->transaction, ref->name, NULL, null_oid(hash_algo), symref_target.buf, NULL, REF_SKIP_CREATE_REFLOG | REF_NO_DEREF, NULL, data->errbuf); if (ret < 0) From 57c590feb96b2298e0966bea3ce88c72fca37bbd Mon Sep 17 00:00:00 2001 From: Shreyansh Paliwal Date: Sat, 4 Apr 2026 19:28:40 +0530 Subject: [PATCH 028/608] refs/reftable-backend: drop uses of the_repository reftable_be_init() and reftable_be_create_on_disk() use the_repository even though a repository instance is already available, either directly or via struct ref_store. Replace these uses with the appropriate local repository instance (repo or ref_store->repo) to avoid relying on global state. Note that USE_THE_REPOSITORY_VARIABLE cannot be removed yet, as is_bare_repository() is still there in the file. Signed-off-by: Shreyansh Paliwal Acked-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- refs/reftable-backend.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/refs/reftable-backend.c b/refs/reftable-backend.c index b124404663edf6..7c8a992fcb40b9 100644 --- a/refs/reftable-backend.c +++ b/refs/reftable-backend.c @@ -404,13 +404,13 @@ static struct ref_store *reftable_be_init(struct repository *repo, default: BUG("unknown hash algorithm %d", repo->hash_algo->format_id); } - refs->write_options.default_permissions = calc_shared_perm(the_repository, 0666 & ~mask); + refs->write_options.default_permissions = calc_shared_perm(repo, 0666 & ~mask); refs->write_options.disable_auto_compact = !git_env_bool("GIT_TEST_REFTABLE_AUTOCOMPACTION", 1); refs->write_options.lock_timeout_ms = 100; refs->write_options.fsync = reftable_be_fsync; - repo_config(the_repository, reftable_be_config, &refs->write_options); + repo_config(repo, reftable_be_config, &refs->write_options); /* * It is somewhat unfortunate that we have to mirror the default block @@ -492,7 +492,7 @@ static int reftable_be_create_on_disk(struct ref_store *ref_store, struct strbuf sb = STRBUF_INIT; strbuf_addf(&sb, "%s/reftable", refs->base.gitdir); - safe_create_dir(the_repository, sb.buf, 1); + safe_create_dir(ref_store->repo, sb.buf, 1); strbuf_reset(&sb); strbuf_release(&sb); From 6077dc8a427950392be15a5507908d5e87a721a6 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Thu, 9 Apr 2026 22:44:31 +0000 Subject: [PATCH 029/608] docs: update version with default Rust support We missed the cut-off for Rust by default in 2.53, but we still can enable it by default for 2.54, so update our breaking changes document accordingly. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- Documentation/BreakingChanges.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/BreakingChanges.adoc b/Documentation/BreakingChanges.adoc index f814450d2f65ac..510ed98b65d755 100644 --- a/Documentation/BreakingChanges.adoc +++ b/Documentation/BreakingChanges.adoc @@ -190,7 +190,7 @@ milestones for the introduction of Rust: 1. Initially, with Git 2.52, support for Rust will be auto-detected by Meson and disabled in our Makefile so that the project can sort out the initial infrastructure. -2. In Git 2.53, both build systems will default-enable support for Rust. +2. In Git 2.54, both build systems will default-enable support for Rust. Consequently, builds will break by default if Rust is not available on the build host. The use of Rust can still be explicitly disabled via build flags. From 40c789dfc250486e60b7d7cdba47d8423a754abf Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Thu, 9 Apr 2026 22:44:32 +0000 Subject: [PATCH 030/608] ci: install cargo on Alpine We'll make Rust the default in a future commit, so be sure to install Cargo (which will also install Rust) to prepare for that case. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- ci/install-dependencies.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/install-dependencies.sh b/ci/install-dependencies.sh index c55441d9df91fd..10c3530d1aacdd 100755 --- a/ci/install-dependencies.sh +++ b/ci/install-dependencies.sh @@ -29,7 +29,7 @@ alpine-*) apk add --update shadow sudo meson ninja-build gcc libc-dev curl-dev openssl-dev expat-dev gettext \ zlib-ng-dev pcre2-dev python3 musl-libintl perl-utils ncurses \ apache2 apache2-http2 apache2-proxy apache2-ssl apache2-webdav apr-util-dbd_sqlite3 \ - bash cvs gnupg perl-cgi perl-dbd-sqlite perl-io-tty >/dev/null + bash cvs gnupg perl-cgi perl-dbd-sqlite perl-io-tty cargo >/dev/null ;; fedora-*|almalinux-*) case "$jobname" in From 30e6f7adf626af926a02897294363dbf5f3bbe65 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Thu, 9 Apr 2026 22:44:33 +0000 Subject: [PATCH 031/608] Linux: link against libdl Older versions of Rust on Linux, such as that used in Debian 11 in our CI, require linking against libdl. Were we linking with Cargo, this would be included automatically, but since we're not, explicitly set it in the system-specific config. This library is part of libc, so linking against it if it happens to be unnecessary will add no dependencies to the resulting binary. In addition, it is provided by both glibc and musl, so it should be portable to almost all Linux systems. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- config.mak.uname | 1 + 1 file changed, 1 insertion(+) diff --git a/config.mak.uname b/config.mak.uname index ccb3f718812740..7aab56c590069e 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,6 +63,7 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o + EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. ifneq ($(findstring .el7.,$(uname_R)),) BASIC_CFLAGS += -std=c99 From 32d5b905909e781786c4735e6bd71503b23e4fb1 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Thu, 9 Apr 2026 22:44:34 +0000 Subject: [PATCH 032/608] Enable Rust by default Our breaking changes document says that we'll enable Rust by default in Git 2.54. Adjust the Makefile to switch the option from WITH_RUST to NO_RUST to enable it by default and update the help text accordingly. Similarly, for Meson, enable the option by default and do not automatically disable it if Cargo is missing, since the goal is to help users find where they are likely to have problems in the future. Update our CI tests to swap out the single Linux job with Rust to a single job without, both for Makefile and Meson. Similarly, update the Windows Makefile job to not use Rust, while the Meson job (which does not build with ci/lib.sh) will default to having it enabled. Move the check for Cargo in the Meson build because it is no longer needed in the main script. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- Makefile | 10 +++++----- ci/lib.sh | 3 +++ ci/run-build-and-tests.sh | 6 ++++-- meson.build | 3 +-- meson_options.txt | 2 +- src/meson.build | 1 + 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index dbf00220541ce1..84b59959dedc5a 100644 --- a/Makefile +++ b/Makefile @@ -498,9 +498,9 @@ include shared.mak # # == Optional Rust support == # -# Define WITH_RUST if you want to include features and subsystems written in -# Rust into Git. For now, Rust is still an optional feature of the build -# process. With Git 3.0 though, Rust will always be enabled. +# Define NO_RUST if you want to disable features and subsystems written in Rust +# from being compiled into Git. For now, Rust is still an optional feature of +# the build process. With Git 3.0 though, Rust will always be enabled. # # Building Rust code requires Cargo. # @@ -1351,7 +1351,7 @@ LIB_OBJS += urlmatch.o LIB_OBJS += usage.o LIB_OBJS += userdiff.o LIB_OBJS += utf8.o -ifndef WITH_RUST +ifdef NO_RUST LIB_OBJS += varint.o endif LIB_OBJS += version.o @@ -1590,7 +1590,7 @@ endif ALL_CFLAGS = $(DEVELOPER_CFLAGS) $(CPPFLAGS) $(CFLAGS) $(CFLAGS_APPEND) ALL_LDFLAGS = $(LDFLAGS) $(LDFLAGS_APPEND) -ifdef WITH_RUST +ifndef NO_RUST BASIC_CFLAGS += -DWITH_RUST GITLIBS += $(RUST_LIB) ifeq ($(uname_S),Windows) diff --git a/ci/lib.sh b/ci/lib.sh index 42a2b6a318b874..1cfc8c6efce09e 100755 --- a/ci/lib.sh +++ b/ci/lib.sh @@ -372,6 +372,9 @@ linux-asan-ubsan) osx-meson) MESONFLAGS="$MESONFLAGS -Dcredential_helpers=osxkeychain" ;; +windows-*) + export NO_RUST=UnfortunatelyYes + ;; esac MAKEFLAGS="$MAKEFLAGS CC=${CC:-cc}" diff --git a/ci/run-build-and-tests.sh b/ci/run-build-and-tests.sh index 28cfe730ee5aed..e2d783d90b0181 100755 --- a/ci/run-build-and-tests.sh +++ b/ci/run-build-and-tests.sh @@ -8,11 +8,12 @@ export TEST_CONTRIB_TOO=yes case "$jobname" in +linux-musl-meson) + MESONFLAGS="$MESONFLAGS -Drust=disabled" + ;; fedora-breaking-changes-musl|linux-breaking-changes) export WITH_BREAKING_CHANGES=YesPlease - export WITH_RUST=YesPlease MESONFLAGS="$MESONFLAGS -Dbreaking_changes=true" - MESONFLAGS="$MESONFLAGS -Drust=enabled" ;; linux-TEST-vars) export OPENSSL_SHA1_UNSAFE=YesPlease @@ -30,6 +31,7 @@ linux-TEST-vars) export GIT_TEST_PACK_USE_BITMAP_BOUNDARY_TRAVERSAL=1 ;; linux-clang) + export NO_RUST=UnfortunatelyYes export GIT_TEST_DEFAULT_HASH=sha1 ;; linux-sha256) diff --git a/meson.build b/meson.build index 8309942d184847..deff129cf6d33a 100644 --- a/meson.build +++ b/meson.build @@ -1745,8 +1745,7 @@ version_def_h = custom_target( ) libgit_sources += version_def_h -cargo = find_program('cargo', dirs: program_path, native: true, required: get_option('rust')) -rust_option = get_option('rust').disable_auto_if(not cargo.found()) +rust_option = get_option('rust') if rust_option.allowed() subdir('src') libgit_c_args += '-DWITH_RUST' diff --git a/meson_options.txt b/meson_options.txt index 659cbb218f46e0..80a8025f20be6e 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -77,7 +77,7 @@ option('zlib_backend', type: 'combo', choices: ['auto', 'zlib', 'zlib-ng'], valu # Build tweaks. option('breaking_changes', type: 'boolean', value: false, description: 'Enable upcoming breaking changes.') -option('rust', type: 'feature', value: 'auto', +option('rust', type: 'feature', value: 'enabled', description: 'Enable building with Rust.') option('macos_use_homebrew_gettext', type: 'boolean', value: true, description: 'Use gettext from Homebrew instead of the slightly-broken system-provided one.') diff --git a/src/meson.build b/src/meson.build index 45739957b451c9..41a4b231e660c4 100644 --- a/src/meson.build +++ b/src/meson.build @@ -29,6 +29,7 @@ libgit_rs = custom_target('git_rs', ) libgit_dependencies += declare_dependency(link_with: libgit_rs) +cargo = find_program('cargo', dirs: program_path, native: true, required: get_option('rust')) if get_option('tests') test('rust', cargo, args: [ From 8d2ffcf4b4a3a55c56c57c8df617516c25d98380 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:05:56 +0300 Subject: [PATCH 033/608] repository: fix repo_init() memleak due to missing _clear() There is an old pre-existing memory leak in repo_init() due to failing to call clear_repository_format() in the error case. It went undetected because a specific bug is required to trigger it: enable a v1 extension in a repository with format v0. Obviously this can only happen in a development environment, so it does not trigger in normal usage, however the memleak is real and needs fixing. Fix it by also calling clear_repository_format() in the error case. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- repository.c | 1 + 1 file changed, 1 insertion(+) diff --git a/repository.c b/repository.c index 9e5537f53961ed..192d6dc9c477fa 100644 --- a/repository.c +++ b/repository.c @@ -323,6 +323,7 @@ int repo_init(struct repository *repo, return 0; error: + clear_repository_format(&format); repo_clear(repo); return -1; } From 1c9e5b3fa235e0da6f62359af36afea8e7617074 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:05:57 +0300 Subject: [PATCH 034/608] config: add a repo_config_get_uint() helper Next commits add a 'hook.jobs' config option of type 'unsigned int', so add a helper to parse it since the API only supports int and ulong. An alternative is to make 'hook.jobs' an 'int' or parse it as an 'int' then cast it to unsigned, however it's better to use proper helpers for the type. Using 'ulong' is another option which already has helpers, but it's a bit excessive in size for just the jobs number. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- config.c | 28 ++++++++++++++++++++++++++++ config.h | 13 +++++++++++++ parse.c | 9 +++++++++ parse.h | 1 + 4 files changed, 51 insertions(+) diff --git a/config.c b/config.c index 156f2a24fa0027..a1b92fe083cf43 100644 --- a/config.c +++ b/config.c @@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value, return ret; } +unsigned int git_config_uint(const char *name, const char *value, + const struct key_value_info *kvi) +{ + unsigned int ret; + if (!git_parse_uint(value, &ret)) + die_bad_number(name, value, kvi); + return ret; +} + int64_t git_config_int64(const char *name, const char *value, const struct key_value_info *kvi) { @@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest) return 1; } +int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest) +{ + const char *value; + struct key_value_info kvi; + + if (!git_configset_get_value(set, key, &value, &kvi)) { + *dest = git_config_uint(key, value, &kvi); + return 0; + } else + return 1; +} + int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest) { const char *value; @@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo, return git_configset_get_int(repo->config, key, dest); } +int repo_config_get_uint(struct repository *repo, + const char *key, unsigned int *dest) +{ + git_config_check_init(repo); + return git_configset_get_uint(repo->config, key, dest); +} + int repo_config_get_ulong(struct repository *repo, const char *key, unsigned long *dest) { diff --git a/config.h b/config.h index ba426a960af9f4..bf47fb3afc61bf 100644 --- a/config.h +++ b/config.h @@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *); int64_t git_config_int64(const char *, const char *, const struct key_value_info *); +/** + * Identical to `git_config_int`, but for unsigned ints. + */ +unsigned int git_config_uint(const char *, const char *, + const struct key_value_info *); + /** * Identical to `git_config_int`, but for unsigned longs. */ @@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key, int git_configset_get_string(struct config_set *cs, const char *key, char **dest); int git_configset_get_int(struct config_set *cs, const char *key, int *dest); +int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest); int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest); int git_configset_get_bool(struct config_set *cs, const char *key, int *dest); int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest); @@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r, */ int repo_config_get_int(struct repository *r, const char *key, int *dest); +/** + * Similar to `repo_config_get_int` but for unsigned ints. + */ +int repo_config_get_uint(struct repository *r, + const char *key, unsigned int *dest); + /** * Similar to `repo_config_get_int` but for unsigned longs. */ diff --git a/parse.c b/parse.c index 48313571aab129..d77f28046a0916 100644 --- a/parse.c +++ b/parse.c @@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret) return 1; } +int git_parse_uint(const char *value, unsigned int *ret) +{ + uintmax_t tmp; + if (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int))) + return 0; + *ret = tmp; + return 1; +} + int git_parse_ulong(const char *value, unsigned long *ret) { uintmax_t tmp; diff --git a/parse.h b/parse.h index ea32de9a91fbfb..a6dd37c4cba273 100644 --- a/parse.h +++ b/parse.h @@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max); int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max); int git_parse_ssize_t(const char *, ssize_t *); int git_parse_ulong(const char *, unsigned long *); +int git_parse_uint(const char *value, unsigned int *ret); int git_parse_int(const char *value, int *ret); int git_parse_int64(const char *value, int64_t *ret); int git_parse_double(const char *value, double *ret); From b9a4c9ad247a09602e0e6d0eccec6a43857f62da Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:05:58 +0300 Subject: [PATCH 035/608] hook: parse the hook.jobs config The hook.jobs config is a global way to set hook parallelization for all hooks, in the sense that it is not per-event nor per-hook. Finer-grained configs will be added in later commits which can override it, for e.g. via a per-event type job options. Next commits will also add to this item's documentation. Parse hook.jobs config key in hook_config_lookup_all() and store its value in hook_all_config_cb.jobs, then transfer it into r->jobs after the config pass completes. This is mostly plumbing and the cached value is not yet used. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/hook.adoc | 4 ++++ hook.c | 23 +++++++++++++++++++++-- repository.h | 3 +++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 9e78f264396ca5..b7847f9338c65f 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -22,3 +22,7 @@ hook..enabled:: configuration. This is particularly useful when a hook is defined in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. + +hook.jobs:: + Specifies how many hooks can be run simultaneously during parallelized + hook execution. If unspecified, defaults to 1 (serial execution). diff --git a/hook.c b/hook.c index cc23276d27f035..b8cce00e578d3c 100644 --- a/hook.c +++ b/hook.c @@ -123,11 +123,13 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook..enabled = false. + * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + unsigned int jobs; }; /* repo_config() callback that collects all hook.* configuration in one pass. */ @@ -143,6 +145,20 @@ static int hook_config_lookup_all(const char *key, const char *value, if (parse_config_key(key, "hook", &name, &name_len, &subkey)) return 0; + /* Handle plain hook. entries that have no hook name component. */ + if (!name) { + if (!strcmp(subkey, "jobs") && value) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.jobs must be a positive integer, ignoring: '%s'"), value); + else if (!v) + warning(_("hook.jobs must be positive, ignoring: 0")); + else + data->jobs = v; + } + return 0; + } + if (!value) return config_error_nonbool(key); @@ -240,7 +256,7 @@ void hook_cache_clear(struct strmap *cache) /* Populate `cache` with the complete hook configuration */ static void build_hook_config_map(struct repository *r, struct strmap *cache) { - struct hook_all_config_cb cb_data; + struct hook_all_config_cb cb_data = { 0 }; struct hashmap_iter iter; struct strmap_entry *e; @@ -248,7 +264,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); - /* Parse all configs in one run. */ + /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); /* Construct the cache from parsed configs. */ @@ -292,6 +308,9 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_put(cache, e->key, hooks); } + if (r) + r->hook_jobs = cb_data.jobs; + strmap_clear(&cb_data.commands, 1); string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { diff --git a/repository.h b/repository.h index 078059a6e02b10..58e46853d089bf 100644 --- a/repository.h +++ b/repository.h @@ -172,6 +172,9 @@ struct repository { */ struct strmap *hook_config_cache; + /* Cached value of hook.jobs config (0 if unset, defaults to serial). */ + unsigned int hook_jobs; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; From 680e69f60d2b3838bb98938dbd3e8881bdfde7d6 Mon Sep 17 00:00:00 2001 From: Emily Shaffer Date: Fri, 10 Apr 2026 12:05:59 +0300 Subject: [PATCH 036/608] hook: allow parallel hook execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks always run in sequential order due to the hardcoded jobs == 1 passed to run_process_parallel(). Remove that hardcoding to allow users to run hooks in parallel (opt-in). Users need to decide which hooks to run in parallel, by specifying "parallel = true" in the config, because Git cannot know if their specific hooks are safe to run or not in parallel (for e.g. two hooks might write to the same file or call the same program). Some hooks are unsafe to run in parallel by design: these will marked in the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL. The hook.jobs config specifies the default number of jobs applied to all hooks which have parallelism enabled. Signed-off-by: Emily Shaffer Helped-by: Ævar Arnfjörð Bjarmason Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/hook.adoc | 13 +++ hook.c | 79 ++++++++++++++++-- hook.h | 25 ++++++ t/t1800-hook.sh | 142 +++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 6 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index b7847f9338c65f..21800db648dca5 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -23,6 +23,19 @@ hook..enabled:: in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. +hook..parallel:: + Whether the hook `hook.` may run in parallel with other hooks + for the same event. Defaults to `false`. Set to `true` only when the + hook script is safe to run concurrently with other hooks for the same + event. If any hook for an event does not have this set to `true`, + all hooks for that event run sequentially regardless of `hook.jobs`. + Only configured (named) hooks need to declare this. Traditional hooks + found in the hooks directory do not need to, and run in parallel when + the effective job count is greater than 1. See linkgit:git-hook[1]. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). ++ +This setting has no effect unless all configured hooks for the event have +`hook..parallel` set to `true`. diff --git a/hook.c b/hook.c index b8cce00e578d3c..85c0de5e47b426 100644 --- a/hook.c +++ b/hook.c @@ -116,6 +116,7 @@ struct hook_config_cache_entry { char *command; enum config_scope scope; bool disabled; + bool parallel; }; /* @@ -123,12 +124,14 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook..enabled = false. + * parallel_hooks: friendly-name to parallel flag. * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + struct strmap parallel_hooks; unsigned int jobs; }; @@ -219,6 +222,15 @@ static int hook_config_lookup_all(const char *key, const char *value, default: break; /* ignore unrecognised values */ } + } else if (!strcmp(subkey, "parallel")) { + int v = git_parse_maybe_bool(value); + if (v >= 0) + strmap_put(&data->parallel_hooks, hook_name, + (void *)(uintptr_t)v); + else + warning(_("hook.%s.parallel must be a boolean," + " ignoring: '%s'"), + hook_name, value); } free(hook_name); @@ -263,6 +275,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.commands); strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); + strmap_init(&cb_data.parallel_hooks); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -282,6 +295,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) struct hook_config_cache_entry *entry; char *command; + bool is_par = !!strmap_get(&cb_data.parallel_hooks, hname); bool is_disabled = !!unsorted_string_list_lookup( &cb_data.disabled_hooks, hname); @@ -302,6 +316,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) entry->command = xstrdup_or_null(command); entry->scope = scope; entry->disabled = is_disabled; + entry->parallel = is_par; string_list_append(hooks, hname)->util = entry; } @@ -312,6 +327,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) r->hook_jobs = cb_data.jobs; strmap_clear(&cb_data.commands, 1); + strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { string_list_clear(e->value, 0); @@ -389,6 +405,7 @@ static void list_hooks_add_configured(struct repository *r, entry->command ? xstrdup(entry->command) : NULL; hook->u.configured.scope = entry->scope; hook->u.configured.disabled = entry->disabled; + hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; } @@ -538,21 +555,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* Determine how many jobs to use for hook execution. */ +static unsigned int get_hook_jobs(struct repository *r, + struct run_hooks_opt *options, + struct string_list *hook_list) +{ + /* + * Hooks needing separate output streams must run sequentially. + * Next commit will allow parallelizing these as well. + */ + if (!options->stdout_to_stderr) + return 1; + + /* + * An explicit job count overrides everything else: this covers both + * FORCE_SERIAL callers (for hooks that must never run in parallel) + * and the -j flag from the CLI. The CLI override is intentional: users + * may want to serialize hooks declared parallel or to parallelize more + * aggressively than the default. + */ + if (options->jobs) + return options->jobs; + + /* + * Use hook.jobs from the already-parsed config cache (in-repo), or + * fallback to a direct config lookup (out-of-repo). + * Default to 1 (serial execution) on failure. + */ + options->jobs = 1; + if (r) { + if (r->gitdir && r->hook_config_cache && r->hook_jobs) + options->jobs = r->hook_jobs; + else + repo_config_get_uint(r, "hook.jobs", &options->jobs); + } + + /* + * Cap to serial any configured hook not marked as parallel = true. + * This enforces the parallel = false default, even for "traditional" + * hooks from the hookdir which cannot be marked parallel = true. + */ + for (size_t i = 0; i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; + if (h->kind == HOOK_CONFIGURED && !h->parallel) { + options->jobs = 1; + break; + } + } + + return options->jobs; +} + int run_hooks_opt(struct repository *r, const char *hook_name, struct run_hooks_opt *options) { + struct string_list *hook_list = list_hooks(r, hook_name, options); struct hook_cb_data cb_data = { .rc = 0, .hook_name = hook_name, + .hook_command_list = hook_list, .options = options, }; int ret = 0; + unsigned int jobs = get_hook_jobs(r, options, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, - .processes = options->jobs, - .ungroup = options->jobs == 1, + .processes = jobs, + .ungroup = jobs == 1, .get_next_task = pick_next_hook, .start_failure = notify_start_failure, @@ -568,9 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->path_to_stdin && options->feed_pipe) BUG("options path_to_stdin and feed_pipe are mutually exclusive"); - if (!options->jobs) - BUG("run_hooks_opt must be called with options.jobs >= 1"); - /* * Ensure cb_data copy and free functions are either provided together, * or neither one is provided. @@ -581,7 +649,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->invoked_hook) *options->invoked_hook = 0; - cb_data.hook_command_list = list_hooks(r, hook_name, options); if (!cb_data.hook_command_list->nr) { if (options->error_if_missing) ret = error("cannot find a hook named %s", hook_name); diff --git a/hook.h b/hook.h index 5c5628dd1f822c..ba7056f8723b73 100644 --- a/hook.h +++ b/hook.h @@ -35,6 +35,13 @@ struct hook { } configured; } u; + /** + * Whether this hook may run in parallel with other hooks for the same + * event. Only useful for configured (named) hooks. Traditional hooks + * always default to 0 (serial). Set via `hook..parallel = true`. + */ + bool parallel; + /** * Opaque data pointer used to keep internal state across callback calls. * @@ -72,6 +79,8 @@ struct run_hooks_opt { * * If > 1, output will be buffered and de-interleaved (ungroup=0). * If == 1, output will be real-time (ungroup=1). + * If == 0, the 'hook.jobs' config is used or, if the config is unset, + * defaults to 1 (serial execution). */ unsigned int jobs; @@ -152,7 +161,23 @@ struct run_hooks_opt { hook_data_free_fn feed_pipe_cb_data_free; }; +/** + * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to + * the 'hook.jobs' config, falling back to serial (1) if unset. + */ #define RUN_HOOKS_OPT_INIT { \ + .env = STRVEC_INIT, \ + .args = STRVEC_INIT, \ + .stdout_to_stderr = 1, \ + .jobs = 0, \ +} + +/** + * Initializer for hooks that must always run sequentially regardless of + * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized + * .jobs = 1 is non-overridable. + */ +#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \ .env = STRVEC_INIT, \ .args = STRVEC_INIT, \ .stdout_to_stderr = 1, \ diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 33decc66c0ea8d..a3011a01ca2908 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -21,6 +21,57 @@ setup_hookdir () { test_when_finished rm -rf .git/hooks } +# write_sentinel_hook [sentinel] +# +# Writes a hook that marks itself as started, sleeps for a few seconds, then +# marks itself done. The sleep must be long enough that sentinel_detector can +# observe .started before .done appears when both hooks +# run concurrently in parallel mode. +write_sentinel_hook () { + sentinel="${2:-sentinel}" + write_script "$1" <<-EOF + touch ${sentinel}.started && + sleep 2 && + touch ${sentinel}.done + EOF +} + +# sentinel_detector +# +# Returns a shell command string suitable for use as hook..command. +# The detector must be registered after the sentinel: +# 1. In serial mode, the sentinel has completed (and .done exists) +# before the detector starts. +# 2. In parallel mode, both run concurrently so .done has not appeared +# yet and the detector just sees .started. +# +# At start, poll until .started exists to absorb startup jitter, then +# write to : +# 1. 'serial' if .done exists (sentinel finished before we started), +# 2. 'parallel' if only .started exists (sentinel still running), +# 3. 'timeout' if .started never appeared. +# +# The command ends with ':' so when git appends "$@" for hooks that receive +# positional arguments (e.g. pre-push), the result ': "$@"' is valid shell +# rather than a syntax error 'fi "$@"'. +sentinel_detector () { + cat <<-EOF + i=0 + while ! test -f ${1}.started && test \$i -lt 10; do + sleep 1 + i=\$((i+1)) + done + if test -f ${1}.done; then + echo serial >${2} + elif test -f ${1}.started; then + echo parallel >${2} + else + echo timeout >${2} + fi + : + EOF +} + test_expect_success 'git hook usage' ' test_expect_code 129 git hook && test_expect_code 129 git hook run && @@ -658,4 +709,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'hook.jobs=1 config runs hooks in series' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + + # Use two configured hooks so the execution order is deterministic: + # hook-1 (sentinel) is listed before hook-2 (detector), so hook-1 + # always runs first even in serial mode. + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 1 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.jobs=2 config runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook..parallel=true enables parallel execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook..parallel=false (default) forces serial execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'one non-parallel hook forces the whole event to run serially' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 has no parallel=true: should force serial for all + + test_config hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done From f776b77f0032fb342d567156626ef3fe9586443f Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:06:00 +0300 Subject: [PATCH 037/608] hook: allow pre-push parallel execution pre-push is the only hook that keeps stdout and stderr separate (for backwards compatibility with git-lfs and potentially other users). This prevents parallelizing it because run-command needs stdout_to_stderr=1 to buffer and de-interleave parallel outputs. Since we now default to jobs=1, backwards compatibility is maintained without needing any extension or extra config: when no parallelism is requested, pre-push behaves exactly as before. When the user explicitly opts into parallelism via hook.jobs > 1, hook..jobs > 1, or -jN, they accept the changed output behavior. Document this and let get_hook_jobs() set stdout_to_stderr=1 automatically when jobs > 1, removing the need for any extension infrastructure. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/hook.adoc | 4 ++++ hook.c | 24 ++++++++++++++++-------- hook.h | 6 ++++-- t/t1800-hook.sh | 32 ++++++++++++++++++++++++++++++++ transport.c | 6 ++++-- 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 21800db648dca5..94c7a9808e29ef 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -39,3 +39,7 @@ hook.jobs:: + This setting has no effect unless all configured hooks for the event have `hook..parallel` set to `true`. ++ +For `pre-push` hooks, which normally keep stdout and stderr separate, +setting this to a value greater than 1 (or passing `-j`) will merge stdout +into stderr to allow correct de-interleaving of parallel output. diff --git a/hook.c b/hook.c index 85c0de5e47b426..25762b6c8d18f9 100644 --- a/hook.c +++ b/hook.c @@ -555,18 +555,24 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* + * When running in parallel, stdout must be merged into stderr so + * run-command can buffer and de-interleave outputs correctly. This + * applies even to hooks like pre-push that normally keep stdout and + * stderr separate: the user has opted into parallelism, so the output + * stream behavior changes accordingly. + */ +static void merge_output_if_parallel(struct run_hooks_opt *options) +{ + if (options->jobs > 1) + options->stdout_to_stderr = 1; +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, struct string_list *hook_list) { - /* - * Hooks needing separate output streams must run sequentially. - * Next commit will allow parallelizing these as well. - */ - if (!options->stdout_to_stderr) - return 1; - /* * An explicit job count overrides everything else: this covers both * FORCE_SERIAL callers (for hooks that must never run in parallel) @@ -575,7 +581,7 @@ static unsigned int get_hook_jobs(struct repository *r, * aggressively than the default. */ if (options->jobs) - return options->jobs; + goto cleanup; /* * Use hook.jobs from the already-parsed config cache (in-repo), or @@ -603,6 +609,8 @@ static unsigned int get_hook_jobs(struct repository *r, } } +cleanup: + merge_output_if_parallel(options); return options->jobs; } diff --git a/hook.h b/hook.h index ba7056f8723b73..01db4226a60306 100644 --- a/hook.h +++ b/hook.h @@ -106,8 +106,10 @@ struct run_hooks_opt { * Send the hook's stdout to stderr. * * This is the default behavior for all hooks except pre-push, - * which has separate stdout and stderr streams for backwards - * compatibility reasons. + * which keeps stdout and stderr separate for backwards compatibility. + * When parallel execution is requested (jobs > 1), get_hook_jobs() + * overrides this to 1 for all hooks so run-command can de-interleave + * their outputs correctly. */ unsigned int stdout_to_stderr:1; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index a3011a01ca2908..4a978aff5e0c1e 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -800,4 +800,36 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall test_cmp expect hook.order ' +test_expect_success 'client hooks: pre-push parallel execution merges stdout to stderr' ' + test_when_finished "rm -rf remote-par stdout.actual stderr.actual" && + git init --bare remote-par && + git remote add origin-par remote-par && + test_commit par-commit && + mkdir -p .git/hooks && + setup_hooks pre-push && + test_config hook.jobs 2 && + git push origin-par HEAD:main >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push +' + +test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' ' + test_when_finished "rm -rf repo-parallel remote-parallel" && + git init --bare remote-parallel && + git init repo-parallel && + git -C repo-parallel remote add origin ../remote-parallel && + test_commit -C repo-parallel A && + + write_sentinel_hook repo-parallel/.git/hooks/pre-push && + git -C repo-parallel config hook.hook-2.event pre-push && + git -C repo-parallel config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + git -C repo-parallel config hook.hook-2.parallel true && + + git -C repo-parallel config hook.jobs 2 && + + git -C repo-parallel push origin HEAD >out 2>err && + echo parallel >expect && + test_cmp expect repo-parallel/hook.order +' + test_done diff --git a/transport.c b/transport.c index e53936d87b641f..9406ec4f2d682a 100644 --- a/transport.c +++ b/transport.c @@ -1391,8 +1391,10 @@ static int run_pre_push_hook(struct transport *transport, opt.feed_pipe_cb_data_free = pre_push_hook_data_free; /* - * pre-push hooks expect stdout & stderr to be separate, so don't merge - * them to keep backwards compatibility with existing hooks. + * pre-push hooks keep stdout and stderr separate by default for + * backwards compatibility. When the user opts into parallel execution + * via hook.jobs > 1 or -j, get_hook_jobs() will set stdout_to_stderr=1 + * automatically so run-command can de-interleave the outputs. */ opt.stdout_to_stderr = 0; From ae25764e50f38b6625e11c3a7d7de290a0075b9c Mon Sep 17 00:00:00 2001 From: Emily Shaffer Date: Fri, 10 Apr 2026 12:06:01 +0300 Subject: [PATCH 038/608] hook: mark non-parallelizable hooks Several hooks are known to be inherently non-parallelizable, so initialize them with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides any hook.jobs or runtime -j flags. These hooks are: applypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout, and push-to-checkout. Signed-off-by: Emily Shaffer Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/hook.adoc | 14 ++++++++++++++ builtin/am.c | 8 +++++--- builtin/checkout.c | 19 +++++++++++++------ builtin/clone.c | 6 ++++-- builtin/receive-pack.c | 3 ++- builtin/worktree.c | 2 +- commit.c | 2 +- t/t1800-hook.sh | 16 ++++++++++++++++ 8 files changed, 56 insertions(+), 14 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 94c7a9808e29ef..6f60775c28a902 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -36,6 +36,20 @@ hook..parallel:: hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Some hooks always run sequentially regardless of this setting because + they operate on shared data and cannot safely be parallelized: ++ +-- +`applypatch-msg`;; +`prepare-commit-msg`;; +`commit-msg`;; + Receive a commit message file and may rewrite it in place. +`pre-commit`;; +`post-checkout`;; +`push-to-checkout`;; +`post-commit`;; + Access the working tree, index, or repository state. +-- + This setting has no effect unless all configured hooks for the event have `hook..parallel` set to `true`. diff --git a/builtin/am.c b/builtin/am.c index fe6e087eee9ff5..e9623b8307793f 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state) assert(state->msg); - if (!state->no_verify) - ret = run_hooks_l(the_repository, "applypatch-msg", - am_path(state, "final-commit"), NULL); + if (!state->no_verify) { + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + strvec_push(&opt.args, am_path(state, "final-commit")); + ret = run_hooks_opt(the_repository, "applypatch-msg", &opt); + } if (!ret) { FREE_AND_NULL(state->msg); diff --git a/builtin/checkout.c b/builtin/checkout.c index e031e6188613a6..ac0186a33e559a 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -31,6 +31,7 @@ #include "resolve-undo.h" #include "revision.h" #include "setup.h" +#include "strvec.h" #include "submodule.h" #include "symlinks.h" #include "trace2.h" @@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info) static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit, int changed) { - return run_hooks_l(the_repository, "post-checkout", - oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), - oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), - changed ? "1" : "0", NULL); - /* "new_commit" can be NULL when checking out from the index before - a commit exists. */ + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + /* + * "new_commit" can be NULL when checking out from the index before + * a commit exists. + */ + strvec_pushl(&opt.args, + oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), + oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), + changed ? "1" : "0", + NULL); + + return run_hooks_opt(the_repository, "post-checkout", &opt); } static int update_some(const struct object_id *oid, struct strbuf *base, diff --git a/builtin/clone.c b/builtin/clone.c index fba3c9c508bc06..d23b0cafcfec30 100644 --- a/builtin/clone.c +++ b/builtin/clone.c @@ -647,6 +647,7 @@ static int checkout(int submodule_progress, struct tree *tree; struct tree_desc t; int err = 0; + struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; if (option_no_checkout) return 0; @@ -697,8 +698,9 @@ static int checkout(int submodule_progress, if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); - err |= run_hooks_l(the_repository, "post-checkout", oid_to_hex(null_oid(the_hash_algo)), - oid_to_hex(&oid), "1", NULL); + strvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)), + oid_to_hex(&oid), "1", NULL); + err |= run_hooks_opt(the_repository, "post-checkout", &hook_opt); if (!err && (option_recurse_submodules.nr > 0)) { struct child_process cmd = CHILD_PROCESS_INIT; diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index dada55884a0b06..6da60f640ce3af 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -1455,7 +1455,8 @@ static const char *push_to_checkout(unsigned char *hash, struct strvec *env, const char *work_tree) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + opt.invoked_hook = invoked_hook; strvec_pushf(env, "GIT_WORK_TREE=%s", absolute_path(work_tree)); diff --git a/builtin/worktree.c b/builtin/worktree.c index 4fd6f7575f9f76..d21c43fde38b5e 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname, * is_junk is cleared, but do return appropriate code when hook fails. */ if (!ret && opts->checkout && !opts->orphan) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL); strvec_pushl(&opt.args, diff --git a/commit.c b/commit.c index 80d8d078757dbc..4385ae4329e921 100644 --- a/commit.c +++ b/commit.c @@ -1970,7 +1970,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len) int run_commit_hook(int editor_is_used, const char *index_file, int *invoked_hook, const char *name, ...) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; va_list args; const char *arg; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 4a978aff5e0c1e..63fa25bca23c51 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -832,4 +832,20 @@ test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' test_cmp expect repo-parallel/hook.order ' +test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event pre-commit && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event pre-commit && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + test_config hook.jobs 2 && + git commit --allow-empty -m "test: verify force-serial on pre-commit" && + echo serial >expect && + test_cmp expect hook.order +' + test_done From 091d2dbeb452b2c8223c622b54e96ebd273b5a78 Mon Sep 17 00:00:00 2001 From: Emily Shaffer Date: Fri, 10 Apr 2026 12:06:02 +0300 Subject: [PATCH 039/608] hook: add -j/--jobs option to git hook run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the parallel job count as a command-line flag so callers can request parallelism without relying only on the hook.jobs config. Add tests covering serial/parallel execution and TTY behaviour under -j1 vs -jN. Signed-off-by: Emily Shaffer Helped-by: Ævar Arnfjörð Bjarmason Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/git-hook.adoc | 23 +++++- builtin/hook.c | 5 +- hook.c | 17 +++++ t/t1800-hook.sh | 135 ++++++++++++++++++++++++++++++++++-- 4 files changed, 170 insertions(+), 10 deletions(-) diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc index 318c637bd8eba5..46ea52db55f268 100644 --- a/Documentation/git-hook.adoc +++ b/Documentation/git-hook.adoc @@ -8,7 +8,8 @@ git-hook - Run git hooks SYNOPSIS -------- [verse] -'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=] [-- ] +'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=] [(-j|--jobs) ] + [-- ] 'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] DESCRIPTION @@ -147,6 +148,23 @@ OPTIONS mirroring the output style of `git config --show-scope`. Traditional hooks from the hookdir are unaffected. +-j:: +--jobs:: + Only valid for `run`. ++ +Specify how many hooks to run simultaneously. If this flag is not specified, +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If +neither is specified, defaults to 1 (serial execution). ++ +When greater than 1, it overrides the per-hook `hook..parallel` +setting, allowing all hooks for the event to run concurrently, even if they +are not individually marked as parallel. ++ +Some hooks always run sequentially regardless of this flag or the +`hook.jobs` config, because git knows they cannot safely run in parallel: +`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`, +`post-commit`, `post-checkout`, and `push-to-checkout`. + WRAPPERS -------- @@ -169,7 +187,8 @@ running: git hook run --allow-unknown-hook-name mywrapper-start-tests \ # providing something to stdin --stdin some-tempfile-123 \ - # execute hooks in serial + # execute multiple hooks in parallel + --jobs 3 \ # plus some arguments of your own... -- \ --testname bar \ diff --git a/builtin/hook.c b/builtin/hook.c index c0585587e5e4fa..bea0668b475931 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -8,7 +8,8 @@ #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ - N_("git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=] [-- ]") + N_("git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=] [(-j|--jobs) ]\n" \ + " [-- ]") #define BUILTIN_HOOK_LIST_USAGE \ N_("git hook list [--allow-unknown-hook-name] [-z] [--show-scope] ") @@ -132,6 +133,8 @@ static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested ")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), N_("file to read into hooks' stdin")), + OPT_UNSIGNED('j', "jobs", &opt.jobs, + N_("run up to hooks simultaneously")), OPT_END(), }; int ret; diff --git a/hook.c b/hook.c index 25762b6c8d18f9..c0b71322cf2ef6 100644 --- a/hook.c +++ b/hook.c @@ -568,6 +568,22 @@ static void merge_output_if_parallel(struct run_hooks_opt *options) options->stdout_to_stderr = 1; } +static void warn_non_parallel_hooks_override(unsigned int jobs, + struct string_list *hook_list) +{ + /* Don't warn for hooks running sequentially. */ + if (jobs == 1) + return; + + for (size_t i = 0; i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; + if (h->kind == HOOK_CONFIGURED && !h->parallel) + warning(_("hook '%s' is not marked as parallel=true, " + "running in parallel anyway due to -j%u"), + h->u.configured.friendly_name, jobs); + } +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, @@ -611,6 +627,7 @@ static unsigned int get_hook_jobs(struct repository *r, cleanup: merge_output_if_parallel(options); + warn_non_parallel_hooks_override(options->jobs, hook_list); return options->jobs; } diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 63fa25bca23c51..aa37a5181a0e0e 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -268,10 +268,20 @@ test_expect_success 'git -c core.hooksPath= hook run' ' ' test_hook_tty () { - cat >expect <<-\EOF - STDOUT TTY - STDERR TTY - EOF + expect_tty=$1 + shift + + if test "$expect_tty" != "no_tty"; then + cat >expect <<-\EOF + STDOUT TTY + STDERR TTY + EOF + else + cat >expect <<-\EOF + STDOUT NO TTY + STDERR NO TTY + EOF + fi test_when_finished "rm -rf repo" && git init repo && @@ -289,12 +299,21 @@ test_hook_tty () { test_cmp expect repo/actual } -test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' ' - test_hook_tty hook run pre-commit +test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' ' + # hooks running sequentially (-j1) are always connected to the tty for + # optimum real-time performance. + test_hook_tty tty hook run -j1 pre-commit +' + +test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' ' + # Hooks are not connected to the tty when run in parallel, instead they + # output to a pipe through which run-command collects and de-interlaces + # their outputs, which then gets passed either to the tty or a sideband. + test_hook_tty no_tty hook run -j2 pre-commit ' test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' ' - test_hook_tty commit -m"B.new" + test_hook_tty tty commit -m"B.new" ' test_expect_success 'git hook list orders by config order' ' @@ -709,6 +728,108 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'parallel hook output is not interleaved' ' + test_when_finished "rm -rf .git/hooks" && + + write_script .git/hooks/test-hook <<-EOF && + echo "Hook 1 Start" + sleep 1 + echo "Hook 1 End" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "echo \"Hook 2 Start\"; sleep 2; echo \"Hook 2 End\"" && + test_config hook.hook-2.parallel true && + test_config hook.hook-3.event test-hook && + test_config hook.hook-3.command \ + "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" && + test_config hook.hook-3.parallel true && + + git hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel && + + # Verify Hook 1 output is grouped + sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out && + test_line_count = 2 hook1_out && + + # Verify Hook 2 output is grouped + sed -n "/Hook 2 Start/,/Hook 2 End/p" err.parallel >hook2_out && + test_line_count = 2 hook2_out && + + # Verify Hook 3 output is grouped + sed -n "/Hook 3 Start/,/Hook 3 End/p" err.parallel >hook3_out && + test_line_count = 2 hook3_out +' + +test_expect_success 'git hook run -j1 runs hooks in series' ' + test_when_finished "rm -rf .git/hooks" && + + test_config hook.series-1.event "test-hook" && + test_config hook.series-1.command "echo 1" --add && + test_config hook.series-2.event "test-hook" && + test_config hook.series-2.command "echo 2" --add && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + echo 3 + EOF + + cat >expected <<-\EOF && + 1 + 2 + 3 + EOF + + git hook run --allow-unknown-hook-name -j1 test-hook 2>actual && + test_cmp expected actual +' + +test_expect_success 'git hook run -j2 runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'git hook run -j2 overrides parallel=false' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # -j2 overrides parallel=false; hooks run in parallel with a warning. + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "true" && + # neither hook has parallel=true + + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + grep "hook .hook-1. is not marked as parallel=true" err && + grep "hook .hook-2. is not marked as parallel=true" err +' + test_expect_success 'hook.jobs=1 config runs hooks in series' ' test_when_finished "rm -f sentinel.started sentinel.done hook.order" && From 084a55b3adf33f70c84091d5957b8bede9b01174 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:06:03 +0300 Subject: [PATCH 040/608] hook: add per-event jobs config Add a hook..jobs count config that allows users to override the global hook.jobs setting for specific hook events. This allows finer-grained control over parallelism on a per-event basis. For example, to run `post-receive` hooks with up to 4 parallel jobs while keeping other events at their global default: [hook] post-receive.jobs = 4 Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/hook.adoc | 19 +++++++++++ hook.c | 46 +++++++++++++++++++++++--- repository.c | 1 + repository.h | 3 ++ t/t1800-hook.sh | 59 ++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 5 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 6f60775c28a902..d4fa29d936d6e2 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -33,9 +33,28 @@ hook..parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. +hook..jobs:: + Specifies how many hooks can be run simultaneously for the `` + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` + for this specific event. The same parallelism restrictions apply: this + setting has no effect unless all configured hooks for the event have + `hook..parallel` set to `true`. Must be a positive int, + zero is rejected with a warning. See linkgit:git-hook[1]. ++ +Note on naming: although this key resembles `hook..*` +(a per-hook setting), `` must be the event name, not a hook +friendly name. The key component is stored literally and looked up by +event name at runtime with no translation between the two namespaces. +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the +lookup at runtime uses the event name (e.g. `"post-receive"`), so +`hook.my-hook.jobs` is silently ignored even when `my-hook` is +registered for that event. Use `hook.post-receive.jobs` or any other +valid event name when setting `hook..jobs`. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Can be overridden on a per-event basis with `hook..jobs`. Some hooks always run sequentially regardless of this setting because they operate on shared data and cannot safely be parallelized: + diff --git a/hook.c b/hook.c index c0b71322cf2ef6..d98b01156366a4 100644 --- a/hook.c +++ b/hook.c @@ -125,6 +125,7 @@ struct hook_config_cache_entry { * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook..enabled = false. * parallel_hooks: friendly-name to parallel flag. + * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { @@ -132,6 +133,7 @@ struct hook_all_config_cb { struct strmap event_hooks; struct string_list disabled_hooks; struct strmap parallel_hooks; + struct strmap event_jobs; unsigned int jobs; }; @@ -231,6 +233,18 @@ static int hook_config_lookup_all(const char *key, const char *value, warning(_("hook.%s.parallel must be a boolean," " ignoring: '%s'"), hook_name, value); + } else if (!strcmp(subkey, "jobs")) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.%s.jobs must be a positive integer," + " ignoring: '%s'"), + hook_name, value); + else if (!v) + warning(_("hook.%s.jobs must be positive," + " ignoring: 0"), hook_name); + else + strmap_put(&data->event_jobs, hook_name, + (void *)(uintptr_t)v); } free(hook_name); @@ -276,6 +290,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); strmap_init(&cb_data.parallel_hooks); + strmap_init(&cb_data.event_jobs); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -323,8 +338,10 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_put(cache, e->key, hooks); } - if (r) + if (r) { r->hook_jobs = cb_data.jobs; + r->event_jobs = cb_data.event_jobs; + } strmap_clear(&cb_data.commands, 1); strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ @@ -587,6 +604,7 @@ static void warn_non_parallel_hooks_override(unsigned int jobs, /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, + const char *hook_name, struct string_list *hook_list) { /* @@ -606,16 +624,34 @@ static unsigned int get_hook_jobs(struct repository *r, */ options->jobs = 1; if (r) { - if (r->gitdir && r->hook_config_cache && r->hook_jobs) - options->jobs = r->hook_jobs; - else + if (r->gitdir && r->hook_config_cache) { + void *event_jobs; + + if (r->hook_jobs) + options->jobs = r->hook_jobs; + + event_jobs = strmap_get(&r->event_jobs, hook_name); + if (event_jobs) + options->jobs = (unsigned int)(uintptr_t)event_jobs; + } else { + unsigned int event_jobs; + char *key; + repo_config_get_uint(r, "hook.jobs", &options->jobs); + + key = xstrfmt("hook.%s.jobs", hook_name); + if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) + options->jobs = event_jobs; + free(key); + } } /* * Cap to serial any configured hook not marked as parallel = true. * This enforces the parallel = false default, even for "traditional" * hooks from the hookdir which cannot be marked parallel = true. + * The same restriction applies whether jobs came from hook.jobs or + * hook..jobs. */ for (size_t i = 0; i < hook_list->nr; i++) { struct hook *h = hook_list->items[i].util; @@ -642,7 +678,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name, .options = options, }; int ret = 0; - unsigned int jobs = get_hook_jobs(r, options, hook_list); + unsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, diff --git a/repository.c b/repository.c index 192d6dc9c477fa..4030db4460714d 100644 --- a/repository.c +++ b/repository.c @@ -426,6 +426,7 @@ void repo_clear(struct repository *repo) hook_cache_clear(repo->hook_config_cache); FREE_AND_NULL(repo->hook_config_cache); } + strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */ if (repo->promisor_remote_config) { promisor_remote_clear(repo->promisor_remote_config); diff --git a/repository.h b/repository.h index 58e46853d089bf..6b67ec02e2984c 100644 --- a/repository.h +++ b/repository.h @@ -175,6 +175,9 @@ struct repository { /* Cached value of hook.jobs config (0 if unset, defaults to serial). */ unsigned int hook_jobs; + /* Cached map of event-name -> jobs count (as uintptr_t) from hook..jobs. */ + struct strmap event_jobs; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index aa37a5181a0e0e..24a3c92b6deb80 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -969,4 +969,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' test_cmp expect hook.order ' +test_expect_success 'hook..jobs overrides hook.jobs for that event' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=1 (serial), but per-event override allows parallel. + test_config hook.jobs 1 && + test_config hook.test-hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook..jobs=1 forces serial even when hook.jobs>1' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=4 allows parallel, but per-event override forces serial. + test_config hook.jobs 4 && + test_config hook.test-hook.jobs 1 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook..jobs still requires hook..parallel=true' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # Per-event jobs=2 but no hook has parallel=true: must still run serially. + test_config hook.test-hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done From 5e57b209ff21bf1087dd8539c458737c89b03150 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:06:04 +0300 Subject: [PATCH 041/608] hook: warn when hook..jobs is set Issue a warning when the user confuses the hook process and event namespaces by setting hook..jobs. Detect this by checking whether the name carrying .jobs also has .command, .event, or .parallel configured. Extract is_friendly_name() as a helper for this check, to be reused by future per-event config handling. Suggested-by: Junio C Hamano Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- hook.c | 40 ++++++++++++++++++++++++++++++++++++++++ t/t1800-hook.sh | 30 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/hook.c b/hook.c index d98b01156366a4..0493993bbe6738 100644 --- a/hook.c +++ b/hook.c @@ -279,6 +279,44 @@ void hook_cache_clear(struct strmap *cache) strmap_clear(cache, 0); } +/* + * Return true if `name` is a hook friendly-name, i.e. it has at least one of + * .command, .event, or .parallel configured. These are the reliable clues + * that distinguish a friendly-name from an event name. Note: .enabled is + * deliberately excluded because it can appear under both namespaces. + */ +static int is_friendly_name(struct hook_all_config_cb *cb, const char *name) +{ + struct hashmap_iter iter; + struct strmap_entry *e; + + if (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name)) + return 1; + + strmap_for_each_entry(&cb->event_hooks, &iter, e) { + if (unsorted_string_list_lookup(e->value, name)) + return 1; + } + + return 0; +} + +/* Warn if any name in event_jobs is also a hook friendly-name. */ +static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data) +{ + struct hashmap_iter iter; + struct strmap_entry *e; + + strmap_for_each_entry(&cb_data->event_jobs, &iter, e) { + if (is_friendly_name(cb_data, e->key)) + warning(_("hook.%s.jobs is set but '%s' looks like a " + "hook friendly-name, not an event name; " + "hook..jobs uses the event name " + "(e.g. hook.post-receive.jobs), so this " + "setting will be ignored"), e->key, e->key); + } +} + /* Populate `cache` with the complete hook configuration */ static void build_hook_config_map(struct repository *r, struct strmap *cache) { @@ -295,6 +333,8 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); + warn_jobs_on_friendly_names(&cb_data); + /* Construct the cache from parsed configs. */ strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { struct string_list *hook_names = e->value; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 24a3c92b6deb80..89fedc48ff497f 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -1028,4 +1028,34 @@ test_expect_success 'hook..jobs still requires hook..parallel=true' test_cmp expect hook.order ' +test_expect_success 'hook..jobs warns when name has .command' ' + test_config hook.my-hook.command "true" && + test_config hook.my-hook.jobs 2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook..jobs warns when name has .event' ' + test_config hook.my-hook.event test-hook && + test_config hook.my-hook.command "true" && + test_config hook.my-hook.jobs 2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook..jobs warns when name has .parallel' ' + test_config hook.my-hook.event test-hook && + test_config hook.my-hook.command "true" && + test_config hook.my-hook.parallel true && + test_config hook.my-hook.jobs 2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook..jobs does not warn for a real event name' ' + test_config hook.test-hook.jobs 2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep ! "friendly-name" err +' + test_done From 2eb541e8f2a9b0dd923279421c741d0a0c00420d Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:06:05 +0300 Subject: [PATCH 042/608] hook: move is_known_hook() to hook.c for wider use Move is_known_hook() from builtin/hook.c (static) into hook.c and export it via hook.h so it can be reused. Make it return bool and the iterator `h` for clarity (iterate hooks). Both meson.build and the Makefile are updated to reflect that the header is now used by libgit, not the builtin sources. The next commit will use this to reject hook friendly-names that collide with known event names. Co-authored-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Makefile | 2 +- builtin/hook.c | 10 ---------- hook.c | 10 ++++++++++ hook.h | 6 ++++++ meson.build | 24 ++++++++++++------------ 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index 5d22394c2ec1a6..c4e83823e4a547 100644 --- a/Makefile +++ b/Makefile @@ -2675,7 +2675,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 +hook.sp hook.s 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 bea0668b475931..1839412dca3edc 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -4,7 +4,6 @@ #include "environment.h" #include "gettext.h" #include "hook.h" -#include "hook-list.h" #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ @@ -13,15 +12,6 @@ #define BUILTIN_HOOK_LIST_USAGE \ 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, BUILTIN_HOOK_LIST_USAGE, diff --git a/hook.c b/hook.c index 0493993bbe6738..19076f8f2baba6 100644 --- a/hook.c +++ b/hook.c @@ -5,6 +5,7 @@ #include "environment.h" #include "gettext.h" #include "hook.h" +#include "hook-list.h" #include "parse.h" #include "path.h" #include "run-command.h" @@ -12,6 +13,15 @@ #include "strbuf.h" #include "strmap.h" +bool is_known_hook(const char *name) +{ + const char **h; + for (h = hook_name_list; *h; h++) + if (!strcmp(*h, name)) + return true; + return false; +} + const char *find_hook(struct repository *r, const char *name) { static struct strbuf path = STRBUF_INIT; diff --git a/hook.h b/hook.h index 01db4226a60306..5a93f56618e123 100644 --- a/hook.h +++ b/hook.h @@ -234,6 +234,12 @@ void hook_free(void *p, const char *str); */ void hook_cache_clear(struct strmap *cache); +/** + * Returns true if `name` is a recognized hook event name + * (e.g. "pre-commit", "post-receive"). + */ +bool is_known_hook(const char *name); + /** * Returns the path to the hook file, or NULL if the hook is missing * or disabled. Note that this points to static storage that will be diff --git a/meson.build b/meson.build index 8309942d184847..f438d5545dafb7 100644 --- a/meson.build +++ b/meson.build @@ -563,6 +563,18 @@ libgit_sources += custom_target( env: script_environment, ) +libgit_sources += custom_target( + input: 'Documentation/githooks.adoc', + output: 'hook-list.h', + command: [ + shell, + meson.current_source_dir() + '/tools/generate-hooklist.sh', + meson.current_source_dir(), + '@OUTPUT@', + ], + env: script_environment, +) + builtin_sources = [ 'builtin/add.c', 'builtin/am.c', @@ -739,18 +751,6 @@ builtin_sources += custom_target( env: script_environment, ) -builtin_sources += custom_target( - input: 'Documentation/githooks.adoc', - output: 'hook-list.h', - command: [ - shell, - meson.current_source_dir() + '/tools/generate-hooklist.sh', - meson.current_source_dir(), - '@OUTPUT@', - ], - env: script_environment, -) - # This contains the variables for GIT-BUILD-OPTIONS, which we use to propagate # build options to our tests. build_options_config = configuration_data() From dcfb5af67e7d7156c4d1ede66de18088c990356c Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:06:06 +0300 Subject: [PATCH 043/608] hook: add hook..enabled switch Add a hook..enabled config key that disables all hooks for a given event, when set to false, acting as a high-level switch above the existing per-hook hook..enabled. Event-disabled hooks are shown in "git hook list" with an "event-disabled" tab-separated prefix before the name: $ git hook list test-hook event-disabled hook-1 event-disabled hook-2 With --show-scope: $ git hook list --show-scope test-hook local event-disabled hook-1 When a hook is both per-hook disabled and event-disabled, only "event-disabled" is shown: the event-level switch is the more relevant piece of information, and the per-hook "disabled" status will surface once the event is re-enabled. Using an event name as a friendly-name (e.g. hook..enabled) can cause ambiguity, so a fatal error is issued when using a known event name and a warning is issued for unknown event name, since a collision cannot be detected with certainty for unknown events. Suggested-by: Patrick Steinhardt Suggested-by: Junio C Hamano Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/hook.adoc | 20 ++++++++ builtin/hook.c | 20 +++++--- hook.c | 47 +++++++++++++++++-- hook.h | 1 + repository.c | 1 + repository.h | 4 ++ t/t1800-hook.sh | 83 ++++++++++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 11 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index d4fa29d936d6e2..e0db3afa194080 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -15,6 +15,12 @@ hook..event:: events, specify the key more than once. An empty value resets the list of events, clearing any previously defined events for `hook.`. See linkgit:git-hook[1]. ++ +The `` must not be the same as a known hook event name +(e.g. do not use `hook.pre-commit.event`). Using a known event name as +a friendly-name is a fatal error because it creates an ambiguity with +`hook..enabled` and `hook..jobs`. For unknown event names, +a warning is issued when `` matches the event value. hook..enabled:: Whether the hook `hook.` is enabled. Defaults to `true`. @@ -33,6 +39,20 @@ hook..parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. +hook..enabled:: + Switch to enable or disable all hooks for the `` hook event. + When set to `false`, no hooks fire for that event, regardless of any + per-hook `hook..enabled` settings. Defaults to `true`. + See linkgit:git-hook[1]. ++ +Note on naming: `` must be the event name (e.g. `pre-commit`), +not a hook friendly-name. Since using a known event name as a +friendly-name is disallowed (see `hook..event` above), +there is no ambiguity between event-level and per-hook `.enabled` +settings for known events. For unknown events, if a friendly-name +matches the event name despite the warning, `.enabled` is treated +as per-hook only. + hook..jobs:: Specifies how many hooks can be run simultaneously for the `` hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` diff --git a/builtin/hook.c b/builtin/hook.c index 1839412dca3edc..8e47e22e2a1e5f 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -87,14 +87,22 @@ static int list(int argc, const char **argv, const char *prefix, const char *name = h->u.configured.friendly_name; const char *scope = show_scope ? config_scope_name(h->u.configured.scope) : NULL; + /* + * Show the most relevant disable reason. Event-level + * takes precedence: if the whole event is off, that + * is what the user needs to know. The per-hook + * "disabled" surfaces once the event is re-enabled. + */ + const char *disability = + h->u.configured.event_disabled ? "event-disabled\t" : + h->u.configured.disabled ? "disabled\t" : + ""; if (scope) - printf("%s\t%s%s%c", scope, - h->u.configured.disabled ? "disabled\t" : "", - name, line_terminator); + printf("%s\t%s%s%c", scope, disability, name, + line_terminator); else - printf("%s%s%c", - h->u.configured.disabled ? "disabled\t" : "", - name, line_terminator); + printf("%s%s%c", disability, name, + line_terminator); break; } default: diff --git a/hook.c b/hook.c index 19076f8f2baba6..bc990d4ed4d754 100644 --- a/hook.c +++ b/hook.c @@ -133,7 +133,9 @@ struct hook_config_cache_entry { * Callback struct to collect all hook.* keys in a single config pass. * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. - * disabled_hooks: set of friendly-names with hook..enabled = false. + * disabled_hooks: set of all names with hook..enabled = false; after + * parsing, names that are not friendly-names become event-level + * disables stored in r->disabled_events. This collects all. * parallel_hooks: friendly-name to parallel flag. * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). @@ -189,8 +191,21 @@ static int hook_config_lookup_all(const char *key, const char *value, strmap_for_each_entry(&data->event_hooks, &iter, e) unsorted_string_list_remove(e->value, hook_name, 0); } else { - struct string_list *hooks = - strmap_get(&data->event_hooks, value); + struct string_list *hooks; + + if (is_known_hook(hook_name)) + die(_("hook friendly-name '%s' collides with " + "a known event name; please choose a " + "different friendly-name"), + hook_name); + + if (!strcmp(hook_name, value)) + warning(_("hook friendly-name '%s' is the " + "same as its event; this may cause " + "ambiguity with hook.%s.enabled"), + hook_name, hook_name); + + hooks = strmap_get(&data->event_hooks, value); if (!hooks) { CALLOC_ARRAY(hooks, 1); @@ -345,6 +360,22 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) warn_jobs_on_friendly_names(&cb_data); + /* + * Populate disabled_events: names in disabled_hooks that are not + * friendly-names are event-level switches (hook..enabled = false). + * Names that are friendly-names are already handled per-hook via the + * hook_config_cache_entry.disabled flag below. + */ + if (r) { + string_list_clear(&r->disabled_events, 0); + string_list_init_dup(&r->disabled_events); + for (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) { + const char *n = cb_data.disabled_hooks.items[i].string; + if (!is_friendly_name(&cb_data, n)) + string_list_append(&r->disabled_events, n); + } + } + /* Construct the cache from parsed configs. */ strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { struct string_list *hook_names = e->value; @@ -446,6 +477,8 @@ static void list_hooks_add_configured(struct repository *r, { struct strmap *cache = get_hook_config_cache(r); struct string_list *configured_hooks = strmap_get(cache, hookname); + bool event_is_disabled = r ? !!unsorted_string_list_lookup(&r->disabled_events, + hookname) : 0; /* Iterate through configured hooks and initialize internal states */ for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) { @@ -472,6 +505,7 @@ static void list_hooks_add_configured(struct repository *r, entry->command ? xstrdup(entry->command) : NULL; hook->u.configured.scope = entry->scope; hook->u.configured.disabled = entry->disabled; + hook->u.configured.event_disabled = event_is_disabled; hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; @@ -484,6 +518,8 @@ static void list_hooks_add_configured(struct repository *r, if (!r || !r->gitdir) { hook_cache_clear(cache); free(cache); + if (r) + string_list_clear(&r->disabled_events, 0); } } @@ -515,7 +551,7 @@ int hook_exists(struct repository *r, const char *name) 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) { + (!h->u.configured.disabled && !h->u.configured.event_disabled)) { exists = 1; break; } @@ -538,7 +574,8 @@ static int pick_next_hook(struct child_process *cp, 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); + } while (h->kind == HOOK_CONFIGURED && + (h->u.configured.disabled || h->u.configured.event_disabled)); cp->no_stdin = 1; strvec_pushv(&cp->env, hook_cb->options->env.v); diff --git a/hook.h b/hook.h index 5a93f56618e123..b4372b636ff4de 100644 --- a/hook.h +++ b/hook.h @@ -32,6 +32,7 @@ struct hook { const char *command; enum config_scope scope; bool disabled; + bool event_disabled; } configured; } u; diff --git a/repository.c b/repository.c index 4030db4460714d..db57b8308b94e7 100644 --- a/repository.c +++ b/repository.c @@ -427,6 +427,7 @@ void repo_clear(struct repository *repo) FREE_AND_NULL(repo->hook_config_cache); } strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */ + string_list_clear(&repo->disabled_events, 0); if (repo->promisor_remote_config) { promisor_remote_clear(repo->promisor_remote_config); diff --git a/repository.h b/repository.h index 6b67ec02e2984c..4969d8b8ebed60 100644 --- a/repository.h +++ b/repository.h @@ -2,6 +2,7 @@ #define REPOSITORY_H #include "strmap.h" +#include "string-list.h" #include "repo-settings.h" #include "environment.h" @@ -178,6 +179,9 @@ struct repository { /* Cached map of event-name -> jobs count (as uintptr_t) from hook..jobs. */ struct strmap event_jobs; + /* Cached list of event names with hook..enabled = false. */ + struct string_list disabled_events; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 89fedc48ff497f..c4ff25f6b088ea 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -1058,4 +1058,87 @@ test_expect_success 'hook..jobs does not warn for a real event name' ' test_grep ! "friendly-name" err ' +test_expect_success 'hook..enabled=false skips all hooks for event' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_must_be_empty out +' + +test_expect_success 'hook..enabled=true does not suppress hooks' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled true && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep "ran" err +' + +test_expect_success 'hook..enabled=false does not affect other events' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.other-event.enabled false && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep "ran" err +' + +test_expect_success 'hook..enabled=false still disables that hook' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo hook-1" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "echo hook-2" && + test_config hook.hook-1.enabled false && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep ! "hook-1" err && + test_grep "hook-2" err +' + +test_expect_success 'git hook list shows event-disabled hooks as event-disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list --allow-unknown-hook-name test-hook >actual && + test_grep "^event-disabled hook-1$" actual && + test_grep "^event-disabled hook-2$" actual +' + +test_expect_success 'git hook list shows scope with event-disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list --allow-unknown-hook-name --show-scope test-hook >actual && + test_grep "^local event-disabled hook-1$" actual +' + +test_expect_success 'git hook list still shows hooks when event is disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list --allow-unknown-hook-name test-hook >actual && + test_grep "event-disabled" actual +' + +test_expect_success 'friendly-name matching known event name is rejected' ' + test_config hook.pre-commit.event pre-commit && + test_config hook.pre-commit.command "echo oops" && + test_must_fail git hook run pre-commit 2>err && + test_grep "collides with a known event name" err +' + +test_expect_success 'friendly-name matching known event name is rejected even for different event' ' + test_config hook.pre-commit.event post-commit && + test_config hook.pre-commit.command "echo oops" && + test_must_fail git hook run post-commit 2>err && + test_grep "collides with a known event name" err +' + +test_expect_success 'friendly-name matching unknown event warns' ' + test_config hook.test-hook.event test-hook && + test_config hook.test-hook.command "echo ran" && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep "same as its event" err +' + test_done From 495b7d54dc006556548e2fd3ca15c4f533917329 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Fri, 10 Apr 2026 12:06:07 +0300 Subject: [PATCH 044/608] hook: allow hook.jobs=-1 to use all available CPU cores Allow -1 as a value for hook.jobs, hook..jobs, and the -j CLI flag to mean "use as many jobs as there are CPU cores", matching the convention used by fetch.parallel and other Git subsystems. The value is resolved to online_cpus() at parse time so the rest of the code always works with a positive resolved count. Other non-positive values (0, -2, etc) are rejected with a warning (config) or die (CLI). Suggested-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/hook.adoc | 4 ++- builtin/hook.c | 15 +++++++-- hook.c | 60 ++++++++++++++++++++++++---------- t/t1800-hook.sh | 49 +++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index e0db3afa194080..a9dc0063c12102 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -58,7 +58,8 @@ hook..jobs:: hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` for this specific event. The same parallelism restrictions apply: this setting has no effect unless all configured hooks for the event have - `hook..parallel` set to `true`. Must be a positive int, + `hook..parallel` set to `true`. Set to `-1` to use the + number of available CPU cores. Must be a positive integer or `-1`; zero is rejected with a warning. See linkgit:git-hook[1]. + Note on naming: although this key resembles `hook..*` @@ -74,6 +75,7 @@ valid event name when setting `hook..jobs`. hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Set to `-1` to use the number of available CPU cores. Can be overridden on a per-event basis with `hook..jobs`. Some hooks always run sequentially regardless of this setting because they operate on shared data and cannot safely be parallelized: diff --git a/builtin/hook.c b/builtin/hook.c index 8e47e22e2a1e5f..cceeb3586e5daf 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -5,6 +5,7 @@ #include "gettext.h" #include "hook.h" #include "parse-options.h" +#include "thread-utils.h" #define BUILTIN_HOOK_RUN_USAGE \ N_("git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=] [(-j|--jobs) ]\n" \ @@ -123,6 +124,7 @@ static int run(int argc, const char **argv, const char *prefix, struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; int ignore_missing = 0; int allow_unknown = 0; + int jobs = 0; const char *hook_name; struct option run_options[] = { OPT_BOOL(0, "allow-unknown-hook-name", &allow_unknown, @@ -131,8 +133,8 @@ static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested ")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), N_("file to read into hooks' stdin")), - OPT_UNSIGNED('j', "jobs", &opt.jobs, - N_("run up to hooks simultaneously")), + OPT_INTEGER('j', "jobs", &jobs, + N_("run up to hooks simultaneously (-1 for CPU count)")), OPT_END(), }; int ret; @@ -141,6 +143,15 @@ static int run(int argc, const char **argv, const char *prefix, builtin_hook_run_usage, PARSE_OPT_KEEP_DASHDASH); + if (jobs == -1) + opt.jobs = online_cpus(); + else if (jobs < 0) + die(_("invalid value for -j: %d" + " (use -1 for CPU count or a" + " positive integer)"), jobs); + else + opt.jobs = jobs; + if (!argc) goto usage; diff --git a/hook.c b/hook.c index bc990d4ed4d754..d10eef4763c679 100644 --- a/hook.c +++ b/hook.c @@ -12,6 +12,7 @@ #include "setup.h" #include "strbuf.h" #include "strmap.h" +#include "thread-utils.h" bool is_known_hook(const char *name) { @@ -165,13 +166,17 @@ static int hook_config_lookup_all(const char *key, const char *value, /* Handle plain hook. entries that have no hook name component. */ if (!name) { if (!strcmp(subkey, "jobs") && value) { - unsigned int v; - if (!git_parse_uint(value, &v)) - warning(_("hook.jobs must be a positive integer, ignoring: '%s'"), value); - else if (!v) - warning(_("hook.jobs must be positive, ignoring: 0")); - else + int v; + if (!git_parse_int(value, &v)) + warning(_("hook.jobs must be an integer, ignoring: '%s'"), value); + else if (v == -1) + data->jobs = online_cpus(); + else if (v > 0) data->jobs = v; + else + warning(_("hook.jobs must be a positive integer" + " or -1, ignoring: '%s'"), + value); } return 0; } @@ -259,17 +264,21 @@ static int hook_config_lookup_all(const char *key, const char *value, " ignoring: '%s'"), hook_name, value); } else if (!strcmp(subkey, "jobs")) { - unsigned int v; - if (!git_parse_uint(value, &v)) - warning(_("hook.%s.jobs must be a positive integer," + int v; + if (!git_parse_int(value, &v)) + warning(_("hook.%s.jobs must be an integer," " ignoring: '%s'"), hook_name, value); - else if (!v) - warning(_("hook.%s.jobs must be positive," - " ignoring: 0"), hook_name); - else + else if (v == -1) + strmap_put(&data->event_jobs, hook_name, + (void *)(uintptr_t)online_cpus()); + else if (v > 0) strmap_put(&data->event_jobs, hook_name, (void *)(uintptr_t)v); + else + warning(_("hook.%s.jobs must be a positive" + " integer or -1, ignoring: '%s'"), + hook_name, value); } free(hook_name); @@ -688,6 +697,25 @@ static void warn_non_parallel_hooks_override(unsigned int jobs, } } +/* Resolve a hook.jobs config key, handling -1 as online_cpus(). */ +static void resolve_hook_config_jobs(struct repository *r, + const char *key, + unsigned int *jobs) +{ + int v; + + if (repo_config_get_int(r, key, &v)) + return; + + if (v == -1) + *jobs = online_cpus(); + else if (v > 0) + *jobs = v; + else + warning(_("%s must be a positive integer or -1," + " ignoring: %d"), key, v); +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, @@ -721,14 +749,12 @@ static unsigned int get_hook_jobs(struct repository *r, if (event_jobs) options->jobs = (unsigned int)(uintptr_t)event_jobs; } else { - unsigned int event_jobs; char *key; - repo_config_get_uint(r, "hook.jobs", &options->jobs); + resolve_hook_config_jobs(r, "hook.jobs", &options->jobs); key = xstrfmt("hook.%s.jobs", hook_name); - if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) - options->jobs = event_jobs; + resolve_hook_config_jobs(r, key, &options->jobs); free(key); } } diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index c4ff25f6b088ea..41b2b2c7460066 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -1058,6 +1058,55 @@ test_expect_success 'hook..jobs does not warn for a real event name' ' test_grep ! "friendly-name" err ' +test_expect_success 'hook.jobs=-1 resolves to online_cpus()' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-1.parallel true && + + test_config hook.jobs -1 && + + cpus=$(test-tool online-cpus) && + GIT_TRACE2_EVENT="$(pwd)/trace.txt" \ + git hook run --allow-unknown-hook-name test-hook >out 2>err && + grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt +' + +test_expect_success 'hook..jobs=-1 resolves to online_cpus()' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-1.parallel true && + + test_config hook.test-hook.jobs -1 && + + cpus=$(test-tool online-cpus) && + GIT_TRACE2_EVENT="$(pwd)/trace.txt" \ + git hook run --allow-unknown-hook-name test-hook >out 2>err && + grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt +' + +test_expect_success 'git hook run -j-1 resolves to online_cpus()' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-1.parallel true && + + cpus=$(test-tool online-cpus) && + GIT_TRACE2_EVENT="$(pwd)/trace.txt" \ + git hook run --allow-unknown-hook-name -j-1 test-hook >out 2>err && + grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt +' + +test_expect_success 'hook.jobs rejects values less than -1' ' + test_config hook.jobs -2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.jobs must be a positive integer or -1" err +' + +test_expect_success 'hook..jobs rejects values less than -1' ' + test_config hook.test-hook.jobs -5 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.test-hook.jobs must be a positive integer or -1" err +' + test_expect_success 'hook..enabled=false skips all hooks for event' ' test_config hook.hook-1.event test-hook && test_config hook.hook-1.command "echo ran" && From 75b7cb5e14f03965cf87a976356bcbdcfb4edbad Mon Sep 17 00:00:00 2001 From: Jeff King Date: Fri, 10 Apr 2026 12:06:08 +0300 Subject: [PATCH 045/608] t1800: test SIGPIPE with parallel hooks We recently fixed a bug in commit 2226ffaacd (run_processes_parallel(): fix order of sigpipe handling, 2026-04-08) where a hook that caused us to get SIGPIPE would accidentally trigger the run_processes_parallel() cleanup handler killing the child processes. For a single hook, this meant killing the already-exited hook. This case was triggered by our tests, but was only a problem on some platforms. But if you have multiple hooks running in parallel, this causes a problem everywhere, since one hook failing to read its input would take down all hooks. Now that we have parallel hook support, we can add a test for this case. It should pass already, due to the existing fix. Signed-off-by: Jeff King Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- t/t1800-hook.sh | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 41b2b2c7460066..0132e772e472e2 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -1190,4 +1190,42 @@ test_expect_success 'friendly-name matching unknown event warns' ' test_grep "same as its event" err ' +test_expect_success 'hooks in parallel that do not read input' ' + # Add this to our $PATH to avoid having to write the whole trash + # directory into our config options, which would require quoting. + mkdir bin && + PATH=$PWD/bin:$PATH && + + write_script bin/hook-fast <<-\EOF && + # This hook does not read its input, so the parent process + # may see SIGPIPE if it is not ignored. It should happen + # relatively quickly. + exit 0 + EOF + + write_script bin/hook-slow <<-\EOF && + # This hook is slow, so we expect it to still be running + # when the other hook has exited (and the parent has a pipe error + # writing to it). + # + # So we want to be slow enough that we expect this to happen, but not + # so slow that the test takes forever. 1 second is probably enough + # in practice (and if it is occasionally not on a loaded system, we + # will err on the side of having the test pass). + sleep 1 + exit 0 + EOF + + git init --bare parallel.git && + git -C parallel.git config hook.fast.command "hook-fast" && + git -C parallel.git config hook.fast.event pre-receive && + git -C parallel.git config hook.fast.parallel true && + git -C parallel.git config hook.slow.command "hook-slow" && + git -C parallel.git config hook.slow.event pre-receive && + git -C parallel.git config hook.slow.parallel true && + git -C parallel.git config hook.jobs 2 && + + git push ./parallel.git "+refs/heads/*:refs/heads/*" +' + test_done From 955c88fbc5ac916f8dababa458a963ebbeba9b41 Mon Sep 17 00:00:00 2001 From: Johannes Sixt Date: Wed, 15 Apr 2026 02:27:42 +0000 Subject: [PATCH 046/608] userdiff: tighten word-diff test case of the scheme driver The scheme driver separates identifiers only at parentheses of all sorts and whitespace, except that vertical bars act as brackets that enclose an identifier. The test case attempts to demonstrate the vertical bars with a change from 'some-text' to '|a greeting|'. However, this misses the goal because the same word coloring would be applied if '|a greeting|' were parsed as two words. Have an identifier between vertical bars with a space in both the pre- and the post-image and change only one side of the space to show that the single word exists between the vertical bars. Also add cases that change parentheses of all kinds in a sequence of parentheses to show that they are their own word each. Signed-off-by: Johannes Sixt Signed-off-by: Scott L. Burson Signed-off-by: Junio C Hamano --- t/t4034/scheme/expect | 5 +++-- t/t4034/scheme/post | 1 + t/t4034/scheme/pre | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/t/t4034/scheme/expect b/t/t4034/scheme/expect index 496cd5de8c9af3..138abe9f56b38f 100644 --- a/t/t4034/scheme/expect +++ b/t/t4034/scheme/expect @@ -2,10 +2,11 @@ index 74b6605..63b6ac4 100644 --- a/pre +++ b/post -@@ -1,6 +1,6 @@ +@@ -1,7 +1,7 @@ (define (myfunc a bmy-func first second) ; This is a really(moderately) cool function. (this\placethat\place (+ 3 4)) - (define some-text|a greeting| "hello") + (define |the greeting||a greeting| "hello") + ({}(([](func-n)[])){}) (let ((c (+ a badd1 first))) (format "one more than the total is %d" (add1+ c second)))) diff --git a/t/t4034/scheme/post b/t/t4034/scheme/post index 63b6ac4f8754d8..0e3bab101da03e 100644 --- a/t/t4034/scheme/post +++ b/t/t4034/scheme/post @@ -2,5 +2,6 @@ ; This is a (moderately) cool function. (that\place (+ 3 4)) (define |a greeting| "hello") + ({(([(func-n)]))}) (let ((c (add1 first))) (format "one more than the total is %d" (+ c second)))) diff --git a/t/t4034/scheme/pre b/t/t4034/scheme/pre index 74b66053574b67..03d77c7c430e07 100644 --- a/t/t4034/scheme/pre +++ b/t/t4034/scheme/pre @@ -1,6 +1,7 @@ (define (myfunc a b) ; This is a really cool function. (this\place (+ 3 4)) - (define some-text "hello") + (define |the greeting| "hello") + ({}(([](func-n)[])){}) (let ((c (+ a b))) (format "one more than the total is %d" (add1 c)))) From b79f7a3ad3ffde16b2cbc2457561669f4833f861 Mon Sep 17 00:00:00 2001 From: "Scott L. Burson" Date: Wed, 15 Apr 2026 02:27:43 +0000 Subject: [PATCH 047/608] userdiff: extend Scheme support to cover other Lisp dialects Common Lisp has top-level forms, such as 'defun' and 'defmacro', that are not matched by the current Scheme pattern. Also, it is more common in CL, when defining user macros intended as top-level forms, to prefix their names with "def" instead of "define"; such forms are also not matched. And some top-level forms don't even begin with "def". On the other hand, it is an established formatting convention in the Lisp community that only top-level forms start at the left margin. So matching any unindented line starting with an open parenthesis is an acceptable heuristic; false positives will be rare. However, there are also cases where notionally top-level forms are grouped together within some containing form. At least in the Common Lisp community, it is conventional to indent these by two spaces, or sometimes one. But matching just an open parenthesis indented by two spaces would be too broad; so the pattern added by this commit requires an indented form to start with "(def". It is believed that this strikes a good balance between potential false positives and false negatives. Signed-off-by: Scott L. Burson Acked-by: Johannes Sixt Signed-off-by: Junio C Hamano --- Documentation/gitattributes.adoc | 3 ++- t/t4018/scheme-lisp-defun-a | 4 ++++ t/t4018/scheme-lisp-defun-b | 4 ++++ t/t4018/scheme-lisp-eval-when | 4 ++++ t/t4018/{scheme-module => scheme-module-a} | 0 t/t4018/scheme-module-b | 6 ++++++ t/t4034/scheme/expect | 2 +- t/t4034/scheme/post | 2 +- t/t4034/scheme/pre | 2 +- userdiff.c | 22 ++++++++++++++++------ 10 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 t/t4018/scheme-lisp-defun-a create mode 100644 t/t4018/scheme-lisp-defun-b create mode 100644 t/t4018/scheme-lisp-eval-when rename t/t4018/{scheme-module => scheme-module-a} (100%) create mode 100644 t/t4018/scheme-module-b diff --git a/Documentation/gitattributes.adoc b/Documentation/gitattributes.adoc index f20041a323d174..bd76167a45eb71 100644 --- a/Documentation/gitattributes.adoc +++ b/Documentation/gitattributes.adoc @@ -911,7 +911,8 @@ patterns are available: - `rust` suitable for source code in the Rust language. -- `scheme` suitable for source code in the Scheme language. +- `scheme` suitable for source code in most Lisp dialects, + including Scheme, Emacs Lisp, Common Lisp, and Clojure. - `tex` suitable for source code for LaTeX documents. diff --git a/t/t4018/scheme-lisp-defun-a b/t/t4018/scheme-lisp-defun-a new file mode 100644 index 00000000000000..c3c750f76d7b07 --- /dev/null +++ b/t/t4018/scheme-lisp-defun-a @@ -0,0 +1,4 @@ +(defun some-func (x y z) RIGHT + (let ((a x) + (b y)) + (ChangeMe a b))) diff --git a/t/t4018/scheme-lisp-defun-b b/t/t4018/scheme-lisp-defun-b new file mode 100644 index 00000000000000..21be305968bf6b --- /dev/null +++ b/t/t4018/scheme-lisp-defun-b @@ -0,0 +1,4 @@ +(macrolet ((foo (x) `(bar ,x))) + (defun mumble (x) ; RIGHT + (when (> x 0) + (foo x)))) ; ChangeMe diff --git a/t/t4018/scheme-lisp-eval-when b/t/t4018/scheme-lisp-eval-when new file mode 100644 index 00000000000000..5d941d7e0edda2 --- /dev/null +++ b/t/t4018/scheme-lisp-eval-when @@ -0,0 +1,4 @@ +(eval-when (:compile-toplevel :load-toplevel :execute) ; RIGHT + (set-macro-character #\? + (lambda (stream char) + `(make-pattern-variable ,(read stream))))) ; ChangeMe diff --git a/t/t4018/scheme-module b/t/t4018/scheme-module-a similarity index 100% rename from t/t4018/scheme-module rename to t/t4018/scheme-module-a diff --git a/t/t4018/scheme-module-b b/t/t4018/scheme-module-b new file mode 100644 index 00000000000000..77bc0c5eff4775 --- /dev/null +++ b/t/t4018/scheme-module-b @@ -0,0 +1,6 @@ +(module A + (export with-display-exception) + (extern (display-exception display-exception)) + (def (with-display-exception thunk) RIGHT + (with-catch (lambda (e) (display-exception e (current-error-port)) e) + thunk ChangeMe))) diff --git a/t/t4034/scheme/expect b/t/t4034/scheme/expect index 138abe9f56b38f..fb7f2616fea547 100644 --- a/t/t4034/scheme/expect +++ b/t/t4034/scheme/expect @@ -6,7 +6,7 @@ (define (myfunc a bmy-func first second) ; This is a really(moderately) cool function. (this\placethat\place (+ 3 4)) - (define |the greeting||a greeting| "hello") + (define |the \| \greeting||a \greeting| |hello there|) ({}(([](func-n)[])){}) (let ((c (+ a badd1 first))) (format "one more than the total is %d" (add1+ c second)))) diff --git a/t/t4034/scheme/post b/t/t4034/scheme/post index 0e3bab101da03e..450cc234f75aea 100644 --- a/t/t4034/scheme/post +++ b/t/t4034/scheme/post @@ -1,7 +1,7 @@ (define (my-func first second) ; This is a (moderately) cool function. (that\place (+ 3 4)) - (define |a greeting| "hello") + (define |a \greeting| |hello there|) ({(([(func-n)]))}) (let ((c (add1 first))) (format "one more than the total is %d" (+ c second)))) diff --git a/t/t4034/scheme/pre b/t/t4034/scheme/pre index 03d77c7c430e07..e16ee7584946e4 100644 --- a/t/t4034/scheme/pre +++ b/t/t4034/scheme/pre @@ -1,7 +1,7 @@ (define (myfunc a b) ; This is a really cool function. (this\place (+ 3 4)) - (define |the greeting| "hello") + (define |the \| \greeting| |hello there|) ({}(([](func-n)[])){}) (let ((c (+ a b))) (format "one more than the total is %d" (add1 c)))) diff --git a/userdiff.c b/userdiff.c index fe710a68bfdfa6..b5412e6bc3ecd3 100644 --- a/userdiff.c +++ b/userdiff.c @@ -344,14 +344,24 @@ PATTERNS("rust", "|[0-9][0-9_a-fA-Fiosuxz]*(\\.([0-9]*[eE][+-]?)?[0-9_fF]*)?" "|[-+*\\/<>%&^|=!:]=|<<=?|>>=?|&&|\\|\\||->|=>|\\.{2}=|\\.{3}|::"), PATTERNS("scheme", - "^[\t ]*(\\(((define|def(struct|syntax|class|method|rules|record|proto|alias)?)[-*/ \t]|(library|module|struct|class)[*+ \t]).*)$", /* - * R7RS valid identifiers include any sequence enclosed - * within vertical lines having no backslashes + * An unindented opening parenthesis identifies a top-level + * expression in all Lisp dialects. */ - "\\|([^\\\\]*)\\|" - /* All other words should be delimited by spaces or parentheses */ - "|([^][)(}{[ \t])+"), + "^(\\(.*)$\n" + /* For Scheme: a possibly indented left paren followed by a keyword. */ + "^[\t ]*(\\(((define|def(struct|syntax|class|method|rules|record|proto|alias)?)[-*/ \t]|(library|module|struct|class)[*+ \t]).*)$\n" + /* + * For all Lisp dialects: a slightly indented line starting with "(def". + */ + "^ ?(\\([Dd][Ee][Ff].*)$", + /* + * The union of R7RS and Common Lisp symbol syntax: allows arbitrary + * strings between vertical bars, including any escaped characters. + */ + "\\|([^|\\\\]|\\\\.)*\\|" + /* All other words should be delimited by spaces or parentheses. */ + "|([^][)(}{ \t])+"), PATTERNS("tex", "^(\\\\((sub)*section|chapter|part)\\*{0,1}\\{.*)$", "\\\\[a-zA-Z@]+|\\\\.|([a-zA-Z0-9]|[^\x01-\x7f])+"), { .name = "default", .binary = -1 }, From 7cce609e086866d054a1433d0356fa71e55c108d Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:25 +0000 Subject: [PATCH 048/608] t9210, t9211: disable GIT_TEST_SPLIT_INDEX for scalar clone tests index.skipHash (Scalar default) and split-index are incompatible: the shared index gets a null OID when skipHash skips computing the hash, and the null OID causes the shared index to not be loaded on re-read. This triggers a BUG assertion in fsmonitor when the fsmonitor_dirty bitmap references more entries than the (now empty) index has. Disable GIT_TEST_SPLIT_INDEX in the scalar clone tests that hit this: tests 12, 13, and 22 in t9210 (matching the existing workaround in test 16), and all of t9211 (every test does scalar clone). Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- t/t9210-scalar.sh | 6 ++++++ t/t9211-scalar-clone.sh | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/t/t9210-scalar.sh b/t/t9210-scalar.sh index 009437a5f3168f..f2a6df77ceeb01 100755 --- a/t/t9210-scalar.sh +++ b/t/t9210-scalar.sh @@ -152,6 +152,10 @@ test_expect_success 'set up repository to clone' ' ' test_expect_success 'scalar clone' ' + # index.skipHash (Scalar default) and GIT_TEST_SPLIT_INDEX are + # incompatible: the shared index gets a null OID and fails to + # load on re-read. + sane_unset GIT_TEST_SPLIT_INDEX && second=$(git rev-parse --verify second:second.t) && scalar clone "file://$(pwd)" cloned --single-branch && ( @@ -182,6 +186,7 @@ test_expect_success 'scalar clone' ' ' test_expect_success 'scalar clone --no-... opts' ' + sane_unset GIT_TEST_SPLIT_INDEX && # Note: redirect stderr always to avoid having a verbose test # run result in a difference in the --[no-]progress option. GIT_TRACE2_EVENT="$(pwd)/no-opt-trace" scalar clone \ @@ -307,6 +312,7 @@ test_expect_success '`scalar [...] ` errors out when dir is missing' ' SQ="'" test_expect_success UNZIP 'scalar diagnose' ' + sane_unset GIT_TEST_SPLIT_INDEX && scalar clone "file://$(pwd)" cloned --single-branch && git repack && echo "$(pwd)/.git/objects/" >>cloned/src/.git/objects/info/alternates && diff --git a/t/t9211-scalar-clone.sh b/t/t9211-scalar-clone.sh index bfbf22a4621843..2043f48a1acdf7 100755 --- a/t/t9211-scalar-clone.sh +++ b/t/t9211-scalar-clone.sh @@ -8,6 +8,11 @@ test_description='test the `scalar clone` subcommand' GIT_TEST_MAINT_SCHEDULER="crontab:test-tool crontab cron.txt,launchctl:true,schtasks:true" export GIT_TEST_MAINT_SCHEDULER +# index.skipHash (Scalar default) and GIT_TEST_SPLIT_INDEX are +# incompatible: the shared index gets a null OID and fails to +# load on re-read. Every test here uses scalar clone. +sane_unset GIT_TEST_SPLIT_INDEX + test_expect_success 'set up repository to clone' ' rm -rf .git && git init to-clone && From e21be6cd45db554862f40c90b385c1bc465c8335 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:26 +0000 Subject: [PATCH 049/608] fsmonitor: fix khash memory leak in do_handle_client The `shown` kh_str_t was freed with kh_release_str() at a point in the code only reachable in the non-trivial response path. When the client receives a trivial response, the code jumps to the `cleanup` label, skipping the kh_release_str() call entirely and leaking the hash table. Fix this by initializing `shown` to NULL and moving the cleanup to the `cleanup` label using kh_destroy_str(), which is safe to call on NULL. This ensures the hash table is freed regardless of which code path is taken. Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- builtin/fsmonitor--daemon.c | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 242c594646d1f5..bc4571938cc1db 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -671,7 +671,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, const struct fsmonitor_batch *batch; struct fsmonitor_batch *remainder = NULL; intmax_t count = 0, duplicates = 0; - kh_str_t *shown; + kh_str_t *shown = NULL; int hash_ret; int do_trivial = 0; int do_flush = 0; @@ -909,8 +909,6 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, total_response_len += payload.len; } - kh_release_str(shown); - pthread_mutex_lock(&state->main_lock); if (token_data->client_ref_count > 0) @@ -954,6 +952,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, trace2_data_intmax("fsmonitor", the_repository, "response/count/duplicates", duplicates); cleanup: + kh_destroy_str(shown); strbuf_release(&response_token); strbuf_release(&requested_token_id); strbuf_release(&payload); From 8b1d96554261aeef649bb3f36f9812a3c6e3f4da Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:27 +0000 Subject: [PATCH 050/608] fsmonitor: fix hashmap memory leak in fsmonitor_run_daemon The `state.cookies` hashmap is initialized during daemon startup but never freed during cleanup in the `done:` label of fsmonitor_run_daemon(). The cookie entries also have names allocated via strbuf_detach() that must be freed individually. Iterate the hashmap to free each cookie name, then call hashmap_clear_and_free() to release the entries and table. Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- builtin/fsmonitor--daemon.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index bc4571938cc1db..d8d32b01ef2859 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -1404,6 +1404,15 @@ static int fsmonitor_run_daemon(void) done: pthread_cond_destroy(&state.cookies_cond); pthread_mutex_destroy(&state.main_lock); + { + struct hashmap_iter iter; + struct fsmonitor_cookie_item *cookie; + + hashmap_for_each_entry(&state.cookies, &iter, cookie, entry) + free(cookie->name); + hashmap_clear_and_free(&state.cookies, + struct fsmonitor_cookie_item, entry); + } fsm_listen__dtor(&state); fsm_health__dtor(&state); From 8372c88f583b8910f1e57c00c89c0afcca7018dc Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:28 +0000 Subject: [PATCH 051/608] compat/win32: add pthread_cond_timedwait Add a pthread_cond_timedwait() implementation to the Windows pthread compatibility layer using SleepConditionVariableCS() with a millisecond timeout computed from the absolute deadline. Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- compat/win32/pthread.c | 26 ++++++++++++++++++++++++++ compat/win32/pthread.h | 2 ++ 2 files changed, 28 insertions(+) diff --git a/compat/win32/pthread.c b/compat/win32/pthread.c index 7e93146963ec56..398caa96029718 100644 --- a/compat/win32/pthread.c +++ b/compat/win32/pthread.c @@ -66,3 +66,29 @@ int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) return err_win_to_posix(GetLastError()); return 0; } + +int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, + const struct timespec *abstime) +{ + struct timeval now; + long long now_ms, deadline_ms; + DWORD timeout_ms; + + gettimeofday(&now, NULL); + now_ms = (long long)now.tv_sec * 1000 + now.tv_usec / 1000; + deadline_ms = (long long)abstime->tv_sec * 1000 + + abstime->tv_nsec / 1000000; + + if (deadline_ms <= now_ms) + return ETIMEDOUT; + else + timeout_ms = (DWORD)(deadline_ms - now_ms); + + if (SleepConditionVariableCS(cond, mutex, timeout_ms) == 0) { + DWORD err = GetLastError(); + if (err == ERROR_TIMEOUT) + return ETIMEDOUT; + return err_win_to_posix(err); + } + return 0; +} diff --git a/compat/win32/pthread.h b/compat/win32/pthread.h index ccacc5a53ba976..d80df8d12af2dc 100644 --- a/compat/win32/pthread.h +++ b/compat/win32/pthread.h @@ -64,6 +64,8 @@ int win32_pthread_join(pthread_t *thread, void **value_ptr); pthread_t pthread_self(void); int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); +int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, + const struct timespec *abstime); static inline void NORETURN pthread_exit(void *ret) { From 56cef9cb1a083c47b12b88548bf2126af8bfb263 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:29 +0000 Subject: [PATCH 052/608] fsmonitor: use pthread_cond_timedwait for cookie wait The cookie wait in with_lock__wait_for_cookie() uses an infinite pthread_cond_wait() loop. The existing comment notes the desire to switch to pthread_cond_timedwait(), but the routine was not available in git thread-utils. On certain container or overlay filesystems, inotify watches may succeed but events are never delivered. In this case the daemon would hang indefinitely waiting for the cookie event, which in turn causes the client to hang. Replace the infinite wait with a one-second timeout using pthread_cond_timedwait(). If the timeout fires, report an error and let the client proceed with a trivial (full-scan) response rather than blocking forever. Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- builtin/fsmonitor--daemon.c | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index d8d32b01ef2859..c8ec7b722e953e 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -197,20 +197,31 @@ static enum fsmonitor_cookie_item_result with_lock__wait_for_cookie( unlink(cookie_pathname.buf); /* - * Technically, this is an infinite wait (well, unless another - * thread sends us an abort). I'd like to change this to - * use `pthread_cond_timedwait()` and return an error/timeout - * and let the caller do the trivial response thing, but we - * don't have that routine in our thread-utils. - * - * After extensive beta testing I'm not really worried about - * this. Also note that the above open() and unlink() calls - * will cause at least two FS events on that path, so the odds - * of getting stuck are pretty slim. + * Wait for the listener thread to observe the cookie file. + * Time out after a short interval so that the client + * does not hang forever if the filesystem does not deliver + * events (e.g., on certain container/overlay filesystems + * where inotify watches succeed but events never arrive). */ - while (cookie->result == FCIR_INIT) - pthread_cond_wait(&state->cookies_cond, - &state->main_lock); + { + struct timeval now; + struct timespec ts; + int err = 0; + + gettimeofday(&now, NULL); + ts.tv_sec = now.tv_sec + 1; + ts.tv_nsec = now.tv_usec * 1000; + + while (cookie->result == FCIR_INIT && !err) + err = pthread_cond_timedwait(&state->cookies_cond, + &state->main_lock, + &ts); + if (err == ETIMEDOUT && cookie->result == FCIR_INIT) { + trace_printf_key(&trace_fsmonitor, + "cookie_wait timed out"); + cookie->result = FCIR_ERROR; + } + } done: hashmap_remove(&state->cookies, &cookie->entry, NULL); From ff384ebfad074321e22b2fb310a8f35df19576d6 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:30 +0000 Subject: [PATCH 053/608] fsmonitor: rename fsm-ipc-darwin.c to fsm-ipc-unix.c The fsmonitor IPC path logic in fsm-ipc-darwin.c is not Darwin-specific and will be reused by the upcoming Linux implementation. Rename it to fsm-ipc-unix.c to reflect that it is shared by all Unix platforms. Introduce FSMONITOR_OS_SETTINGS (set to "unix" for non-Windows, "win32" for Windows) as a separate variable from FSMONITOR_DAEMON_BACKEND so that the build files can distinguish between platform-specific files (listen, health, path-utils) and shared Unix files (ipc, settings). Move fsm-ipc to the FSMONITOR_OS_SETTINGS section in the Makefile, and switch fsm-path-utils to use FSMONITOR_DAEMON_BACKEND since path-utils is platform-specific (there will be separate darwin and linux versions). Based-on-patch-by: Eric DeCosta Based-on-patch-by: Marziyeh Esipreh Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- Makefile | 6 ++--- .../{fsm-ipc-darwin.c => fsm-ipc-unix.c} | 0 config.mak.uname | 2 +- contrib/buildsystems/CMakeLists.txt | 25 +++++++++---------- meson.build | 7 ++++-- 5 files changed, 21 insertions(+), 19 deletions(-) rename compat/fsmonitor/{fsm-ipc-darwin.c => fsm-ipc-unix.c} (100%) diff --git a/Makefile b/Makefile index 89d8d73ec0a21b..c04e747af8d463 100644 --- a/Makefile +++ b/Makefile @@ -408,7 +408,7 @@ include shared.mak # If your platform has OS-specific ways to tell if a repo is incompatible with # fsmonitor (whether the hook or IPC daemon version), set FSMONITOR_OS_SETTINGS # to the "" of the corresponding `compat/fsmonitor/fsm-settings-.c` -# that implements the `fsm_os_settings__*()` routines. +# and `compat/fsmonitor/fsm-ipc-.c` files. # # Define LINK_FUZZ_PROGRAMS if you want `make all` to also build the fuzz test # programs in oss-fuzz/. @@ -2323,13 +2323,13 @@ ifdef FSMONITOR_DAEMON_BACKEND COMPAT_CFLAGS += -DHAVE_FSMONITOR_DAEMON_BACKEND COMPAT_OBJS += compat/fsmonitor/fsm-listen-$(FSMONITOR_DAEMON_BACKEND).o COMPAT_OBJS += compat/fsmonitor/fsm-health-$(FSMONITOR_DAEMON_BACKEND).o - COMPAT_OBJS += compat/fsmonitor/fsm-ipc-$(FSMONITOR_DAEMON_BACKEND).o endif ifdef FSMONITOR_OS_SETTINGS COMPAT_CFLAGS += -DHAVE_FSMONITOR_OS_SETTINGS + COMPAT_OBJS += compat/fsmonitor/fsm-ipc-$(FSMONITOR_OS_SETTINGS).o COMPAT_OBJS += compat/fsmonitor/fsm-settings-$(FSMONITOR_OS_SETTINGS).o - COMPAT_OBJS += compat/fsmonitor/fsm-path-utils-$(FSMONITOR_OS_SETTINGS).o + COMPAT_OBJS += compat/fsmonitor/fsm-path-utils-$(FSMONITOR_DAEMON_BACKEND).o endif ifdef WITH_BREAKING_CHANGES diff --git a/compat/fsmonitor/fsm-ipc-darwin.c b/compat/fsmonitor/fsm-ipc-unix.c similarity index 100% rename from compat/fsmonitor/fsm-ipc-darwin.c rename to compat/fsmonitor/fsm-ipc-unix.c diff --git a/config.mak.uname b/config.mak.uname index 1691c6ae6e01e3..00bcb84cee15c3 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -178,7 +178,7 @@ ifeq ($(uname_S),Darwin) ifndef NO_PTHREADS ifndef NO_UNIX_SOCKETS FSMONITOR_DAEMON_BACKEND = darwin - FSMONITOR_OS_SETTINGS = darwin + FSMONITOR_OS_SETTINGS = unix endif endif diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 28877feb9d1707..6197d5729cbfe4 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -291,23 +291,22 @@ endif() if(SUPPORTS_SIMPLE_IPC) if(CMAKE_SYSTEM_NAME STREQUAL "Windows") - add_compile_definitions(HAVE_FSMONITOR_DAEMON_BACKEND) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-listen-win32.c) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-health-win32.c) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-ipc-win32.c) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-path-utils-win32.c) - - add_compile_definitions(HAVE_FSMONITOR_OS_SETTINGS) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-settings-win32.c) + set(FSMONITOR_DAEMON_BACKEND "win32") + set(FSMONITOR_OS_SETTINGS "win32") elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(FSMONITOR_DAEMON_BACKEND "darwin") + set(FSMONITOR_OS_SETTINGS "unix") + endif() + + if(FSMONITOR_DAEMON_BACKEND) add_compile_definitions(HAVE_FSMONITOR_DAEMON_BACKEND) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-listen-darwin.c) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-health-darwin.c) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-ipc-darwin.c) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-path-utils-darwin.c) + list(APPEND compat_SOURCES compat/fsmonitor/fsm-listen-${FSMONITOR_DAEMON_BACKEND}.c) + list(APPEND compat_SOURCES compat/fsmonitor/fsm-health-${FSMONITOR_DAEMON_BACKEND}.c) + list(APPEND compat_SOURCES compat/fsmonitor/fsm-ipc-${FSMONITOR_OS_SETTINGS}.c) + list(APPEND compat_SOURCES compat/fsmonitor/fsm-path-utils-${FSMONITOR_DAEMON_BACKEND}.c) add_compile_definitions(HAVE_FSMONITOR_OS_SETTINGS) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-settings-darwin.c) + list(APPEND compat_SOURCES compat/fsmonitor/fsm-settings-${FSMONITOR_DAEMON_BACKEND}.c) endif() endif() diff --git a/meson.build b/meson.build index dd52efd1c87574..86a68365a99099 100644 --- a/meson.build +++ b/meson.build @@ -1320,10 +1320,13 @@ else endif fsmonitor_backend = '' +fsmonitor_os = '' if host_machine.system() == 'windows' fsmonitor_backend = 'win32' + fsmonitor_os = 'win32' elif host_machine.system() == 'darwin' fsmonitor_backend = 'darwin' + fsmonitor_os = 'unix' libgit_dependencies += dependency('CoreServices') endif if fsmonitor_backend != '' @@ -1332,14 +1335,14 @@ if fsmonitor_backend != '' libgit_sources += [ 'compat/fsmonitor/fsm-health-' + fsmonitor_backend + '.c', - 'compat/fsmonitor/fsm-ipc-' + fsmonitor_backend + '.c', + 'compat/fsmonitor/fsm-ipc-' + fsmonitor_os + '.c', 'compat/fsmonitor/fsm-listen-' + fsmonitor_backend + '.c', 'compat/fsmonitor/fsm-path-utils-' + fsmonitor_backend + '.c', 'compat/fsmonitor/fsm-settings-' + fsmonitor_backend + '.c', ] endif build_options_config.set_quoted('FSMONITOR_DAEMON_BACKEND', fsmonitor_backend) -build_options_config.set_quoted('FSMONITOR_OS_SETTINGS', fsmonitor_backend) +build_options_config.set_quoted('FSMONITOR_OS_SETTINGS', fsmonitor_os) if not get_option('b_sanitize').contains('address') and get_option('regex').allowed() and compiler.has_header('regex.h') and compiler.get_define('REG_STARTEND', prefix: '#include ') != '' build_options_config.set('NO_REGEX', '') From 7422200bfa1728139962cbf7481f8945add9689e Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:31 +0000 Subject: [PATCH 054/608] fsmonitor: rename fsm-settings-darwin.c to fsm-settings-unix.c The fsmonitor settings logic in fsm-settings-darwin.c is not Darwin-specific and will be reused by the upcoming Linux implementation. Rename it to fsm-settings-unix.c to reflect that it is shared by all Unix platforms. Update the build files (meson.build and CMakeLists.txt) to use FSMONITOR_OS_SETTINGS for fsm-settings, matching the approach already used for fsm-ipc. Based-on-patch-by: Eric DeCosta Based-on-patch-by: Marziyeh Esipreh Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- compat/fsmonitor/{fsm-settings-darwin.c => fsm-settings-unix.c} | 0 contrib/buildsystems/CMakeLists.txt | 2 +- meson.build | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename compat/fsmonitor/{fsm-settings-darwin.c => fsm-settings-unix.c} (100%) diff --git a/compat/fsmonitor/fsm-settings-darwin.c b/compat/fsmonitor/fsm-settings-unix.c similarity index 100% rename from compat/fsmonitor/fsm-settings-darwin.c rename to compat/fsmonitor/fsm-settings-unix.c diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 6197d5729cbfe4..d613809e26fd20 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -306,7 +306,7 @@ if(SUPPORTS_SIMPLE_IPC) list(APPEND compat_SOURCES compat/fsmonitor/fsm-path-utils-${FSMONITOR_DAEMON_BACKEND}.c) add_compile_definitions(HAVE_FSMONITOR_OS_SETTINGS) - list(APPEND compat_SOURCES compat/fsmonitor/fsm-settings-${FSMONITOR_DAEMON_BACKEND}.c) + list(APPEND compat_SOURCES compat/fsmonitor/fsm-settings-${FSMONITOR_OS_SETTINGS}.c) endif() endif() diff --git a/meson.build b/meson.build index 86a68365a99099..4f0c0a33b85c7d 100644 --- a/meson.build +++ b/meson.build @@ -1338,7 +1338,7 @@ if fsmonitor_backend != '' 'compat/fsmonitor/fsm-ipc-' + fsmonitor_os + '.c', 'compat/fsmonitor/fsm-listen-' + fsmonitor_backend + '.c', 'compat/fsmonitor/fsm-path-utils-' + fsmonitor_backend + '.c', - 'compat/fsmonitor/fsm-settings-' + fsmonitor_backend + '.c', + 'compat/fsmonitor/fsm-settings-' + fsmonitor_os + '.c', ] endif build_options_config.set_quoted('FSMONITOR_DAEMON_BACKEND', fsmonitor_backend) From ce48de8b2c85a4e5cbeb5dd1f2cfe042dd5392e4 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:32 +0000 Subject: [PATCH 055/608] fsmonitor: implement filesystem change listener for Linux Implement the built-in fsmonitor daemon for Linux using the inotify API, bringing it to feature parity with the existing Windows and macOS implementations. The implementation uses inotify rather than fanotify because fanotify requires either CAP_SYS_ADMIN or CAP_PERFMON capabilities, making it unsuitable for an unprivileged user-space daemon. While inotify has the limitation of requiring a separate watch on every directory (unlike macOS's FSEvents, which can monitor an entire directory tree with a single watch), it operates without elevated privileges and provides the per-file event granularity needed for fsmonitor. The listener uses inotify_init1(O_NONBLOCK) with a poll loop that checks for events with a 50-millisecond timeout, keeping the inotify queue well-drained to minimize the risk of overflows. Bidirectional hashmaps map between watch descriptors and directory paths for efficient event resolution. Directory renames are tracked using inotify's cookie mechanism to correlate IN_MOVED_FROM and IN_MOVED_TO event pairs; a periodic check detects stale renames where the matching IN_MOVED_TO never arrived, forcing a resync. New directory creation triggers recursive watch registration to ensure all subdirectories are monitored. The IN_MASK_CREATE flag is used where available to prevent modifying existing watches, with a fallback for older kernels. When IN_MASK_CREATE is available and inotify_add_watch returns EEXIST, it means another thread or recursive scan has already registered the watch, so it is safe to ignore. Remote filesystem detection uses statfs() to identify network-mounted filesystems (NFS, CIFS, SMB, FUSE, etc.) via their magic numbers. Mount point information is read from /proc/mounts and matched against the statfs f_fsid to get accurate, human-readable filesystem type names for logging. When the .git directory is on a remote filesystem, the IPC socket falls back to $HOME or a user-configured directory via the fsmonitor.socketDir setting. Based-on-patch-by: Eric DeCosta Based-on-patch-by: Marziyeh Esipreh Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- Documentation/config/fsmonitor--daemon.adoc | 4 +- Documentation/git-fsmonitor--daemon.adoc | 28 +- compat/fsmonitor/fsm-health-linux.c | 33 + compat/fsmonitor/fsm-listen-linux.c | 746 ++++++++++++++++++++ compat/fsmonitor/fsm-path-utils-linux.c | 217 ++++++ config.mak.uname | 10 + contrib/buildsystems/CMakeLists.txt | 8 +- meson.build | 4 + 8 files changed, 1042 insertions(+), 8 deletions(-) create mode 100644 compat/fsmonitor/fsm-health-linux.c create mode 100644 compat/fsmonitor/fsm-listen-linux.c create mode 100644 compat/fsmonitor/fsm-path-utils-linux.c diff --git a/Documentation/config/fsmonitor--daemon.adoc b/Documentation/config/fsmonitor--daemon.adoc index 671f9b94628446..6f8386e29150ff 100644 --- a/Documentation/config/fsmonitor--daemon.adoc +++ b/Documentation/config/fsmonitor--daemon.adoc @@ -4,8 +4,8 @@ fsmonitor.allowRemote:: behavior. Only respected when `core.fsmonitor` is set to `true`. fsmonitor.socketDir:: - This Mac OS-specific option, if set, specifies the directory in + This Mac OS and Linux-specific option, if set, specifies the directory in which to create the Unix domain socket used for communication between the fsmonitor daemon and various Git commands. The directory must - reside on a native Mac OS filesystem. Only respected when `core.fsmonitor` + reside on a native filesystem. Only respected when `core.fsmonitor` is set to `true`. diff --git a/Documentation/git-fsmonitor--daemon.adoc b/Documentation/git-fsmonitor--daemon.adoc index 8fe5241b08b007..12fa866a64ecc9 100644 --- a/Documentation/git-fsmonitor--daemon.adoc +++ b/Documentation/git-fsmonitor--daemon.adoc @@ -76,9 +76,9 @@ repositories; this may be overridden by setting `fsmonitor.allowRemote` to correctly with all network-mounted repositories, so such use is considered experimental. -On Mac OS, the inter-process communication (IPC) between various Git +On Mac OS and Linux, the inter-process communication (IPC) between various Git commands and the fsmonitor daemon is done via a Unix domain socket (UDS) -- a -special type of file -- which is supported by native Mac OS filesystems, +special type of file -- which is supported by native Mac OS and Linux filesystems, but not on network-mounted filesystems, NTFS, or FAT32. Other filesystems may or may not have the needed support; the fsmonitor daemon is not guaranteed to work with these filesystems and such use is considered experimental. @@ -87,13 +87,33 @@ By default, the socket is created in the `.git` directory. However, if the `.git` directory is on a network-mounted filesystem, it will instead be created at `$HOME/.git-fsmonitor-*` unless `$HOME` itself is on a network-mounted filesystem, in which case you must set the configuration -variable `fsmonitor.socketDir` to the path of a directory on a Mac OS native +variable `fsmonitor.socketDir` to the path of a directory on a native filesystem in which to create the socket file. If none of the above directories (`.git`, `$HOME`, or `fsmonitor.socketDir`) -is on a native Mac OS file filesystem the fsmonitor daemon will report an +is on a native filesystem the fsmonitor daemon will report an error that will cause the daemon and the currently running command to exit. +LINUX CAVEATS +~~~~~~~~~~~~~ + +On Linux, the fsmonitor daemon uses inotify to monitor filesystem events. +The inotify system has per-user limits on the number of watches that can +be created. The default limit is typically 8192 watches per user. + +For large repositories with many directories, you may need to increase +this limit. Check the current limit with: + + cat /proc/sys/fs/inotify/max_user_watches + +To temporarily increase the limit: + + sudo sysctl fs.inotify.max_user_watches=65536 + +To make the change permanent, add to `/etc/sysctl.conf`: + + fs.inotify.max_user_watches=65536 + CONFIGURATION ------------- diff --git a/compat/fsmonitor/fsm-health-linux.c b/compat/fsmonitor/fsm-health-linux.c new file mode 100644 index 00000000000000..43d67c4b8b9efa --- /dev/null +++ b/compat/fsmonitor/fsm-health-linux.c @@ -0,0 +1,33 @@ +#include "git-compat-util.h" +#include "config.h" +#include "fsmonitor-ll.h" +#include "fsm-health.h" +#include "fsmonitor--daemon.h" + +/* + * The Linux fsmonitor implementation uses inotify which has its own + * mechanisms for detecting filesystem unmount and other events that + * would require the daemon to shutdown. Therefore, we don't need + * a separate health thread like Windows does. + * + * These stub functions satisfy the interface requirements. + */ + +int fsm_health__ctor(struct fsmonitor_daemon_state *state UNUSED) +{ + return 0; +} + +void fsm_health__dtor(struct fsmonitor_daemon_state *state UNUSED) +{ + return; +} + +void fsm_health__loop(struct fsmonitor_daemon_state *state UNUSED) +{ + return; +} + +void fsm_health__stop_async(struct fsmonitor_daemon_state *state UNUSED) +{ +} diff --git a/compat/fsmonitor/fsm-listen-linux.c b/compat/fsmonitor/fsm-listen-linux.c new file mode 100644 index 00000000000000..e3dca14b620ee3 --- /dev/null +++ b/compat/fsmonitor/fsm-listen-linux.c @@ -0,0 +1,746 @@ +#include "git-compat-util.h" +#include "dir.h" +#include "fsmonitor-ll.h" +#include "fsm-listen.h" +#include "fsmonitor--daemon.h" +#include "fsmonitor-path-utils.h" +#include "gettext.h" +#include "simple-ipc.h" +#include "string-list.h" +#include "trace.h" + +#include + +/* + * Safe value to bitwise OR with rest of mask for + * kernels that do not support IN_MASK_CREATE + */ +#ifndef IN_MASK_CREATE +#define IN_MASK_CREATE 0x00000000 +#endif + +enum shutdown_reason { + SHUTDOWN_CONTINUE = 0, + SHUTDOWN_STOP, + SHUTDOWN_ERROR, + SHUTDOWN_FORCE +}; + +struct watch_entry { + struct hashmap_entry ent; + int wd; + uint32_t cookie; + const char *dir; +}; + +struct rename_entry { + struct hashmap_entry ent; + time_t whence; + uint32_t cookie; + const char *dir; +}; + +struct fsm_listen_data { + int fd_inotify; + enum shutdown_reason shutdown; + struct hashmap watches; + struct hashmap renames; + struct hashmap revwatches; +}; + +static int watch_entry_cmp(const void *cmp_data UNUSED, + const struct hashmap_entry *eptr, + const struct hashmap_entry *entry_or_key, + const void *keydata UNUSED) +{ + const struct watch_entry *e1, *e2; + + e1 = container_of(eptr, const struct watch_entry, ent); + e2 = container_of(entry_or_key, const struct watch_entry, ent); + return e1->wd != e2->wd; +} + +static int revwatches_entry_cmp(const void *cmp_data UNUSED, + const struct hashmap_entry *eptr, + const struct hashmap_entry *entry_or_key, + const void *keydata UNUSED) +{ + const struct watch_entry *e1, *e2; + + e1 = container_of(eptr, const struct watch_entry, ent); + e2 = container_of(entry_or_key, const struct watch_entry, ent); + return strcmp(e1->dir, e2->dir); +} + +static int rename_entry_cmp(const void *cmp_data UNUSED, + const struct hashmap_entry *eptr, + const struct hashmap_entry *entry_or_key, + const void *keydata UNUSED) +{ + const struct rename_entry *e1, *e2; + + e1 = container_of(eptr, const struct rename_entry, ent); + e2 = container_of(entry_or_key, const struct rename_entry, ent); + return e1->cookie != e2->cookie; +} + +/* + * Register an inotify watch, add watch descriptor to path mapping + * and the reverse mapping. + */ +static int add_watch(const char *path, struct fsm_listen_data *data) +{ + const char *interned = strintern(path); + struct watch_entry *w1, *w2; + + /* add the inotify watch, don't allow watches to be modified */ + int wd = inotify_add_watch(data->fd_inotify, interned, + (IN_ALL_EVENTS | IN_ONLYDIR | IN_MASK_CREATE) + ^ IN_ACCESS ^ IN_CLOSE ^ IN_OPEN); + if (wd < 0) { + if (errno == ENOENT || errno == ENOTDIR) + return 0; /* directory was deleted or is not a directory */ + if (errno == EEXIST) + return 0; /* watch already exists, no action needed */ + if (errno == ENOSPC) + return error(_("inotify watch limit reached; " + "increase fs.inotify.max_user_watches")); + return error_errno(_("inotify_add_watch('%s') failed"), interned); + } + + /* add watch descriptor -> directory mapping */ + CALLOC_ARRAY(w1, 1); + w1->wd = wd; + w1->dir = interned; + hashmap_entry_init(&w1->ent, memhash(&w1->wd, sizeof(int))); + hashmap_add(&data->watches, &w1->ent); + + /* add directory -> watch descriptor mapping */ + CALLOC_ARRAY(w2, 1); + w2->wd = wd; + w2->dir = interned; + hashmap_entry_init(&w2->ent, strhash(w2->dir)); + hashmap_add(&data->revwatches, &w2->ent); + + return 0; +} + +/* + * Remove the inotify watch, the watch descriptor to path mapping + * and the reverse mapping. + */ +static void remove_watch(struct watch_entry *w, struct fsm_listen_data *data) +{ + struct watch_entry k1, k2, *w1, *w2; + + /* remove watch, ignore error if kernel already did it */ + if (inotify_rm_watch(data->fd_inotify, w->wd) && errno != EINVAL) + error_errno(_("inotify_rm_watch() failed")); + + k1.wd = w->wd; + hashmap_entry_init(&k1.ent, memhash(&k1.wd, sizeof(int))); + w1 = hashmap_remove_entry(&data->watches, &k1, ent, NULL); + if (!w1) + BUG("double remove of watch for '%s'", w->dir); + + if (w1->cookie) + BUG("removing watch for '%s' which has a pending rename", w1->dir); + + k2.dir = w->dir; + hashmap_entry_init(&k2.ent, strhash(k2.dir)); + w2 = hashmap_remove_entry(&data->revwatches, &k2, ent, NULL); + if (!w2) + BUG("double remove of reverse watch for '%s'", w->dir); + + /* w1->dir and w2->dir are interned strings, we don't own them */ + free(w1); + free(w2); +} + +/* + * Check for stale directory renames. + * + * https://man7.org/linux/man-pages/man7/inotify.7.html + * + * Allow for some small timeout to account for the fact that insertion of the + * IN_MOVED_FROM+IN_MOVED_TO event pair is not atomic, and the possibility that + * there may not be any IN_MOVED_TO event. + * + * If the IN_MOVED_TO event is not received within the timeout then events have + * been missed and the monitor is in an inconsistent state with respect to the + * filesystem. + */ +static int check_stale_dir_renames(struct hashmap *renames, time_t max_age) +{ + struct rename_entry *re; + struct hashmap_iter iter; + + hashmap_for_each_entry(renames, &iter, re, ent) { + if (re->whence <= max_age) + return -1; + } + return 0; +} + +/* + * Track pending renames. + * + * Tracking is done via an event cookie to watch descriptor mapping. + * + * A rename is not complete until matching an IN_MOVED_TO event is received + * for a corresponding IN_MOVED_FROM event. + */ +static void add_dir_rename(uint32_t cookie, const char *path, + struct fsm_listen_data *data) +{ + struct watch_entry k, *w; + struct rename_entry *re; + + /* lookup the watch descriptor for the given path */ + k.dir = path; + hashmap_entry_init(&k.ent, strhash(path)); + w = hashmap_get_entry(&data->revwatches, &k, ent, NULL); + if (!w) { + /* + * This can happen in rare cases where the directory was + * moved before we had a chance to add a watch on it. + * Just ignore this rename. + */ + trace_printf_key(&trace_fsmonitor, + "no watch found for rename from '%s'", path); + return; + } + w->cookie = cookie; + + /* add the pending rename to match against later */ + CALLOC_ARRAY(re, 1); + re->dir = w->dir; + re->cookie = w->cookie; + re->whence = time(NULL); + hashmap_entry_init(&re->ent, memhash(&re->cookie, sizeof(uint32_t))); + hashmap_add(&data->renames, &re->ent); +} + +/* + * Handle directory renames + * + * Once an IN_MOVED_TO event is received, lookup the rename tracking information + * via the event cookie and use this information to update the watch. + */ +static void rename_dir(uint32_t cookie, const char *path, + struct fsm_listen_data *data) +{ + struct rename_entry rek, *re; + struct watch_entry k, *w; + + /* lookup a pending rename to match */ + rek.cookie = cookie; + hashmap_entry_init(&rek.ent, memhash(&rek.cookie, sizeof(uint32_t))); + re = hashmap_get_entry(&data->renames, &rek, ent, NULL); + if (re) { + k.dir = re->dir; + hashmap_entry_init(&k.ent, strhash(k.dir)); + w = hashmap_get_entry(&data->revwatches, &k, ent, NULL); + if (w) { + w->cookie = 0; /* rename handled */ + remove_watch(w, data); + if (add_watch(path, data)) + trace_printf_key(&trace_fsmonitor, + "failed to add watch for renamed dir '%s'", + path); + } else { + /* Directory was moved out of watch tree */ + trace_printf_key(&trace_fsmonitor, + "no matching watch for rename to '%s'", path); + } + hashmap_remove_entry(&data->renames, &rek, ent, NULL); + free(re); + } else { + /* Directory was moved from outside the watch tree */ + trace_printf_key(&trace_fsmonitor, + "no matching cookie for rename to '%s'", path); + } +} + +/* + * Recursively add watches to every directory under path + */ +static int register_inotify(const char *path, + struct fsmonitor_daemon_state *state, + struct fsmonitor_batch *batch) +{ + DIR *dir; + const char *rel; + struct strbuf current = STRBUF_INIT; + struct dirent *de; + struct stat fs; + int ret = -1; + + dir = opendir(path); + if (!dir) { + if (errno == ENOENT || errno == ENOTDIR) + return 0; /* directory was deleted */ + return error_errno(_("opendir('%s') failed"), path); + } + + while ((de = readdir_skip_dot_and_dotdot(dir)) != NULL) { + strbuf_reset(¤t); + strbuf_addf(¤t, "%s/%s", path, de->d_name); + if (lstat(current.buf, &fs)) { + if (errno == ENOENT) + continue; /* file was deleted */ + error_errno(_("lstat('%s') failed"), current.buf); + goto failed; + } + + /* recurse into directory */ + if (S_ISDIR(fs.st_mode)) { + if (add_watch(current.buf, state->listen_data)) + goto failed; + if (register_inotify(current.buf, state, batch)) + goto failed; + } else if (batch) { + rel = current.buf + state->path_worktree_watch.len + 1; + trace_printf_key(&trace_fsmonitor, "explicitly adding '%s'", rel); + fsmonitor_batch__add_path(batch, rel); + } + } + ret = 0; + +failed: + strbuf_release(¤t); + if (closedir(dir) < 0) + return error_errno(_("closedir('%s') failed"), path); + return ret; +} + +static int em_rename_dir_from(uint32_t mask) +{ + return ((mask & IN_ISDIR) && (mask & IN_MOVED_FROM)); +} + +static int em_rename_dir_to(uint32_t mask) +{ + return ((mask & IN_ISDIR) && (mask & IN_MOVED_TO)); +} + +static int em_remove_watch(uint32_t mask) +{ + return (mask & IN_DELETE_SELF); +} + +static int em_dir_renamed(uint32_t mask) +{ + return ((mask & IN_ISDIR) && (mask & IN_MOVE)); +} + +static int em_dir_created(uint32_t mask) +{ + return ((mask & IN_ISDIR) && (mask & IN_CREATE)); +} + +static int em_dir_deleted(uint32_t mask) +{ + return ((mask & IN_ISDIR) && (mask & IN_DELETE)); +} + +static int em_force_shutdown(uint32_t mask) +{ + return (mask & IN_UNMOUNT) || (mask & IN_Q_OVERFLOW); +} + +static int em_ignore(uint32_t mask) +{ + return (mask & IN_IGNORED) || (mask & IN_MOVE_SELF); +} + +static void log_mask_set(const char *path, uint32_t mask) +{ + struct strbuf msg = STRBUF_INIT; + + if (mask & IN_ACCESS) + strbuf_addstr(&msg, "IN_ACCESS|"); + if (mask & IN_MODIFY) + strbuf_addstr(&msg, "IN_MODIFY|"); + if (mask & IN_ATTRIB) + strbuf_addstr(&msg, "IN_ATTRIB|"); + if (mask & IN_CLOSE_WRITE) + strbuf_addstr(&msg, "IN_CLOSE_WRITE|"); + if (mask & IN_CLOSE_NOWRITE) + strbuf_addstr(&msg, "IN_CLOSE_NOWRITE|"); + if (mask & IN_OPEN) + strbuf_addstr(&msg, "IN_OPEN|"); + if (mask & IN_MOVED_FROM) + strbuf_addstr(&msg, "IN_MOVED_FROM|"); + if (mask & IN_MOVED_TO) + strbuf_addstr(&msg, "IN_MOVED_TO|"); + if (mask & IN_CREATE) + strbuf_addstr(&msg, "IN_CREATE|"); + if (mask & IN_DELETE) + strbuf_addstr(&msg, "IN_DELETE|"); + if (mask & IN_DELETE_SELF) + strbuf_addstr(&msg, "IN_DELETE_SELF|"); + if (mask & IN_MOVE_SELF) + strbuf_addstr(&msg, "IN_MOVE_SELF|"); + if (mask & IN_UNMOUNT) + strbuf_addstr(&msg, "IN_UNMOUNT|"); + if (mask & IN_Q_OVERFLOW) + strbuf_addstr(&msg, "IN_Q_OVERFLOW|"); + if (mask & IN_IGNORED) + strbuf_addstr(&msg, "IN_IGNORED|"); + if (mask & IN_ISDIR) + strbuf_addstr(&msg, "IN_ISDIR|"); + + strbuf_strip_suffix(&msg, "|"); + + trace_printf_key(&trace_fsmonitor, "inotify_event: '%s', mask=%#8.8x %s", + path, mask, msg.buf); + + strbuf_release(&msg); +} + +int fsm_listen__ctor(struct fsmonitor_daemon_state *state) +{ + int fd; + int ret = 0; + struct fsm_listen_data *data; + + CALLOC_ARRAY(data, 1); + state->listen_data = data; + state->listen_error_code = -1; + data->fd_inotify = -1; + data->shutdown = SHUTDOWN_ERROR; + + fd = inotify_init1(O_NONBLOCK); + if (fd < 0) { + FREE_AND_NULL(state->listen_data); + return error_errno(_("inotify_init1() failed")); + } + + data->fd_inotify = fd; + + hashmap_init(&data->watches, watch_entry_cmp, NULL, 0); + hashmap_init(&data->renames, rename_entry_cmp, NULL, 0); + hashmap_init(&data->revwatches, revwatches_entry_cmp, NULL, 0); + + if (add_watch(state->path_worktree_watch.buf, data)) + ret = -1; + else if (register_inotify(state->path_worktree_watch.buf, state, NULL)) + ret = -1; + else if (state->nr_paths_watching > 1) { + if (add_watch(state->path_gitdir_watch.buf, data)) + ret = -1; + else if (register_inotify(state->path_gitdir_watch.buf, state, NULL)) + ret = -1; + } + + if (!ret) { + state->listen_error_code = 0; + data->shutdown = SHUTDOWN_CONTINUE; + } + + return ret; +} + +void fsm_listen__dtor(struct fsmonitor_daemon_state *state) +{ + struct fsm_listen_data *data; + struct hashmap_iter iter; + struct watch_entry *w; + struct watch_entry **to_remove; + size_t nr_to_remove = 0, alloc_to_remove = 0; + size_t i; + int fd; + + if (!state || !state->listen_data) + return; + + data = state->listen_data; + fd = data->fd_inotify; + + /* + * Collect all entries first, then remove them. + * We can't modify the hashmap while iterating over it. + */ + to_remove = NULL; + hashmap_for_each_entry(&data->watches, &iter, w, ent) { + ALLOC_GROW(to_remove, nr_to_remove + 1, alloc_to_remove); + to_remove[nr_to_remove++] = w; + } + + for (i = 0; i < nr_to_remove; i++) { + to_remove[i]->cookie = 0; /* ignore any pending renames */ + remove_watch(to_remove[i], data); + } + free(to_remove); + + hashmap_clear(&data->watches); + + hashmap_clear(&data->revwatches); /* remove_watch freed the entries */ + + hashmap_clear_and_free(&data->renames, struct rename_entry, ent); + + FREE_AND_NULL(state->listen_data); + + if (fd >= 0 && (close(fd) < 0)) + error_errno(_("closing inotify file descriptor failed")); +} + +void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) +{ + if (state && state->listen_data && + state->listen_data->shutdown == SHUTDOWN_CONTINUE) + state->listen_data->shutdown = SHUTDOWN_STOP; +} + +/* + * Process a single inotify event and queue for publication. + */ +static int process_event(const char *path, + const struct inotify_event *event, + struct fsmonitor_batch **batch, + struct string_list *cookie_list, + struct fsmonitor_daemon_state *state) +{ + const char *rel; + const char *last_sep; + + switch (fsmonitor_classify_path_absolute(state, path)) { + case IS_INSIDE_DOT_GIT_WITH_COOKIE_PREFIX: + case IS_INSIDE_GITDIR_WITH_COOKIE_PREFIX: + /* Use just the filename of the cookie file. */ + last_sep = find_last_dir_sep(path); + string_list_append(cookie_list, + last_sep ? last_sep + 1 : path); + break; + case IS_INSIDE_DOT_GIT: + case IS_INSIDE_GITDIR: + break; + case IS_DOT_GIT: + case IS_GITDIR: + /* + * If .git directory is deleted or renamed away, + * we have to quit. + */ + if (em_dir_deleted(event->mask)) { + trace_printf_key(&trace_fsmonitor, + "event: gitdir removed"); + state->listen_data->shutdown = SHUTDOWN_FORCE; + goto done; + } + + if (em_dir_renamed(event->mask)) { + trace_printf_key(&trace_fsmonitor, + "event: gitdir renamed"); + state->listen_data->shutdown = SHUTDOWN_FORCE; + goto done; + } + break; + case IS_WORKDIR_PATH: + /* normal events in the working directory */ + if (trace_pass_fl(&trace_fsmonitor)) + log_mask_set(path, event->mask); + + if (!*batch) + *batch = fsmonitor_batch__new(); + + rel = path + state->path_worktree_watch.len + 1; + fsmonitor_batch__add_path(*batch, rel); + + if (em_dir_deleted(event->mask)) + break; + + /* received IN_MOVE_FROM, add tracking for expected IN_MOVE_TO */ + if (em_rename_dir_from(event->mask)) + add_dir_rename(event->cookie, path, state->listen_data); + + /* received IN_MOVE_TO, update watch to reflect new path */ + if (em_rename_dir_to(event->mask)) { + rename_dir(event->cookie, path, state->listen_data); + if (register_inotify(path, state, *batch)) { + state->listen_data->shutdown = SHUTDOWN_ERROR; + goto done; + } + } + + if (em_dir_created(event->mask)) { + if (add_watch(path, state->listen_data)) { + state->listen_data->shutdown = SHUTDOWN_ERROR; + goto done; + } + if (register_inotify(path, state, *batch)) { + state->listen_data->shutdown = SHUTDOWN_ERROR; + goto done; + } + } + break; + case IS_OUTSIDE_CONE: + default: + trace_printf_key(&trace_fsmonitor, + "ignoring '%s'", path); + break; + } + return 0; +done: + return -1; +} + +/* + * Read the inotify event stream and pre-process events before further + * processing and eventual publishing. + */ +static void handle_events(struct fsmonitor_daemon_state *state) +{ + /* See https://man7.org/linux/man-pages/man7/inotify.7.html */ + char buf[4096] + __attribute__ ((aligned(__alignof__(struct inotify_event)))); + + struct hashmap *watches = &state->listen_data->watches; + struct fsmonitor_batch *batch = NULL; + struct string_list cookie_list = STRING_LIST_INIT_DUP; + struct watch_entry k, *w; + struct strbuf path = STRBUF_INIT; + const struct inotify_event *event; + int fd = state->listen_data->fd_inotify; + ssize_t len; + char *ptr, *p; + + for (;;) { + len = read(fd, buf, sizeof(buf)); + if (len == -1) { + if (errno == EAGAIN || errno == EINTR) + goto done; + error_errno(_("reading inotify message stream failed")); + state->listen_data->shutdown = SHUTDOWN_ERROR; + goto done; + } + + /* nothing to read */ + if (len == 0) + goto done; + + /* Loop over all events in the buffer. */ + for (ptr = buf; ptr < buf + len; + ptr += sizeof(struct inotify_event) + event->len) { + + event = (const struct inotify_event *)ptr; + + if (em_ignore(event->mask)) + continue; + + /* File system was unmounted or event queue overflowed */ + if (em_force_shutdown(event->mask)) { + if (trace_pass_fl(&trace_fsmonitor)) + log_mask_set("forcing shutdown", event->mask); + state->listen_data->shutdown = SHUTDOWN_FORCE; + goto done; + } + + k.wd = event->wd; + hashmap_entry_init(&k.ent, memhash(&k.wd, sizeof(int))); + + w = hashmap_get_entry(watches, &k, ent, NULL); + if (!w) { + /* Watch was removed, skip event */ + continue; + } + + /* directory watch was removed */ + if (em_remove_watch(event->mask)) { + remove_watch(w, state->listen_data); + continue; + } + + strbuf_reset(&path); + strbuf_addf(&path, "%s/%s", w->dir, event->name); + + p = fsmonitor__resolve_alias(path.buf, &state->alias); + if (!p) + p = strbuf_detach(&path, NULL); + + if (process_event(p, event, &batch, &cookie_list, state)) { + free(p); + goto done; + } + free(p); + } + strbuf_reset(&path); + fsmonitor_publish(state, batch, &cookie_list); + string_list_clear(&cookie_list, 0); + batch = NULL; + } +done: + strbuf_release(&path); + fsmonitor_batch__free_list(batch); + string_list_clear(&cookie_list, 0); +} + +/* + * Non-blocking read of the inotify events stream. The inotify fd is polled + * frequently to help minimize the number of queue overflows. + */ +void fsm_listen__loop(struct fsmonitor_daemon_state *state) +{ + int poll_num; + /* + * Interval in seconds between checks for stale directory renames. + * A directory rename that is not completed within this window + * (i.e. no matching IN_MOVED_TO for an IN_MOVED_FROM) indicates + * missed events, forcing a shutdown. + */ + const int interval = 1; + time_t checked = time(NULL); + struct pollfd fds[1]; + + fds[0].fd = state->listen_data->fd_inotify; + fds[0].events = POLLIN; + + /* + * Our fs event listener is now running, so it's safe to start + * serving client requests. + */ + ipc_server_start_async(state->ipc_server_data); + + for (;;) { + switch (state->listen_data->shutdown) { + case SHUTDOWN_CONTINUE: + poll_num = poll(fds, 1, 50); + if (poll_num == -1) { + if (errno == EINTR) + continue; + error_errno(_("polling inotify message stream failed")); + state->listen_data->shutdown = SHUTDOWN_ERROR; + continue; + } + + if ((time(NULL) - checked) >= interval) { + checked = time(NULL); + if (check_stale_dir_renames(&state->listen_data->renames, + checked - interval)) { + trace_printf_key(&trace_fsmonitor, + "missed IN_MOVED_TO events, forcing shutdown"); + state->listen_data->shutdown = SHUTDOWN_FORCE; + continue; + } + } + + if (poll_num > 0 && (fds[0].revents & POLLIN)) + handle_events(state); + + continue; + case SHUTDOWN_ERROR: + state->listen_error_code = -1; + ipc_server_stop_async(state->ipc_server_data); + break; + case SHUTDOWN_FORCE: + state->listen_error_code = 0; + ipc_server_stop_async(state->ipc_server_data); + break; + case SHUTDOWN_STOP: + default: + state->listen_error_code = 0; + break; + } + return; + } +} diff --git a/compat/fsmonitor/fsm-path-utils-linux.c b/compat/fsmonitor/fsm-path-utils-linux.c new file mode 100644 index 00000000000000..c9866b1b24ca8e --- /dev/null +++ b/compat/fsmonitor/fsm-path-utils-linux.c @@ -0,0 +1,217 @@ +#include "git-compat-util.h" +#include "fsmonitor-ll.h" +#include "fsmonitor-path-utils.h" +#include "gettext.h" +#include "trace.h" + +#include + +#ifdef HAVE_LINUX_MAGIC_H +#include +#endif + +/* + * Filesystem magic numbers for remote filesystems. + * Defined here if not available in linux/magic.h. + */ +#ifndef CIFS_SUPER_MAGIC +#define CIFS_SUPER_MAGIC 0xff534d42 +#endif +#ifndef SMB_SUPER_MAGIC +#define SMB_SUPER_MAGIC 0x517b +#endif +#ifndef SMB2_SUPER_MAGIC +#define SMB2_SUPER_MAGIC 0xfe534d42 +#endif +#ifndef NFS_SUPER_MAGIC +#define NFS_SUPER_MAGIC 0x6969 +#endif +#ifndef AFS_SUPER_MAGIC +#define AFS_SUPER_MAGIC 0x5346414f +#endif +#ifndef CODA_SUPER_MAGIC +#define CODA_SUPER_MAGIC 0x73757245 +#endif +#ifndef FUSE_SUPER_MAGIC +#define FUSE_SUPER_MAGIC 0x65735546 +#endif + +/* + * Check if filesystem type is a remote filesystem. + */ +static int is_remote_fs(unsigned long f_type) +{ + switch (f_type) { + case CIFS_SUPER_MAGIC: + case SMB_SUPER_MAGIC: + case SMB2_SUPER_MAGIC: + case NFS_SUPER_MAGIC: + case AFS_SUPER_MAGIC: + case CODA_SUPER_MAGIC: + case FUSE_SUPER_MAGIC: + return 1; + default: + return 0; + } +} + +/* + * Map filesystem magic numbers to human-readable names as a fallback + * when /proc/mounts is unavailable. This only covers the remote and + * special filesystems in is_remote_fs() above; local filesystems are + * never flagged as incompatible, so we do not need their names here. + */ +static const char *get_fs_typename(unsigned long f_type) +{ + switch (f_type) { + case CIFS_SUPER_MAGIC: + return "cifs"; + case SMB_SUPER_MAGIC: + return "smb"; + case SMB2_SUPER_MAGIC: + return "smb2"; + case NFS_SUPER_MAGIC: + return "nfs"; + case AFS_SUPER_MAGIC: + return "afs"; + case CODA_SUPER_MAGIC: + return "coda"; + case FUSE_SUPER_MAGIC: + return "fuse"; + default: + return "unknown"; + } +} + +/* + * Find the mount point for a given path by reading /proc/mounts. + * + * statfs(2) gives us f_type (the magic number) but not the human-readable + * filesystem type string. We scan /proc/mounts to find the mount entry + * whose path is the longest prefix of ours and whose f_fsid matches, + * which gives us the fstype string (e.g. "nfs", "ext4") for logging. + */ +static char *find_mount(const char *path, const struct statfs *path_fs) +{ + FILE *fp; + struct strbuf line = STRBUF_INIT; + struct strbuf match = STRBUF_INIT; + struct strbuf fstype = STRBUF_INIT; + char *result = NULL; + + fp = fopen("/proc/mounts", "r"); + if (!fp) + return NULL; + + while (strbuf_getline(&line, fp) != EOF) { + char *fields[6]; + char *p = line.buf; + int i; + + /* Parse mount entry: device mountpoint fstype options dump pass */ + for (i = 0; i < 6 && p; i++) { + fields[i] = p; + p = strchr(p, ' '); + if (p) + *p++ = '\0'; + } + + if (i >= 3) { + const char *mountpoint = fields[1]; + const char *type = fields[2]; + struct statfs mount_fs; + + /* Check if this mount point is a prefix of our path */ + if (starts_with(path, mountpoint) && + (path[strlen(mountpoint)] == '/' || + path[strlen(mountpoint)] == '\0')) { + /* Check if filesystem ID matches */ + if (statfs(mountpoint, &mount_fs) == 0 && + !memcmp(&mount_fs.f_fsid, &path_fs->f_fsid, + sizeof(mount_fs.f_fsid))) { + /* Keep the longest matching mount point */ + if (strlen(mountpoint) > match.len) { + strbuf_reset(&match); + strbuf_addstr(&match, mountpoint); + strbuf_reset(&fstype); + strbuf_addstr(&fstype, type); + } + } + } + } + } + + fclose(fp); + strbuf_release(&line); + strbuf_release(&match); + + if (fstype.len) + result = strbuf_detach(&fstype, NULL); + else + strbuf_release(&fstype); + + return result; +} + +int fsmonitor__get_fs_info(const char *path, struct fs_info *fs_info) +{ + struct statfs fs; + + if (statfs(path, &fs) == -1) { + int saved_errno = errno; + trace_printf_key(&trace_fsmonitor, "statfs('%s') failed: %s", + path, strerror(saved_errno)); + errno = saved_errno; + return -1; + } + + trace_printf_key(&trace_fsmonitor, + "statfs('%s') [type 0x%08lx]", + path, (unsigned long)fs.f_type); + + fs_info->is_remote = is_remote_fs(fs.f_type); + + /* + * Try to get filesystem type from /proc/mounts for a more + * descriptive name. + */ + fs_info->typename = find_mount(path, &fs); + if (!fs_info->typename) + fs_info->typename = xstrdup(get_fs_typename(fs.f_type)); + + trace_printf_key(&trace_fsmonitor, + "'%s' is_remote: %d, typename: %s", + path, fs_info->is_remote, fs_info->typename); + + return 0; +} + +int fsmonitor__is_fs_remote(const char *path) +{ + struct fs_info fs; + + if (fsmonitor__get_fs_info(path, &fs)) + return -1; + + free(fs.typename); + + return fs.is_remote; +} + +/* + * No-op for Linux - we don't have firmlinks like macOS. + */ +int fsmonitor__get_alias(const char *path UNUSED, + struct alias_info *info UNUSED) +{ + return 0; +} + +/* + * No-op for Linux - we don't have firmlinks like macOS. + */ +char *fsmonitor__resolve_alias(const char *path UNUSED, + const struct alias_info *info UNUSED) +{ + return NULL; +} diff --git a/config.mak.uname b/config.mak.uname index 00bcb84cee15c3..fd91729dd2b80f 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -68,6 +68,16 @@ ifeq ($(uname_S),Linux) BASIC_CFLAGS += -std=c99 endif LINK_FUZZ_PROGRAMS = YesPlease + + # The builtin FSMonitor on Linux builds upon Simple-IPC. Both require + # Unix domain sockets and PThreads. + ifndef NO_PTHREADS + ifndef NO_UNIX_SOCKETS + FSMONITOR_DAEMON_BACKEND = linux + FSMONITOR_OS_SETTINGS = unix + BASIC_CFLAGS += -DHAVE_LINUX_MAGIC_H + endif + endif endif ifeq ($(uname_S),GNU/kFreeBSD) HAVE_ALLOCA_H = YesPlease diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index d613809e26fd20..b7da108f298dc3 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -296,6 +296,10 @@ if(SUPPORTS_SIMPLE_IPC) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") set(FSMONITOR_DAEMON_BACKEND "darwin") set(FSMONITOR_OS_SETTINGS "unix") + elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(FSMONITOR_DAEMON_BACKEND "linux") + set(FSMONITOR_OS_SETTINGS "unix") + add_compile_definitions(HAVE_LINUX_MAGIC_H) endif() if(FSMONITOR_DAEMON_BACKEND) @@ -1149,8 +1153,8 @@ endif() file(STRINGS ${CMAKE_SOURCE_DIR}/GIT-BUILD-OPTIONS.in git_build_options NEWLINE_CONSUME) string(REPLACE "@BROKEN_PATH_FIX@" "" git_build_options "${git_build_options}") string(REPLACE "@DIFF@" "'${DIFF}'" git_build_options "${git_build_options}") -string(REPLACE "@FSMONITOR_DAEMON_BACKEND@" "win32" git_build_options "${git_build_options}") -string(REPLACE "@FSMONITOR_OS_SETTINGS@" "win32" git_build_options "${git_build_options}") +string(REPLACE "@FSMONITOR_DAEMON_BACKEND@" "${FSMONITOR_DAEMON_BACKEND}" git_build_options "${git_build_options}") +string(REPLACE "@FSMONITOR_OS_SETTINGS@" "${FSMONITOR_OS_SETTINGS}" git_build_options "${git_build_options}") string(REPLACE "@GITWEBDIR@" "'${GITWEBDIR}'" git_build_options "${git_build_options}") string(REPLACE "@GIT_INTEROP_MAKE_OPTS@" "" git_build_options "${git_build_options}") string(REPLACE "@GIT_PERF_LARGE_REPO@" "" git_build_options "${git_build_options}") diff --git a/meson.build b/meson.build index 4f0c0a33b85c7d..123d2184602aa9 100644 --- a/meson.build +++ b/meson.build @@ -1324,6 +1324,10 @@ fsmonitor_os = '' if host_machine.system() == 'windows' fsmonitor_backend = 'win32' fsmonitor_os = 'win32' +elif host_machine.system() == 'linux' and threads.found() and compiler.has_header('linux/magic.h') + fsmonitor_backend = 'linux' + fsmonitor_os = 'unix' + libgit_c_args += '-DHAVE_LINUX_MAGIC_H' elif host_machine.system() == 'darwin' fsmonitor_backend = 'darwin' fsmonitor_os = 'unix' From 50dc89cdfb6d8495853ceac4801c1cca9cd4ce38 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:33 +0000 Subject: [PATCH 056/608] run-command: add close_fd_above_stderr option Add a close_fd_above_stderr flag to struct child_process. When set, the child closes file descriptors 3 and above between fork and exec (skipping the child-notifier pipe), capped at sysconf(_SC_OPEN_MAX) or 4096, whichever is smaller. This prevents the child from inheriting pipe endpoints or other descriptors from the parent environment (e.g., the test harness). Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- run-command.c | 12 ++++++++++++ run-command.h | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/run-command.c b/run-command.c index e3e02475ccec50..f4361906c9b0e5 100644 --- a/run-command.c +++ b/run-command.c @@ -546,6 +546,7 @@ static void atfork_parent(struct atfork_state *as) "restoring signal mask"); #endif } + #endif /* GIT_WINDOWS_NATIVE */ static inline void set_cloexec(int fd) @@ -832,6 +833,17 @@ int start_command(struct child_process *cmd) child_close(cmd->out); } + if (cmd->close_fd_above_stderr) { + long max_fd = sysconf(_SC_OPEN_MAX); + int fd; + if (max_fd < 0 || max_fd > 4096) + max_fd = 4096; + for (fd = 3; fd < max_fd; fd++) { + if (fd != child_notifier) + close(fd); + } + } + if (cmd->dir && chdir(cmd->dir)) child_die(CHILD_ERR_CHDIR); diff --git a/run-command.h b/run-command.h index 0df25e445f001c..fdaa01e140705f 100644 --- a/run-command.h +++ b/run-command.h @@ -141,6 +141,15 @@ struct child_process { unsigned stdout_to_stderr:1; unsigned clean_on_exit:1; unsigned wait_after_clean:1; + + /** + * Close file descriptors 3 and above in the child after forking + * but before exec. This prevents the child from inheriting + * pipe endpoints or other descriptors from the parent + * environment (e.g., the test harness). + */ + unsigned close_fd_above_stderr:1; + void (*clean_on_exit_handler)(struct child_process *process); }; From 9266aaff0aba923eb6ef08a24d413ed7052818d7 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:34 +0000 Subject: [PATCH 057/608] fsmonitor: close inherited file descriptors and detach in daemon When the fsmonitor daemon is spawned as a background process, it may inherit file descriptors from its parent that it does not need. In particular, when the test harness or a CI system captures output through pipes, the daemon can inherit duplicated pipe endpoints. If the daemon holds these open, the parent process never sees EOF and may appear to hang. Set close_fd_above_stderr on the child process at both daemon startup paths: the explicit "fsmonitor--daemon start" command and the implicit spawn triggered by fsmonitor-ipc when a client finds no running daemon. Also suppress stdout and stderr on the implicit spawn path to prevent the background daemon from writing to the client's terminal. Additionally, call setsid() when the daemon starts with --detach to create a new session and process group. This prevents the daemon from being part of the spawning shell's process group, which could cause the shell's "wait" to block until the daemon exits. Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- builtin/fsmonitor--daemon.c | 16 ++++++++++++++-- fsmonitor-ipc.c | 3 +++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index c8ec7b722e953e..b2a816dc3fea5e 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -1439,7 +1439,7 @@ static int fsmonitor_run_daemon(void) return err; } -static int try_to_run_foreground_daemon(int detach_console MAYBE_UNUSED) +static int try_to_run_foreground_daemon(int detach_console) { /* * Technically, we don't need to probe for an existing daemon @@ -1459,10 +1459,21 @@ static int try_to_run_foreground_daemon(int detach_console MAYBE_UNUSED) fflush(stderr); } + if (detach_console) { #ifdef GIT_WINDOWS_NATIVE - if (detach_console) FreeConsole(); +#else + /* + * Create a new session so that the daemon is detached + * from the parent's process group. This prevents + * shells with job control (e.g. bash with "set -m") + * from waiting on the daemon when they wait for a + * foreground command that implicitly spawned it. + */ + if (setsid() == -1) + warning_errno(_("setsid failed")); #endif + } return !!fsmonitor_run_daemon(); } @@ -1525,6 +1536,7 @@ static int try_to_start_background_daemon(void) cp.no_stdin = 1; cp.no_stdout = 1; cp.no_stderr = 1; + cp.close_fd_above_stderr = 1; sbgr = start_bg_command(&cp, bg_wait_cb, NULL, fsmonitor__start_timeout_sec); diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index f1b163111194fb..6112d130644f04 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -61,6 +61,9 @@ static int spawn_daemon(void) cmd.git_cmd = 1; cmd.no_stdin = 1; + cmd.no_stdout = 1; + cmd.no_stderr = 1; + cmd.close_fd_above_stderr = 1; cmd.trace2_child_class = "fsmonitor"; strvec_pushl(&cmd.args, "fsmonitor--daemon", "start", NULL); From 1cbfa62766d04eee86d8cf0f0efe1c344e73591a Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:35 +0000 Subject: [PATCH 058/608] fsmonitor: add timeout to daemon stop command The "fsmonitor--daemon stop" command polls in a loop waiting for the daemon to exit after sending a "quit" command over IPC. If the daemon fails to shut down (e.g. it is stuck or wedged), this loop spins forever. Add a 30-second timeout so the stop command returns an error instead of blocking indefinitely. Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- builtin/fsmonitor--daemon.c | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index b2a816dc3fea5e..53d8ad1f0d2a17 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -86,6 +86,8 @@ static int do_as_client__send_stop(void) { struct strbuf answer = STRBUF_INIT; int ret; + int max_wait_ms = 30000; + int elapsed_ms = 0; ret = fsmonitor_ipc__send_command("quit", &answer); @@ -96,8 +98,16 @@ static int do_as_client__send_stop(void) return ret; trace2_region_enter("fsm_client", "polling-for-daemon-exit", NULL); - while (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) + while (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (elapsed_ms >= max_wait_ms) { + trace2_region_leave("fsm_client", + "polling-for-daemon-exit", NULL); + return error(_("daemon did not stop within %d seconds"), + max_wait_ms / 1000); + } sleep_millisec(50); + elapsed_ms += 50; + } trace2_region_leave("fsm_client", "polling-for-daemon-exit", NULL); return 0; From d21fc23546e72ef7067c6664485d2436fc67fdde Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:36 +0000 Subject: [PATCH 059/608] fsmonitor: add tests for Linux Add a smoke test that verifies the filesystem actually delivers inotify events to the daemon. On some configurations (e.g., overlayfs with older kernels), inotify watches succeed but events are never delivered. The daemon cookie wait will time out, but every subsequent test would fail. Skip the entire test file early when this is detected. Add a test that exercises rapid nested directory creation to verify the daemon correctly handles the EEXIST race between recursive scan and queued inotify events. When IN_MASK_CREATE is available and a directory watch is added during recursive registration, the kernel may also deliver a queued IN_CREATE event for the same directory. The second inotify_add_watch() returns EEXIST, which must be treated as harmless. An earlier version of the listener crashed in this scenario. Reduce --start-timeout from the default 60 seconds to 10 seconds so that tests fail promptly when the daemon cannot start. Harden the test helpers to work in environments without procps (e.g., Fedora CI): fall back to reading /proc/$pid/stat for the process group ID when ps is unavailable, guard stop_git() against an empty pgid, and redirect stderr from kill to /dev/null to avoid noise when processes have already exited. Use set -m to enable job control in the submodule-pull test so that the background git pull gets its own process group, preventing the shell wait from blocking on the daemon. setsid() in the previous commit detaches the daemon itself, but the intermediate git pull process still needs its own process group for the test shell to manage it correctly. Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- t/t7527-builtin-fsmonitor.sh | 88 +++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 409cd0cd121695..ed12f218de32f1 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -10,9 +10,57 @@ then test_done fi +# Verify that the filesystem delivers events to the daemon. +# On some configurations (e.g., overlayfs with older kernels), +# inotify watches succeed but events are never delivered. The +# cookie wait will time out and the daemon logs a trace message. +# +# Use "timeout" (if available) to guard each step against hangs. +maybe_timeout () { + if type timeout >/dev/null 2>&1 + then + timeout "$@" + else + shift + "$@" + fi +} +verify_fsmonitor_works () { + git init test_fsmonitor_smoke || return 1 + + GIT_TRACE_FSMONITOR="$PWD/smoke.trace" && + export GIT_TRACE_FSMONITOR && + maybe_timeout 30 \ + git -C test_fsmonitor_smoke fsmonitor--daemon start \ + --start-timeout=10 + ret=$? + unset GIT_TRACE_FSMONITOR + if test $ret -ne 0 + then + rm -rf test_fsmonitor_smoke smoke.trace + return 1 + fi + + maybe_timeout 10 \ + test-tool -C test_fsmonitor_smoke fsmonitor-client query \ + --token 0 >/dev/null 2>&1 + maybe_timeout 5 \ + git -C test_fsmonitor_smoke fsmonitor--daemon stop 2>/dev/null + ! grep -q "cookie_wait timed out" "$PWD/smoke.trace" 2>/dev/null + ret=$? + rm -rf test_fsmonitor_smoke smoke.trace + return $ret +} + +if ! verify_fsmonitor_works +then + skip_all="filesystem does not deliver fsmonitor events (container/overlayfs?)" + test_done +fi + stop_daemon_delete_repo () { r=$1 && - test_might_fail git -C $r fsmonitor--daemon stop && + { maybe_timeout 30 git -C $r fsmonitor--daemon stop 2>/dev/null || :; } && rm -rf $1 } @@ -67,7 +115,7 @@ start_daemon () { export GIT_TEST_FSMONITOR_TOKEN fi && - git $r fsmonitor--daemon start && + git $r fsmonitor--daemon start --start-timeout=10 && git $r fsmonitor--daemon status ) } @@ -520,6 +568,28 @@ test_expect_success 'directory changes to a file' ' grep "^event: dir1$" .git/trace ' +test_expect_success 'rapid nested directory creation' ' + test_when_finished "git fsmonitor--daemon stop; rm -rf rapid" && + + start_daemon --tf "$PWD/.git/trace" && + + # Rapidly create nested directories to exercise race conditions + # where directory watches may be added concurrently during + # event processing and recursive scanning. + for i in $(test_seq 1 20) + do + mkdir -p "rapid/nested/dir$i/subdir/deep" || return 1 + done && + + # Give the daemon time to process all events + sleep 1 && + + test-tool fsmonitor-client query --token 0 && + + # Verify daemon is still running (did not crash) + git fsmonitor--daemon status +' + # The next few test cases exercise the token-resync code. When filesystem # drops events (because of filesystem velocity or because the daemon isn't # polling fast enough), we need to discard the cached data (relative to the @@ -910,7 +980,10 @@ test_expect_success "submodule absorbgitdirs implicitly starts daemon" ' start_git_in_background () { git "$@" & git_pid=$! - git_pgid=$(ps -o pgid= -p $git_pid) + git_pgid=$(ps -o pgid= -p $git_pid 2>/dev/null || + awk '{print $5}' /proc/$git_pid/stat 2>/dev/null) && + git_pgid="${git_pgid## }" && + git_pgid="${git_pgid%% }" nr_tries_left=10 while true do @@ -921,15 +994,16 @@ start_git_in_background () { fi sleep 1 nr_tries_left=$(($nr_tries_left - 1)) - done >/dev/null 2>&1 & + done >/dev/null 2>&1 3>&- 4>&- 5>&- 6>&- 7>&- & watchdog_pid=$! wait $git_pid } stop_git () { - while kill -0 -- -$git_pgid + test -n "$git_pgid" || return 0 + while kill -0 -- -$git_pgid 2>/dev/null do - kill -- -$git_pgid + kill -- -$git_pgid 2>/dev/null sleep 1 done } @@ -944,7 +1018,7 @@ stop_watchdog () { test_expect_success !MINGW "submodule implicitly starts daemon by pull" ' test_atexit "stop_watchdog" && - test_when_finished "stop_git; rm -rf cloned super sub" && + test_when_finished "set +m; stop_git; rm -rf cloned super sub" && create_super super && create_sub sub && From b1cebd7194299ad5414ab2122b2970b339399446 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:37 +0000 Subject: [PATCH 060/608] fsmonitor: convert shown khash to strset in do_handle_client Replace the khash-based string set used for deduplicating pathnames in do_handle_client() with a strset, which provides a cleaner interface for the same purpose. Since the paths are interned strings from the batch data, use strdup_strings=0 to avoid unnecessary copies. Suggested-by: Patrick Steinhardt Signed-off-by: Paul Tarjan Signed-off-by: Junio C Hamano --- builtin/fsmonitor--daemon.c | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 53d8ad1f0d2a17..f920cf3a8202f6 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -16,7 +16,7 @@ #include "fsmonitor--daemon.h" #include "simple-ipc.h" -#include "khash.h" +#include "strmap.h" #include "run-command.h" #include "trace.h" #include "trace2.h" @@ -674,8 +674,6 @@ static int fsmonitor_parse_client_token(const char *buf_token, return 0; } -KHASH_INIT(str, const char *, int, 0, kh_str_hash_func, kh_str_hash_equal) - static int do_handle_client(struct fsmonitor_daemon_state *state, const char *command, ipc_server_reply_cb *reply, @@ -692,8 +690,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, const struct fsmonitor_batch *batch; struct fsmonitor_batch *remainder = NULL; intmax_t count = 0, duplicates = 0; - kh_str_t *shown = NULL; - int hash_ret; + struct strset shown = STRSET_INIT; int do_trivial = 0; int do_flush = 0; int do_cookie = 0; @@ -882,14 +879,14 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, * so walk the batch list backwards from the current head back * to the batch (sequence number) they named. * - * We use khash to de-dup the list of pathnames. + * We use a strset to de-dup the list of pathnames. * * NEEDSWORK: each batch contains a list of interned strings, * so we only need to do pointer comparisons here to build the * hash table. Currently, we're still comparing the string * values. */ - shown = kh_init_str(); + strset_init_with_options(&shown, NULL, 0); for (batch = batch_head; batch && batch->batch_seq_nr > requested_oldest_seq_nr; batch = batch->next) { @@ -899,11 +896,9 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, const char *s = batch->interned_paths[k]; size_t s_len; - if (kh_get_str(shown, s) != kh_end(shown)) + if (!strset_add(&shown, s)) duplicates++; else { - kh_put_str(shown, s, &hash_ret); - trace_printf_key(&trace_fsmonitor, "send[%"PRIuMAX"]: %s", count, s); @@ -973,7 +968,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, trace2_data_intmax("fsmonitor", the_repository, "response/count/duplicates", duplicates); cleanup: - kh_destroy_str(shown); + strset_clear(&shown); strbuf_release(&response_token); strbuf_release(&requested_token_id); strbuf_release(&payload); From 6b79aee7de1ac7142707326699c3f4aa5b18cb28 Mon Sep 17 00:00:00 2001 From: Elijah Newren Date: Wed, 15 Apr 2026 23:58:00 +0000 Subject: [PATCH 061/608] backfill: reject rev-list arguments that do not make sense Some rev-list options accepted by setup_revisions() are silently ignored or actively counterproductive when used with 'git backfill', because the path-walk API has its own tree-walking logic that bypasses the mechanisms these options rely on: * -S/-G (pickaxe) and --diff-filter work by computing per-commit diffs in get_revision_1() and filtering commits whose diffs don't match. Since backfill's goal is to download all blobs reachable from commits in the range, filtering out commits based on diff content would silently skip blobs -- the opposite of what users want. * --follow disables path pruning (revs->prune) and only makes sense for tracking a single file through renames in log output. It has no useful interaction with backfill. * -L (line-log) computes line-level diffs to track the evolution of a function or line range. Like pickaxe, it filters commits based on diff content, which would cause blobs to be silently skipped. * --diff-merges controls how merge commit diffs are displayed. The path-walk API walks trees directly and never computes per-commit diffs, so this option would be silently ignored. * --filter (object filtering, e.g. --filter=blob:none) is used by the list-objects traversal but is completely ignored by the path-walk API, so it would silently do nothing. Rather than letting users think these options are being honored, reject them with a clear error message. Signed-off-by: Elijah Newren Signed-off-by: Junio C Hamano --- builtin/backfill.c | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/builtin/backfill.c b/builtin/backfill.c index d794dd842f65ce..a9edddcb7edfa3 100644 --- a/builtin/backfill.c +++ b/builtin/backfill.c @@ -78,6 +78,28 @@ static int fill_missing_blobs(const char *path UNUSED, return 0; } +static void reject_unsupported_rev_list_options(struct rev_info *revs) +{ + if (revs->diffopt.pickaxe) + die(_("'%s' cannot be used with 'git backfill'"), + (revs->diffopt.pickaxe_opts & DIFF_PICKAXE_REGEX) ? "-G" : "-S"); + if (revs->diffopt.filter || revs->diffopt.filter_not) + die(_("'%s' cannot be used with 'git backfill'"), + "--diff-filter"); + if (revs->diffopt.flags.follow_renames) + die(_("'%s' cannot be used with 'git backfill'"), + "--follow"); + if (revs->line_level_traverse) + die(_("'%s' cannot be used with 'git backfill'"), + "-L"); + if (revs->explicit_diff_merges) + die(_("'%s' cannot be used with 'git backfill'"), + "--diff-merges"); + if (revs->filter.choice) + die(_("'%s' cannot be used with 'git backfill'"), + "--filter"); +} + static int do_backfill(struct backfill_context *ctx) { struct path_walk_info info = PATH_WALK_INFO_INIT; @@ -144,6 +166,7 @@ int cmd_backfill(int argc, const char **argv, const char *prefix, struct reposit if (argc > 1) die(_("unrecognized argument: %s"), argv[1]); + reject_unsupported_rev_list_options(&ctx.revs); repo_config(repo, git_default_config, NULL); From ef6d3c94746462ec560893ed35c83df485a6344d Mon Sep 17 00:00:00 2001 From: Elijah Newren Date: Wed, 15 Apr 2026 23:58:01 +0000 Subject: [PATCH 062/608] backfill: document acceptance of revision-range in more standard manner 302aff09223f (backfill: accept revision arguments, 2026-03-26) added support for passing revision arguments to 'git backfill' but documented them only with a prose sentence: You may also specify the commit limiting options from git-rev-list(1). No other command that accepts revision arguments documents them this way. Commands like log, shortlog, and replay define a formal entry and include rev-list-options.adoc. Commands like bundle, fast-export, and filter-branch, which pass arguments through to the revision machinery without including the full options file, still define a formal entry explaining what is accepted. Add a formal entry in the synopsis and OPTIONS section, following the convention used by other commands, and mention that commit-limiting options from git-rev-list(1) are also accepted. Signed-off-by: Elijah Newren Signed-off-by: Junio C Hamano --- Documentation/git-backfill.adoc | 15 ++++++++++++--- builtin/backfill.c | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Documentation/git-backfill.adoc b/Documentation/git-backfill.adoc index 246ab417c24a10..bf26d7694f402d 100644 --- a/Documentation/git-backfill.adoc +++ b/Documentation/git-backfill.adoc @@ -9,7 +9,7 @@ git-backfill - Download missing objects in a partial clone SYNOPSIS -------- [synopsis] -git backfill [--min-batch-size=] [--[no-]sparse] +git backfill [--min-batch-size=] [--[no-]sparse] [] DESCRIPTION ----------- @@ -43,7 +43,7 @@ smaller network calls than downloading the entire repository at clone time. By default, `git backfill` downloads all blobs reachable from the `HEAD` -commit. This set can be restricted or expanded using various options. +commit. This set can be restricted or expanded using various options below. THIS COMMAND IS EXPERIMENTAL. ITS BEHAVIOR MAY CHANGE IN THE FUTURE. @@ -63,7 +63,16 @@ 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]. +``:: + Backfill only blobs reachable from commits in the specified + revision range. When no __ is specified, it + defaults to `HEAD` (i.e. the whole history leading to the + current commit). For a complete list of ways to spell + __, see the "Specifying Ranges" section of + linkgit:gitrevisions[7]. ++ +You may also use commit-limiting options understood by +linkgit:git-rev-list[1] such as `--first-parent`, `--since`, or pathspecs. SEE ALSO -------- diff --git a/builtin/backfill.c b/builtin/backfill.c index a9edddcb7edfa3..e934d360fdd9d0 100644 --- a/builtin/backfill.c +++ b/builtin/backfill.c @@ -26,7 +26,7 @@ #include "path-walk.h" static const char * const builtin_backfill_usage[] = { - N_("git backfill [--min-batch-size=] [--[no-]sparse]"), + N_("git backfill [--min-batch-size=] [--[no-]sparse] []"), NULL }; From a1ad4a0fca14cdeb55ab9fb065551b15cafa8a4f Mon Sep 17 00:00:00 2001 From: Elijah Newren Date: Wed, 15 Apr 2026 23:58:02 +0000 Subject: [PATCH 063/608] backfill: default to grabbing edge blobs too Commit 302aff09223f (backfill: accept revision arguments, 2026-03-26) added support for accepting revision arguments to backfill. This allows users to do things like git backfill --remotes ^v2.3.0 and then run many commands without triggering on-demand downloads of blobs. However, if they have topics based on v2.3.0, they will likely still trigger on-demand downloads. Consider, for example, the command git log -p v2.3.0..topic This would still trigger on-demand blob loadings after the backfill command above, because the commit(s) with A as a parent will need to diff against the blobs in A. In fact, multiple commands need blobs from the lower boundary of the revision range: * git log -p A..B # After backfill A..B * git replay --onto TARGET A..B # After backfill TARGET^! A..B * git checkout A && git merge B # After backfill A...B Add an extra --[no-]include-edges flag to allow grabbing blobs from edge commits. Since the point of backfill is to prevent on-demand blob loading and these are common commands, default to --include-edges. Signed-off-by: Elijah Newren Signed-off-by: Junio C Hamano --- Documentation/git-backfill.adoc | 9 ++- builtin/backfill.c | 8 ++- t/t5620-backfill.sh | 110 ++++++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/Documentation/git-backfill.adoc b/Documentation/git-backfill.adoc index bf26d7694f402d..c0a3b80615e034 100644 --- a/Documentation/git-backfill.adoc +++ b/Documentation/git-backfill.adoc @@ -9,7 +9,7 @@ git-backfill - Download missing objects in a partial clone SYNOPSIS -------- [synopsis] -git backfill [--min-batch-size=] [--[no-]sparse] [] +git backfill [--min-batch-size=] [--[no-]sparse] [--[no-]include-edges] [] DESCRIPTION ----------- @@ -63,6 +63,13 @@ OPTIONS current sparse-checkout. If the sparse-checkout feature is enabled, then `--sparse` is assumed and can be disabled with `--no-sparse`. +`--include-edges`:: +`--no-include-edges`:: + Include blobs from boundary commits in the backfill. Useful in + preparation for commands like `git log -p A..B` or `git replay + --onto TARGET A..B`, where A..B normally excludes A but you need + the blobs from A as well. `--include-edges` is the default. + ``:: Backfill only blobs reachable from commits in the specified revision range. When no __ is specified, it diff --git a/builtin/backfill.c b/builtin/backfill.c index e934d360fdd9d0..7ffab2ea74f5cc 100644 --- a/builtin/backfill.c +++ b/builtin/backfill.c @@ -26,7 +26,7 @@ #include "path-walk.h" static const char * const builtin_backfill_usage[] = { - N_("git backfill [--min-batch-size=] [--[no-]sparse] []"), + N_("git backfill [--min-batch-size=] [--[no-]sparse] [--[no-]include-edges] []"), NULL }; @@ -35,6 +35,7 @@ struct backfill_context { struct oid_array current_batch; size_t min_batch_size; int sparse; + int include_edges; struct rev_info revs; }; @@ -116,6 +117,8 @@ static int do_backfill(struct backfill_context *ctx) /* Walk from HEAD if otherwise unspecified. */ if (!ctx->revs.pending.nr) add_head_to_pending(&ctx->revs); + if (ctx->include_edges) + ctx->revs.edge_hint = 1; info.blobs = 1; info.tags = info.commits = info.trees = 0; @@ -143,12 +146,15 @@ int cmd_backfill(int argc, const char **argv, const char *prefix, struct reposit .min_batch_size = 50000, .sparse = -1, .revs = REV_INFO_INIT, + .include_edges = 1, }; struct option options[] = { OPT_UNSIGNED(0, "min-batch-size", &ctx.min_batch_size, N_("Minimum number of objects to request at a time")), OPT_BOOL(0, "sparse", &ctx.sparse, N_("Restrict the missing objects to the current sparse-checkout")), + OPT_BOOL(0, "include-edges", &ctx.include_edges, + N_("Include blobs from boundary commits in the backfill")), OPT_END(), }; struct repo_config_values *cfg = repo_config_values(the_repository); diff --git a/t/t5620-backfill.sh b/t/t5620-backfill.sh index f3b5e39493677b..94f35ce1901671 100755 --- a/t/t5620-backfill.sh +++ b/t/t5620-backfill.sh @@ -257,11 +257,12 @@ test_expect_success 'backfill with revision range' ' 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 && + GIT_TRACE2_EVENT="$(pwd)/backfill-trace" git -C backfill-revs backfill HEAD~2..HEAD && - # 30 objects downloaded. + # 36 objects downloaded, 12 still missing + test_trace2_data promisor fetch_count 36 missing && - test_line_count = 18 missing + test_line_count = 12 missing ' test_expect_success 'backfill with revisions over stdin' ' @@ -279,11 +280,12 @@ test_expect_success 'backfill with revisions over stdin' ' ^HEAD~2 EOF - git -C backfill-revs backfill --stdin missing && - test_line_count = 18 missing + test_line_count = 12 missing ' test_expect_success 'backfill with prefix pathspec' ' @@ -398,6 +400,102 @@ test_expect_success 'backfill with --since' ' test_line_count = 6 missing ' +test_expect_success 'backfill range with include-edges enables fetch-free git-log' ' + git clone --no-checkout --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv.bare" backfill-log && + + # Backfill the range with default include edges. + git -C backfill-log backfill HEAD~2..HEAD && + + # git log -p needs edge blobs for the "before" side of + # diffs. With edge inclusion, all needed blobs are local. + GIT_TRACE2_EVENT="$(pwd)/log-trace" git \ + -C backfill-log log -p HEAD~2..HEAD >log-output && + + # No promisor fetches should have been needed. + ! grep "fetch_count" log-trace +' + +test_expect_success 'backfill range without include edges causes on-demand fetches in git-log' ' + git clone --no-checkout --filter=blob:none \ + --single-branch --branch=main \ + "file://$(pwd)/srv.bare" backfill-log-no-bdy && + + # Backfill WITHOUT include edges -- file.3 v1 blobs are missing. + git -C backfill-log-no-bdy backfill --no-include-edges HEAD~2..HEAD && + + # git log -p HEAD~2..HEAD computes diff of commit 7 against + # commit 6. It needs file.3 v1 (the "before" side), which was + # not backfilled. This triggers on-demand promisor fetches. + GIT_TRACE2_EVENT="$(pwd)/log-no-bdy-trace" git \ + -C backfill-log-no-bdy log -p HEAD~2..HEAD >log-output && + + grep "fetch_count" log-no-bdy-trace +' + +test_expect_success 'backfill range enables fetch-free replay' ' + # Create a repo with a branch to replay. + git init replay-src && + ( + cd replay-src && + git config uploadpack.allowfilter 1 && + git config uploadpack.allowanysha1inwant 1 && + test_commit base && + git checkout -b topic && + test_commit topic-change && + git checkout main && + test_commit main-change + ) && + git clone --bare --filter=blob:none \ + "file://$(pwd)/replay-src" replay-dest.git && + + # Backfill the replay range: --onto main, replaying topic~1..topic. + # For replay, we need TARGET^! plus the range. + main_oid=$(git -C replay-dest.git rev-parse main) && + topic_oid=$(git -C replay-dest.git rev-parse topic) && + base_oid=$(git -C replay-dest.git rev-parse topic~1) && + git -C replay-dest.git backfill \ + "$main_oid^!" "$base_oid..$topic_oid" && + + # Now replay should complete without any promisor fetches. + GIT_TRACE2_EVENT="$(pwd)/replay-trace" git -C replay-dest.git \ + replay --onto main topic~1..topic >replay-out && + + ! grep "fetch_count" replay-trace +' + +test_expect_success 'backfill enables fetch-free merge' ' + # Create a repo with two branches to merge. + git init merge-src && + ( + cd merge-src && + git config uploadpack.allowfilter 1 && + git config uploadpack.allowanysha1inwant 1 && + test_commit merge-base && + git checkout -b side && + test_commit side-change && + git checkout main && + test_commit main-side-change + ) && + git clone --filter=blob:none \ + "file://$(pwd)/merge-src" merge-dest && + + # The clone checked out main, fetching its blobs. + # Backfill the three endpoint commits needed for merge. + main_oid=$(git -C merge-dest rev-parse origin/main) && + side_oid=$(git -C merge-dest rev-parse origin/side) && + mbase=$(git -C merge-dest merge-base origin/main origin/side) && + git -C merge-dest backfill --no-include-edges \ + "$main_oid^!" "$side_oid^!" "$mbase^!" && + + # Merge should complete without promisor fetches. + GIT_TRACE2_EVENT="$(pwd)/merge-trace" git -C merge-dest \ + merge origin/side -m "test merge" && + + ! grep "fetch_count" merge-trace +' + . "$TEST_DIRECTORY"/lib-httpd.sh start_httpd From 6da647d8522641f932528de0167413719a38a2c0 Mon Sep 17 00:00:00 2001 From: Siddharth Asthana Date: Thu, 16 Apr 2026 09:02:50 +0530 Subject: [PATCH 064/608] cat-file: add mailmap subcommand to --batch-command git-cat-file(1)'s --batch-command works with the --use-mailmap option, but this option needs to be set when the process is created. This means we cannot change this option mid-operation. At GitLab, Gitaly keeps interacting with a long-lived git-cat-file process and it would be useful if --batch-command supported toggling mailmap dynamically on an existing process. Add a `mailmap` subcommand to --batch-command that takes a boolean argument (usual ways you can specify a boolean value like 'yes', 'true', etc., are supported). Mailmap data is loaded lazily and kept in memory, while a helper centralizes the one-time load path used both at startup and from the batch-command handler. Extend tests to cover runtime toggling, startup option interactions (`--mailmap`/`--no-mailmap`), accepted boolean forms, and invalid values. Signed-off-by: Siddharth Asthana Signed-off-by: Junio C Hamano --- Documentation/git-cat-file.adoc | 5 ++ builtin/cat-file.c | 37 +++++++++-- t/t4203-mailmap.sh | 105 ++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/Documentation/git-cat-file.adoc b/Documentation/git-cat-file.adoc index c139f55a168d61..86b9181599317e 100644 --- a/Documentation/git-cat-file.adoc +++ b/Documentation/git-cat-file.adoc @@ -174,6 +174,11 @@ flush:: since the beginning or since the last flush was issued. When `--buffer` is used, no output will come until a `flush` is issued. When `--buffer` is not used, commands are flushed each time without issuing `flush`. + +`mailmap ()`:: + Enable or disable mailmap for subsequent commands. The `` + argument accepts the same boolean values as linkgit:git-config[1]. + The mailmap data is read upon the first use and only once. -- + diff --git a/builtin/cat-file.c b/builtin/cat-file.c index b6f12f41d6070a..631e19a4fbf4ca 100644 --- a/builtin/cat-file.c +++ b/builtin/cat-file.c @@ -57,6 +57,20 @@ static int use_mailmap; static char *replace_idents_using_mailmap(char *, size_t *); +/* + * The mailmap is initialized with .strdup_strings set to 0, + * but read_mailmap() sets the bit to 1 (this is true even when + * not a single mailmap entry is read), so it can be used for + * lazy loading. + */ +static void load_mailmap(void) +{ + if (mailmap.strdup_strings) + return; + + read_mailmap(the_repository, &mailmap); +} + static char *replace_idents_using_mailmap(char *object_buf, size_t *size) { struct strbuf sb = STRBUF_INIT; @@ -692,6 +706,20 @@ static void parse_cmd_info(struct batch_options *opt, batch_one_object(line, output, opt, data); } +static void parse_cmd_mailmap(struct batch_options *opt UNUSED, + const char *line, + struct strbuf *output UNUSED, + struct expand_data *data UNUSED) +{ + use_mailmap = git_parse_maybe_bool(line); + + if (use_mailmap < 0) + die(_("mailmap: invalid boolean '%s'"), line); + + if (use_mailmap) + load_mailmap(); +} + static void dispatch_calls(struct batch_options *opt, struct strbuf *output, struct expand_data *data, @@ -725,9 +753,10 @@ static const struct parse_cmd { parse_cmd_fn_t fn; unsigned takes_args; } commands[] = { - { "contents", parse_cmd_contents, 1}, - { "info", parse_cmd_info, 1}, - { "flush", NULL, 0}, + { "contents", parse_cmd_contents, 1 }, + { "info", parse_cmd_info, 1 }, + { "flush", NULL, 0 }, + { "mailmap", parse_cmd_mailmap, 1 }, }; static void batch_objects_command(struct batch_options *opt, @@ -1128,7 +1157,7 @@ int cmd_cat_file(int argc, opt_epts = (opt == 'e' || opt == 'p' || opt == 't' || opt == 's'); if (use_mailmap) - read_mailmap(the_repository, &mailmap); + load_mailmap(); switch (batch.objects_filter.choice) { case LOFC_DISABLED: diff --git a/t/t4203-mailmap.sh b/t/t4203-mailmap.sh index 74b7ddccb26d59..249548eb9bc159 100755 --- a/t/t4203-mailmap.sh +++ b/t/t4203-mailmap.sh @@ -1133,6 +1133,111 @@ test_expect_success 'git cat-file --batch-command returns correct size with --us test_cmp expect actual ' +test_expect_success 'git cat-file --batch-command mailmap yes enables mailmap mid-stream' ' + test_when_finished "rm .mailmap" && + cat >.mailmap <<-\EOF && + C O Mitter Orig + EOF + commit_sha=$(git rev-parse HEAD) && + git cat-file commit HEAD >commit_no_mailmap.out && + git cat-file --use-mailmap commit HEAD >commit_mailmap.out && + size_no_mailmap=$(wc -c actual && + echo $commit_sha commit $size_no_mailmap >expect && + echo $commit_sha commit $size_mailmap >>expect && + test_cmp expect actual +' + +test_expect_success 'git cat-file --batch-command mailmap no disables mailmap mid-stream' ' + test_when_finished "rm .mailmap" && + cat >.mailmap <<-\EOF && + C O Mitter Orig + EOF + commit_sha=$(git rev-parse HEAD) && + git cat-file commit HEAD >commit_no_mailmap.out && + git cat-file --use-mailmap commit HEAD >commit_mailmap.out && + size_no_mailmap=$(wc -c actual && + echo $commit_sha commit $size_mailmap >expect && + echo $commit_sha commit $size_no_mailmap >>expect && + test_cmp expect actual +' + +test_expect_success 'git cat-file --batch-command mailmap works in --buffer mode' ' + test_when_finished "rm .mailmap" && + cat >.mailmap <<-\EOF && + C O Mitter Orig + EOF + commit_sha=$(git rev-parse HEAD) && + git cat-file commit HEAD >commit_no_mailmap.out && + git cat-file --use-mailmap commit HEAD >commit_mailmap.out && + size_no_mailmap=$(wc -c actual && + echo $commit_sha commit $size_mailmap >expect && + echo $commit_sha commit $size_no_mailmap >>expect && + test_cmp expect actual +' + +test_expect_success 'git cat-file --batch-command mailmap no overrides startup --mailmap' ' + test_when_finished "rm .mailmap" && + cat >.mailmap <<-\EOF && + C O Mitter Orig + EOF + commit_sha=$(git rev-parse HEAD) && + git cat-file --use-mailmap commit HEAD >commit_mailmap.out && + size_mailmap=$(wc -c commit_no_mailmap.out && + size_no_mailmap=$(wc -c actual && + echo $commit_sha commit $size_mailmap >expect && + echo $commit_sha commit $size_no_mailmap >>expect && + test_cmp expect actual +' + +test_expect_success 'git cat-file --batch-command mailmap yes overrides startup --no-mailmap' ' + test_when_finished "rm .mailmap" && + cat >.mailmap <<-\EOF && + C O Mitter Orig + EOF + commit_sha=$(git rev-parse HEAD) && + git cat-file commit HEAD >commit_no_mailmap.out && + size_no_mailmap=$(wc -c commit_mailmap.out && + size_mailmap=$(wc -c actual && + echo $commit_sha commit $size_no_mailmap >expect && + echo $commit_sha commit $size_mailmap >>expect && + test_cmp expect actual +' + +test_expect_success 'git cat-file --batch-command mailmap accepts true/false' ' + test_when_finished "rm .mailmap" && + cat >.mailmap <<-\EOF && + C O Mitter Orig + EOF + commit_sha=$(git rev-parse HEAD) && + git cat-file commit HEAD >commit_no_mailmap.out && + size_no_mailmap=$(wc -c commit_mailmap.out && + size_mailmap=$(wc -c actual && + echo $commit_sha commit $size_mailmap >expect && + echo $commit_sha commit $size_no_mailmap >>expect && + test_cmp expect actual +' + +test_expect_success 'git cat-file --batch-command mailmap rejects invalid boolean' ' + echo "mailmap maybe" >in && + test_must_fail git cat-file --batch-command err && + test_grep "mailmap: invalid boolean .*maybe" err +' + test_expect_success 'git cat-file --mailmap works with different author and committer' ' test_when_finished "rm .mailmap" && cat >.mailmap <<-\EOF && From a8faa7a56033486c576b6386798dca4591e163eb Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 16 Apr 2026 09:20:57 +0000 Subject: [PATCH 065/608] http: extract http_reauth_prepare() from retry paths All three HTTP retry paths (http_request_recoverable, post_rpc, probe_rpc) call credential_fill() directly when handling HTTP_REAUTH. Extract this into a helper function so that a subsequent commit can add pre-fill logic (such as attempting empty-auth before prompting) in one place. No functional change. Signed-off-by: Matthew John Cheetham Signed-off-by: Junio C Hamano --- http.c | 7 ++++++- http.h | 6 ++++++ remote-curl.c | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/http.c b/http.c index 67c9c6fc60673d..f3ba2964b943ef 100644 --- a/http.c +++ b/http.c @@ -665,6 +665,11 @@ static void init_curl_http_auth(CURL *result) } } +void http_reauth_prepare(int all_capabilities) +{ + credential_fill(the_repository, &http_auth, all_capabilities); +} + /* *var must be free-able */ static void var_override(char **var, char *value) { @@ -2398,7 +2403,7 @@ static int http_request_recoverable(const char *url, sleep(retry_delay); } } else if (ret == HTTP_REAUTH) { - credential_fill(the_repository, &http_auth, 1); + http_reauth_prepare(1); } ret = http_request(url, result, target, options); diff --git a/http.h b/http.h index f9ee888c3ed67e..729c51904d39ad 100644 --- a/http.h +++ b/http.h @@ -76,6 +76,12 @@ extern int http_is_verbose; extern ssize_t http_post_buffer; extern struct credential http_auth; +/** + * Prepare for an HTTP re-authentication retry. This fills credentials + * via credential_fill() so the next request can include them. + */ +void http_reauth_prepare(int all_capabilities); + extern char curl_errorstr[CURL_ERROR_SIZE]; enum http_follow_config { diff --git a/remote-curl.c b/remote-curl.c index aba60d571282d3..affdb880f7b3bf 100644 --- a/remote-curl.c +++ b/remote-curl.c @@ -946,7 +946,7 @@ static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_rece do { err = probe_rpc(rpc, &results); if (err == HTTP_REAUTH) - credential_fill(the_repository, &http_auth, 0); + http_reauth_prepare(0); } while (err == HTTP_REAUTH); if (err != HTTP_OK) return -1; @@ -1068,7 +1068,7 @@ static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_rece rpc->any_written = 0; err = run_slot(slot, NULL); if (err == HTTP_REAUTH && !large_request) { - credential_fill(the_repository, &http_auth, 0); + http_reauth_prepare(0); curl_slist_free_all(headers); goto retry; } From 5dbc8c1367226cae6acaa1626d31f01bd186a28c Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 16 Apr 2026 09:20:58 +0000 Subject: [PATCH 066/608] http: attempt Negotiate auth in http.emptyAuth=auto mode When a server advertises Negotiate (SPNEGO) authentication, the "auto" mode of http.emptyAuth should detect this as an "exotic" method and proactively send empty credentials, allowing libcurl to use the system Kerberos ticket without prompting the user. However, two features interact to prevent this from working: The Negotiate-stripping logic, introduced in 4dbe66464b (remote-curl: fall back to Basic auth if Negotiate fails, 2015-01-08), removes CURLAUTH_GSSNEGOTIATE from the allowed methods on the first 401 response. The empty-auth auto-detection, introduced in 40a18fc77c (http: add an "auto" mode for http.emptyauth, 2017-02-25), then checks the remaining methods for anything "exotic" -- but Negotiate has already been removed, so auto mode never activates for servers whose only non-Basic/Digest method is Negotiate (e.g., Apache with mod_auth_kerb offering Basic + Negotiate). Fix this by delaying the Negotiate stripping in auto mode: on the first 401, keep Negotiate in the allowed methods so that auto mode can detect it and retry with empty credentials. If that attempt fails (no valid Kerberos ticket), strip Negotiate on the second 401 and fall through to credential_fill() as usual. To support this, also teach http_reauth_prepare() to skip credential_fill() when empty auth is about to be attempted, since filling real credentials would bypass the empty-auth mechanism. The true and false modes are unchanged: true sends empty credentials on the very first request (before any 401), and false never sends them. Signed-off-by: Matthew John Cheetham Signed-off-by: Junio C Hamano --- http.c | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/http.c b/http.c index f3ba2964b943ef..412f7af2504b26 100644 --- a/http.c +++ b/http.c @@ -138,6 +138,7 @@ static unsigned long empty_auth_useless = CURLAUTH_BASIC | CURLAUTH_DIGEST_IE | CURLAUTH_DIGEST; +static int empty_auth_try_negotiate; static struct curl_slist *pragma_header; static struct string_list extra_http_headers = STRING_LIST_INIT_DUP; @@ -667,6 +668,17 @@ static void init_curl_http_auth(CURL *result) void http_reauth_prepare(int all_capabilities) { + /* + * If we deferred stripping Negotiate to give empty auth a + * chance (auto mode), skip credential_fill on this retry so + * that init_curl_http_auth() sends empty credentials and + * libcurl can attempt Negotiate with the system ticket cache. + */ + if (empty_auth_try_negotiate && + !http_auth.password && !http_auth.credential && + (http_auth_methods & CURLAUTH_GSSNEGOTIATE)) + return; + credential_fill(the_repository, &http_auth, all_capabilities); } @@ -1895,7 +1907,18 @@ static int handle_curl_result(struct slot_results *results) http_proactive_auth = PROACTIVE_AUTH_NONE; return HTTP_NOAUTH; } else { - http_auth_methods &= ~CURLAUTH_GSSNEGOTIATE; + if (curl_empty_auth == -1 && + !empty_auth_try_negotiate && + (results->auth_avail & CURLAUTH_GSSNEGOTIATE)) { + /* + * In auto mode, give Negotiate a chance via + * empty auth before stripping it. If it fails, + * we will strip it on the next 401. + */ + empty_auth_try_negotiate = 1; + } else { + http_auth_methods &= ~CURLAUTH_GSSNEGOTIATE; + } if (results->auth_avail) { http_auth_methods &= results->auth_avail; http_auth_methods_restricted = 1; From 9b1630b97273beceb64ea8f740c5820317aaa8b3 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 16 Apr 2026 09:20:59 +0000 Subject: [PATCH 067/608] t5563: add tests for http.emptyAuth with Negotiate Add tests exercising the interaction between http.emptyAuth and servers that advertise Negotiate (SPNEGO) authentication. Verify that auto mode gives Negotiate a chance via empty auth (resulting in two 401 responses before falling through to credential_fill with Basic credentials), and that false mode strips Negotiate immediately (only one 401 response). Signed-off-by: Matthew John Cheetham Signed-off-by: Junio C Hamano --- t/t5563-simple-http-auth.sh | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/t/t5563-simple-http-auth.sh b/t/t5563-simple-http-auth.sh index 00635816156ba3..a7d475dd68dbd7 100755 --- a/t/t5563-simple-http-auth.sh +++ b/t/t5563-simple-http-auth.sh @@ -719,4 +719,78 @@ test_expect_success 'access using three-legged auth' ' EOF ' +test_lazy_prereq SPNEGO 'curl --version | grep -qi "SPNEGO\|GSS-API\|Kerberos\|negotiate"' + +test_expect_success SPNEGO 'http.emptyAuth=auto attempts Negotiate before credential_fill' ' + test_when_finished "per_test_cleanup" && + + set_credential_reply get <<-EOF && + username=alice + password=secret-passwd + EOF + + # Basic base64(alice:secret-passwd) + cat >"$HTTPD_ROOT_PATH/custom-auth.valid" <<-EOF && + id=1 creds=Basic YWxpY2U6c2VjcmV0LXBhc3N3ZA== + EOF + + cat >"$HTTPD_ROOT_PATH/custom-auth.challenge" <<-EOF && + id=1 status=200 + id=default response=WWW-Authenticate: Negotiate + id=default response=WWW-Authenticate: Basic realm="example.com" + EOF + + test_config_global credential.helper test-helper && + GIT_TRACE_CURL="$TRASH_DIRECTORY/trace-auto" \ + git -c http.emptyAuth=auto \ + ls-remote "$HTTPD_URL/custom_auth/repo.git" && + + # In auto mode with a Negotiate+Basic server, there should be + # three 401 responses: (1) initial no-auth request, (2) empty-auth + # retry where Negotiate fails (no Kerberos ticket), (3) libcurl + # internal Negotiate retry. The fourth attempt uses Basic + # credentials from credential_fill and succeeds. + grep "HTTP/[0-9.]* 401" "$TRASH_DIRECTORY/trace-auto" >actual_401s && + test_line_count = 3 actual_401s && + + expect_credential_query get <<-EOF + capability[]=authtype + capability[]=state + protocol=http + host=$HTTPD_DEST + wwwauth[]=Negotiate + wwwauth[]=Basic realm="example.com" + EOF +' + +test_expect_success SPNEGO 'http.emptyAuth=false skips Negotiate' ' + test_when_finished "per_test_cleanup" && + + set_credential_reply get <<-EOF && + username=alice + password=secret-passwd + EOF + + # Basic base64(alice:secret-passwd) + cat >"$HTTPD_ROOT_PATH/custom-auth.valid" <<-EOF && + id=1 creds=Basic YWxpY2U6c2VjcmV0LXBhc3N3ZA== + EOF + + cat >"$HTTPD_ROOT_PATH/custom-auth.challenge" <<-EOF && + id=1 status=200 + id=default response=WWW-Authenticate: Negotiate + id=default response=WWW-Authenticate: Basic realm="example.com" + EOF + + test_config_global credential.helper test-helper && + GIT_TRACE_CURL="$TRASH_DIRECTORY/trace-false" \ + git -c http.emptyAuth=false \ + ls-remote "$HTTPD_URL/custom_auth/repo.git" && + + # With emptyAuth=false, Negotiate is stripped immediately and + # credential_fill is called right away. Only one 401 response. + grep "HTTP/[0-9.]* 401" "$TRASH_DIRECTORY/trace-false" >actual_401s && + test_line_count = 1 actual_401s +' + test_done From b96490241e342fe1aecbd3c4f40de6998d2a3eaa Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Fri, 10 Apr 2026 11:10:48 -0700 Subject: [PATCH 068/608] CodingGuidelines: st_mtimespec vs st_mtim vs st_mtime Most unfortunately macOS does not support st_[amc]tim for timestamps down to nanosecond resolution as POSIX systems. Signed-off-by: Junio C Hamano --- Documentation/CodingGuidelines | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Documentation/CodingGuidelines b/Documentation/CodingGuidelines index b8670751f5c705..b9a29e39f2eec4 100644 --- a/Documentation/CodingGuidelines +++ b/Documentation/CodingGuidelines @@ -681,6 +681,12 @@ For C programs: char *dogs[] = ...; walk_all_dogs(dogs); + - For file timestamps, do not use "st_mtim" (and other timestamp + members in "struct stat") unconditionally; not everybody is POSIX + (grep for USE_ST_TIMESPEC). If you only need a timestamp in whole + second resolution, "st_mtime" should work fine everywhere. + + For Perl programs: - Most of the C guidelines above apply. From 09d86a3b98573cba6d7c813ec97eadb69e3e2dfd Mon Sep 17 00:00:00 2001 From: Elijah Newren Date: Mon, 20 Apr 2026 15:42:58 +0000 Subject: [PATCH 069/608] diff: fix out-of-bounds reads and NULL deref in diffstat UTF-8 truncation f85b49f3d4a (diff: improve scaling of filenames in diffstat to handle UTF-8 chars, 2026-01-16) introduced a loop in show_stats() that calls utf8_width() repeatedly to skip leading characters until the displayed width fits. However, utf8_width() can return problematic values: - For invalid UTF-8 sequences, pick_one_utf8_char() sets the name pointer to NULL and utf8_width() returns 0. Since name_len does not change, the loop iterates once more and pick_one_utf8_char() dereferences the NULL pointer, crashing. - For control characters, utf8_width() returns -1, so name_len grows when it is expected to shrink. This can cause the loop to consume more characters than the string contains, reading past the trailing NUL. By default, fill_print_name() will C-quote filenames which escapes control characters and invalid bytes to printable text. That avoids this bug from being triggered; however, with core.quotePath=false, most characters are no longer escaped (though some control characters still are) and raw bytes can reach this code. Add tests exercising both failure modes with core.quotePath=false and a narrow --stat-name-width to force truncation: one with a bare 0xC0 byte (invalid UTF-8 lead byte, triggers NULL deref) and one with several C1 control characters (repeats of 0xC2 0x9F, causing the loop to read past the end of the string). The second test reliably catches the out-of-bounds read when run under ASan, though it may pass silently without sanitizers. Fix both issues by introducing utf8_ish_width(), a thin wrapper around utf8_width() that guarantees the pointer always advances and the returned width is never negative: - On invalid UTF-8 it restores the pointer, advances by one byte, and returns width 1 (matching the strlen()-based fallback used by utf8_strwidth()). - On a control character it returns 0 (matching utf8_strnwidth() which skips them). Also add a "&& *name" guard to the while-loop condition so it terminates at end-of-string even when utf8_strwidth()'s strlen() fallback causes name_len to exceed the sum of per-character widths. Signed-off-by: Elijah Newren Signed-off-by: Junio C Hamano --- diff.c | 26 ++++++++++++++++++++++++-- t/t4052-stat-output.sh | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/diff.c b/diff.c index 397e38b41cc6fa..1a3b19f71f0e30 100644 --- a/diff.c +++ b/diff.c @@ -2927,6 +2927,28 @@ void print_stat_summary(FILE *fp, int files, print_stat_summary_inserts_deletes(&o, files, insertions, deletions); } +/* + * Like utf8_width(), but guaranteed safe for use in loops that subtract + * per-character widths: + * + * - utf8_width() sets *start to NULL on invalid UTF-8 and returns 0; + * we restore the pointer and advance by one byte, returning width 1 + * (matching the strlen()-based fallback in utf8_strwidth()). + * + * - utf8_width() returns -1 for control characters; we return 0 + * (matching utf8_strnwidth() which skips them). + */ +static int utf8_ish_width(const char **start) +{ + const char *old = *start; + int w = utf8_width(start, NULL); + if (!*start) { + *start = old + 1; + return 1; + } + return (w < 0) ? 0 : w; +} + static void show_stats(struct diffstat_t *data, struct diff_options *options) { int i, len, add, del, adds = 0, dels = 0; @@ -3093,8 +3115,8 @@ static void show_stats(struct diffstat_t *data, struct diff_options *options) if (len < 0) len = 0; - while (name_len > len) - name_len -= utf8_width((const char**)&name, NULL); + while (name_len > len && *name) + name_len -= utf8_ish_width((const char**)&name); slash = strchr(name, '/'); if (slash) diff --git a/t/t4052-stat-output.sh b/t/t4052-stat-output.sh index 7c749062e2b8d1..e0095859250018 100755 --- a/t/t4052-stat-output.sh +++ b/t/t4052-stat-output.sh @@ -445,4 +445,29 @@ test_expect_success 'diffstat where line_prefix contains ANSI escape codes is co test_grep "| ${FILENAME_TRIMMED} | 0" out ' +test_expect_success 'diffstat truncation with invalid UTF-8 does not crash' ' + empty_blob=$(git hash-object -w --stdin tree_file && + tree=$(cat tree_file) && + empty_tree=$(git mktree output && + test_grep "| 0" output +' + +test_expect_success FUNNYNAMES 'diffstat truncation with control chars does not read out of bounds' ' + FNAME=$(printf "aaa-\302\237\302\237\302\237-aaa") && + git commit --allow-empty -m setup && + >$FNAME && + git add -- $FNAME && + git commit -m "add file with control char name" && + git -c core.quotepath=false diff --stat --stat-name-width=5 HEAD~1..HEAD >output && + test_grep "| 0" output && + rm -- $FNAME && + git rm -- $FNAME && + git commit -m "remove test file" +' + test_done From 7002d6cd16047c0ed0b6befc22b5a7d54d4d6fde Mon Sep 17 00:00:00 2001 From: Siddharth Shrimali Date: Tue, 21 Apr 2026 11:03:32 +0530 Subject: [PATCH 070/608] t7004: drop hardcoded tag count for state verification The test 'trying to create a tag with a non-valid name should fail', checked that exactly one tag existed in the repository before and after attempting to create invalid tags. As pointed out by Junio, this makes the test brittle by relying on a specific global tag count. If future tests are added or removed before this test, the expected state changes and this test would break for completely unrelated reasons. Modernize the test by taking a snapshot of the existing tags before the failure attempts and comparing it to a snapshot taken after. This provides a "belt-and-suspenders" approach: we verify that 'git tag' both exits with the expected error code and leaves the repository state untouched, without being brittle to the specific number of tags present. This replaces the hardcoded 'test_line_count = 1' checks with 'test_cmp' to ensure the tag list remains identical. Suggested-by: Junio C Hamano Signed-off-by: Siddharth Shrimali Signed-off-by: Junio C Hamano --- t/t7004-tag.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/t/t7004-tag.sh b/t/t7004-tag.sh index faf7d97fc487d3..77a7a9777d1989 100755 --- a/t/t7004-tag.sh +++ b/t/t7004-tag.sh @@ -191,15 +191,14 @@ test_expect_success 'trying to create a tag with the name of one existing should ' test_expect_success 'trying to create a tag with a non-valid name should fail' ' - git tag -l >actual && - test_line_count = 1 actual && + git tag -l >tags-before && test_must_fail git tag "" && test_must_fail git tag .othertag && test_must_fail git tag "other tag" && test_must_fail git tag "othertag^" && test_must_fail git tag "other~tag" && - git tag -l >actual && - test_line_count = 1 actual + git tag -l >tags-after && + test_cmp tags-before tags-after ' test_expect_success 'creating a tag using HEAD directly should succeed' ' From e3253255d3e1c007e80742c304ddde9421dca9ca Mon Sep 17 00:00:00 2001 From: Siddharth Shrimali Date: Tue, 21 Apr 2026 11:03:33 +0530 Subject: [PATCH 071/608] t7004: dynamically grab expected state in tests The tests for 'Multiple -l or --list options' and 'trying to delete tags without params', hardcodes that exactly one or two specific tags ('myhead', 'mytag') exist in the repository. If other tests are added, modified, or removed earlier in the script, this expected global state will change, resulting in these tests to fail for completely unrelated reasons. Instead of hardcoding the expected tags, dynamically grab the state of the repository before running the commands under test ('git tag -l' and 'git tag -d'), and verify that the output matches or remains unchanged afterward. This keeps the tests independent from the script's overall state. Signed-off-by: Siddharth Shrimali Signed-off-by: Junio C Hamano --- t/t7004-tag.sh | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/t/t7004-tag.sh b/t/t7004-tag.sh index 77a7a9777d1989..bef7618da24433 100755 --- a/t/t7004-tag.sh +++ b/t/t7004-tag.sh @@ -145,9 +145,7 @@ test_expect_success 'listing all tags if one exists should succeed' ' ' test_expect_success 'Multiple -l or --list options are equivalent to one -l option' ' - cat >expect <<-\EOF && - mytag - EOF + git tag -l >expect && git tag -l -l >actual && test_cmp expect actual && git tag --list --list >actual && @@ -226,12 +224,7 @@ test_expect_success 'trying to delete an unknown tag should fail' ' ' test_expect_success 'trying to delete tags without params should succeed and do nothing' ' - cat >expect <<-\EOF && - myhead - mytag - EOF - git tag -l >actual && - test_cmp expect actual && + git tag -l >expect && git tag -d && git tag -l >actual && test_cmp expect actual From ef85286e511b4cebfdce0c4bffc7c8985274f142 Mon Sep 17 00:00:00 2001 From: Siddharth Shrimali Date: Tue, 21 Apr 2026 11:03:34 +0530 Subject: [PATCH 072/608] t7004: avoid subshells to capture git exit codes Several tests in t7004 use the 'test$(git ...) = ...' or the '! (git ...)' subshell pattern. This swallows git's exit code. If git crashes (e.g. segmentation fault) the crash would go undetected, and the test would fail due to a mismatch or an inverted exit code. Modernize these tests by directly writing output to files(actual) and verifying them with 'test_cmp' or 'test_grep'. Replace subshell negations with 'test_must_fail'. This way, if git crashes, the test fails immediately and clearly instead of hiding the error behind a string mismatch. Signed-off-by: Siddharth Shrimali Signed-off-by: Junio C Hamano --- t/t7004-tag.sh | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/t/t7004-tag.sh b/t/t7004-tag.sh index bef7618da24433..d918005dd9859a 100755 --- a/t/t7004-tag.sh +++ b/t/t7004-tag.sh @@ -155,8 +155,10 @@ test_expect_success 'Multiple -l or --list options are equivalent to one -l opti ' test_expect_success 'listing all tags if one exists should output that tag' ' - test $(git tag -l) = mytag && - test $(git tag) = mytag + git tag -l >actual && + test_grep "^mytag$" actual && + git tag >actual && + test_grep "^mytag$" actual ' # pattern matching: @@ -166,11 +168,15 @@ test_expect_success 'listing a tag using a matching pattern should succeed' ' ' test_expect_success 'listing a tag with --ignore-case' ' - test $(git tag -l --ignore-case MYTAG) = mytag + echo mytag >expect && + git tag -l --ignore-case MYTAG >actual && + test_cmp expect actual ' test_expect_success 'listing a tag using a matching pattern should output that tag' ' - test $(git tag -l mytag) = mytag + echo mytag >expect && + git tag -l mytag >actual && + test_cmp expect actual ' test_expect_success 'listing tags using a non-matching pattern should succeed' ' @@ -430,8 +436,12 @@ test_expect_success 'listing tags -n in column with column.ui ignored' ' test_expect_success 'a non-annotated tag created without parameters should point to HEAD' ' git tag non-annotated-tag && - test $(git cat-file -t non-annotated-tag) = commit && - test $(git rev-parse non-annotated-tag) = $(git rev-parse HEAD) + echo commit >expect && + git cat-file -t non-annotated-tag >actual && + test_cmp expect actual && + git rev-parse HEAD >expect && + git rev-parse non-annotated-tag >actual && + test_cmp expect actual ' test_expect_success 'trying to verify an unknown tag should fail' ' @@ -1520,11 +1530,11 @@ test_expect_success GPG 'verify signed tag fails when public key is not present' ' test_expect_success 'git tag -a fails if tag annotation is empty' ' - ! (GIT_EDITOR=cat git tag -a initial-comment) + test_must_fail env GIT_EDITOR=cat git tag -a initial-comment ' test_expect_success 'message in editor has initial comment' ' - ! (GIT_EDITOR=cat git tag -a initial-comment >actual) + test_must_fail env GIT_EDITOR=cat git tag -a initial-comment >actual ' test_expect_success 'message in editor has initial comment: first line' ' From 0cb6316b08463d9ec491e6db5ce53c3a65c048a8 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:14 +0200 Subject: [PATCH 073/608] t: prepare `test_match_signal ()` calls for `set -e` We have a couple of calls to `test_match_signal ()` where we execute a Git command and expect it to die with a specific signal. These calls will essentially execute the process in a subshell via `foo; echo $?`, but as we expect `foo` to fail this will cause the overall subshell to fail once we `set -e`. Fix this issue by using `foo && echo 0 || echo $?` instead. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/t0005-signals.sh | 4 ++-- t/t3600-rm.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/t/t0005-signals.sh b/t/t0005-signals.sh index afba0fc3fc673e..84319cf169e571 100755 --- a/t/t0005-signals.sh +++ b/t/t0005-signals.sh @@ -42,12 +42,12 @@ test_expect_success 'create blob' ' ' test_expect_success !MINGW 'a constipated git dies with SIGPIPE' ' - OUT=$( ((large_git; echo $? 1>&3) | :) 3>&1 ) && + OUT=$( ((large_git && echo 0 1>&3 || echo $? 1>&3) | :) 3>&1 ) && test_match_signal 13 "$OUT" ' test_expect_success !MINGW 'a constipated git dies with SIGPIPE even if parent ignores it' ' - OUT=$( ((trap "" PIPE && large_git; echo $? 1>&3) | :) 3>&1 ) && + OUT=$( ((trap "" PIPE && large_git && echo 0 1>&3 || echo $? 1>&3) | :) 3>&1 ) && test_match_signal 13 "$OUT" ' diff --git a/t/t3600-rm.sh b/t/t3600-rm.sh index 1f16e6b52285bc..a371ea690ef79d 100755 --- a/t/t3600-rm.sh +++ b/t/t3600-rm.sh @@ -260,7 +260,7 @@ test_expect_success 'choking "git rm" should not let it die with cruft (induce S test_expect_success !MINGW 'choking "git rm" should not let it die with cruft (induce and check SIGPIPE)' ' choke_git_rm_setup && - OUT=$( ((trap "" PIPE && git rm -n "some-file-*"; echo $? 1>&3) | :) 3>&1 ) && + OUT=$( ((trap "" PIPE && git rm -n "some-file-*" && echo 0 1>&3 || echo $? 1>&3) | :) 3>&1 ) && test_match_signal 13 "$OUT" && test_path_is_missing .git/index.lock ' From 990fd368ac7fcb56f69a27ba341cac10f04e285b Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:15 +0200 Subject: [PATCH 074/608] t: prepare `test_must_fail ()` for `set -e` The helper function `test_must_fail ()` executes a specific Git command that may or may not fail in a specific way. This is done by executing the command in question and then comparing its exit code against a set of conditions. This works, but once we run our test suite with `set -e` we may bail out of `test_must_fail ()` early in case the command actually fails, even though we expect it to fail. Prepare for this change by handling the failed case with `||`. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/test-lib-functions.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/t/test-lib-functions.sh b/t/test-lib-functions.sh index f3af10fb7e0205..5fd5494ef1030c 100644 --- a/t/test-lib-functions.sh +++ b/t/test-lib-functions.sh @@ -1195,8 +1195,9 @@ test_must_fail () { echo >&7 "test_must_fail: only 'git' is allowed: $*" return 1 fi - "$@" 2>&7 - exit_code=$? + + exit_code=0; "$@" 2>&7 || exit_code=$? + if test $exit_code -eq 0 && ! list_contains "$_test_ok" success then echo >&4 "test_must_fail: command succeeded: $*" From f43d01ab90be2711927c8d265b3fb21d2213d2ab Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:16 +0200 Subject: [PATCH 075/608] t: prepare `stop_git_daemon ()` for `set -e` We have a couple of calls to `stop_git_daemon ()` outside of specific test cases that will kill a backgrounded git-daemon(1) process and expect the process with a specific error code. While these function calls do end up killing git-daemon(1), the error handling we have in those contexts is basically ineffective. So while we expect the process to exit with a specific error code, we will just continue with any error in case it doesn't. This will change once we enable `set -e` in a subsequent commit. There's two issues though that will make this _always_ fail: - Our call to `wait` is expected to fail, but because it's not part of a condition it will cause us to bail out immediately with `set -e`. - We try to kill git-daemon(1) a second time via the pidfile. We can generally expect that this is the same PID though as we had in the "GIT_DAEMON_PID" environment variable, and thus it's more likely than not that we have already killed it, and the call to kill will fail. Prepare for this change by handling the failure of `wait` with `||` and by silencing failures of the second call to `kill`. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/lib-git-daemon.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/t/lib-git-daemon.sh b/t/lib-git-daemon.sh index e62569222b55aa..d172aa51f0c386 100644 --- a/t/lib-git-daemon.sh +++ b/t/lib-git-daemon.sh @@ -85,14 +85,16 @@ stop_git_daemon() { # kill git-daemon child of git say >&3 "Stopping git daemon ..." + kill "$GIT_DAEMON_PID" - wait "$GIT_DAEMON_PID" >&3 2>&4 - ret=$? + ret=0; wait "$GIT_DAEMON_PID" >&3 2>&4 || ret=$? + if ! test_match_signal 15 $ret then error "git daemon exited with status: $ret" fi - kill "$(cat "$GIT_DAEMON_PIDFILE")" 2>/dev/null + + kill "$(cat "$GIT_DAEMON_PIDFILE")" 2>/dev/null || : GIT_DAEMON_PID= rm -f git_daemon_output "$GIT_DAEMON_PIDFILE" } From 9c64add20b09cc47c191c2bc7b2503b02d290385 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:17 +0200 Subject: [PATCH 076/608] t: prepare `git config --unset` calls for `set -e` We have a couple of calls to `git config --unset` that ultimately end up as no-ops as the configuration variables aren't set (anymore) in the first place. These calls are mostly intended to recover unconditionally from tests that may have executed only partially, but they'll ultimately fail during a normal test run. This hasn't been a problem until now as we aren't running tests with `set -e`. This is about to change though, so let's silence the case where we cannot unset the config keys. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/t4032-diff-inter-hunk-context.sh | 2 +- t/t7508-status.sh | 4 ++-- t/t9138-git-svn-authors-prog.sh | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/t/t4032-diff-inter-hunk-context.sh b/t/t4032-diff-inter-hunk-context.sh index bada0cbd32f764..c98eb6abb2e3e5 100755 --- a/t/t4032-diff-inter-hunk-context.sh +++ b/t/t4032-diff-inter-hunk-context.sh @@ -17,7 +17,7 @@ f() { t() { use_config= - git config --unset diff.interHunkContext + git config --unset diff.interHunkContext || : case $# in 4) hunks=$4; cmd="diff -U$3";; diff --git a/t/t7508-status.sh b/t/t7508-status.sh index a5e21bf8bffb45..1167b835a4635c 100755 --- a/t/t7508-status.sh +++ b/t/t7508-status.sh @@ -773,8 +773,8 @@ test_expect_success TTY 'status --porcelain ignores color.status' ' ' # recover unconditionally from color tests -git config --unset color.status -git config --unset color.ui +git config --unset color.status || : +git config --unset color.ui || : test_expect_success 'status --porcelain respects -b' ' diff --git a/t/t9138-git-svn-authors-prog.sh b/t/t9138-git-svn-authors-prog.sh index 784ec7fc2d6e4d..5bb38cb23a64e9 100755 --- a/t/t9138-git-svn-authors-prog.sh +++ b/t/t9138-git-svn-authors-prog.sh @@ -68,8 +68,8 @@ test_expect_success 'authors-file overrode authors-prog' ' ) ' -git --git-dir=x/.git config --unset svn.authorsfile -git --git-dir=x/.git config --unset svn.authorsprog +git --git-dir=x/.git config --unset svn.authorsfile || : +git --git-dir=x/.git config --unset svn.authorsprog || : test_expect_success 'authors-prog imported user without email' ' svn mkdir -m gg --username gg-hermit "$svnrepo"/gg && From 0c6600cdc751e8c018e6baddc0edce070c7c3f55 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:18 +0200 Subject: [PATCH 077/608] t: prepare conditional test execution for `set -e` We have some test in our test suite where we use the pattern of `test ... && test_expect_succeess` to conditionally execute a test. The problem is that when we decide to not execute the test, we'll indeed skip the test, but the overall statement will also be unsuccessful. This will become a problem once we enable `set -e`. Prepare for this future by turning this into a proper conditional, which is also a bit easier to read overall. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/t4032-diff-inter-hunk-context.sh | 12 +++++++----- t/t7450-bad-git-dotfiles.sh | 24 +++++++++++++----------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/t/t4032-diff-inter-hunk-context.sh b/t/t4032-diff-inter-hunk-context.sh index c98eb6abb2e3e5..2d216fb70f982b 100755 --- a/t/t4032-diff-inter-hunk-context.sh +++ b/t/t4032-diff-inter-hunk-context.sh @@ -40,11 +40,13 @@ t() { test $(git $cmd $file | grep '^@@ ' | wc -l) = $hunks " - test -f $expected && - test_expect_success "$label: check output" " - git $cmd $file | grep -v '^index ' >actual && - test_cmp $expected actual - " + if test -f $expected + then + test_expect_success "$label: check output" " + git $cmd $file | grep -v '^index ' >actual && + test_cmp $expected actual + " + fi } cat <expected.f1.0.1 || exit 1 diff --git a/t/t7450-bad-git-dotfiles.sh b/t/t7450-bad-git-dotfiles.sh index f512eed278c46b..8cc86522b27d9b 100755 --- a/t/t7450-bad-git-dotfiles.sh +++ b/t/t7450-bad-git-dotfiles.sh @@ -220,17 +220,19 @@ check_dotx_symlink () { ) ' - test -n "$refuse_index" && - test_expect_success "refuse to load symlinked $name into index ($type)" ' - test_must_fail \ - git -C $dir \ - -c core.protectntfs \ - -c core.protecthfs \ - read-tree $tree 2>err && - grep "invalid path.*$name" err && - git -C $dir ls-files -s >out && - test_must_be_empty out - ' + if test -n "$refuse_index" + then + test_expect_success "refuse to load symlinked $name into index ($type)" ' + test_must_fail \ + git -C $dir \ + -c core.protectntfs \ + -c core.protecthfs \ + read-tree $tree 2>err && + grep "invalid path.*$name" err && + git -C $dir ls-files -s >out && + test_must_be_empty out + ' + fi } check_dotx_symlink gitmodules vanilla .gitmodules From 5f0d596fe4a7a4ea8752d3e5115190906c1bea4a Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:19 +0200 Subject: [PATCH 078/608] t: prepare execution of potentially failing commands for `set -e` Several of our tests verify whether a certain binary can be executed, potentially skipping tests in case we cannot, for example because the binary doesn't exist. In those cases we often run the binary outside of any conditionally. This will start to fail once we enable `set -e`, as that will cause us to bail out the test immediately. Improve these tests by executing them inside of a conditional instead. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/lib-git-svn.sh | 7 +++---- t/lib-httpd.sh | 3 +-- t/t3901-i18n-patch.sh | 3 ++- t/t5000-tar-tree.sh | 4 ++-- t/t7422-submodule-output.sh | 2 +- t/t9200-git-cvsexportcommit.sh | 3 +-- t/t9400-git-cvsserver-server.sh | 5 +++-- t/t9401-git-cvsserver-crlf.sh | 4 ++-- t/t9402-git-cvsserver-refs.sh | 4 ++-- t/test-lib-functions.sh | 3 +-- t/test-lib.sh | 10 ++++++---- 11 files changed, 24 insertions(+), 24 deletions(-) diff --git a/t/lib-git-svn.sh b/t/lib-git-svn.sh index 2fde2353fd3835..52843f667de0d6 100644 --- a/t/lib-git-svn.sh +++ b/t/lib-git-svn.sh @@ -15,8 +15,7 @@ GIT_SVN_DIR=$GIT_DIR/svn/refs/remotes/git-svn SVN_TREE=$GIT_SVN_DIR/svn-tree test_set_port SVNSERVE_PORT -svn >/dev/null 2>&1 -if test $? -ne 1 +if ! svn help >/dev/null 2>&1 then skip_all='skipping git svn tests, svn not found' test_done @@ -27,13 +26,13 @@ export svnrepo svnconf=$PWD/svnconf export svnconf +x=0 perl -w -e " use SVN::Core; use SVN::Repos; \$SVN::Core::VERSION gt '1.1.0' or exit(42); system(qw/svnadmin create --fs-type fsfs/, \$ENV{svnrepo}) == 0 or exit(41); -" >&3 2>&4 -x=$? +" >&3 2>&4 || x=$? if test $x -ne 0 then if test $x -eq 42; then diff --git a/t/lib-httpd.sh b/t/lib-httpd.sh index 4c76e813e396bf..fc646447d5c038 100644 --- a/t/lib-httpd.sh +++ b/t/lib-httpd.sh @@ -235,11 +235,10 @@ start_httpd() { test_atexit stop_httpd - "$LIB_HTTPD_PATH" -d "$HTTPD_ROOT_PATH" \ + if ! "$LIB_HTTPD_PATH" -d "$HTTPD_ROOT_PATH" \ -f "$TEST_PATH/apache.conf" $HTTPD_PARA \ -c "Listen 127.0.0.1:$LIB_HTTPD_PORT" -k start \ >&3 2>&4 - if test $? -ne 0 then cat "$HTTPD_ROOT_PATH"/error.log >&4 2>/dev/null test_skip_or_die GIT_TEST_HTTPD "web server setup failed" diff --git a/t/t3901-i18n-patch.sh b/t/t3901-i18n-patch.sh index f03601b49a9397..ef7d7e1edc2aee 100755 --- a/t/t3901-i18n-patch.sh +++ b/t/t3901-i18n-patch.sh @@ -28,7 +28,8 @@ check_encoding () { 8859) grep "^encoding ISO8859-1" ;; *) - grep "^encoding ISO8859-1"; test "$?" != 0 ;; + ret=0; grep "^encoding ISO8859-1" || ret=$? + test "$ret" != 0 ;; esac || return 1 j=$i i=$(($i+1)) diff --git a/t/t5000-tar-tree.sh b/t/t5000-tar-tree.sh index 5465054f1779f0..a8c28533dc73f8 100755 --- a/t/t5000-tar-tree.sh +++ b/t/t5000-tar-tree.sh @@ -503,8 +503,8 @@ test_expect_success LONG_IS_64BIT 'set up repository with huge blob' ' # would generate the whole 64GB). test_expect_success LONG_IS_64BIT 'generate tar with huge size' ' { - git archive HEAD - echo $? >exit-code + { ret=0 && git archive HEAD || ret=$?; } && + echo "$ret" >exit-code } | test_copy_bytes 4096 >huge.tar && echo 141 >expect && test_cmp expect exit-code diff --git a/t/t7422-submodule-output.sh b/t/t7422-submodule-output.sh index aea1ddf117e8ee..852136fdfd3ec8 100755 --- a/t/t7422-submodule-output.sh +++ b/t/t7422-submodule-output.sh @@ -198,7 +198,7 @@ test_expect_success !MINGW 'git submodule status --recursive propagates SIGPIPE' ( cd repo && GIT_ALLOW_PROTOCOL=file git submodule add "$(pwd)"/../submodule && - { git submodule status --recursive 2>err; echo $?>status; } | + { { ret=0 && git submodule status --recursive 2>err || ret=$?; } && echo $ret >status; } | grep -q recursive-submodule-path-1 && test_must_be_empty err && test_match_signal 13 "$(cat status)" diff --git a/t/t9200-git-cvsexportcommit.sh b/t/t9200-git-cvsexportcommit.sh index 14cbe9652779bc..581cf3d28fc05b 100755 --- a/t/t9200-git-cvsexportcommit.sh +++ b/t/t9200-git-cvsexportcommit.sh @@ -11,8 +11,7 @@ if ! test_have_prereq PERL; then test_done fi -cvs >/dev/null 2>&1 -if test $? -ne 1 +if ! cvs version >/dev/null 2>&1 then skip_all='skipping git cvsexportcommit tests, cvs not found' test_done diff --git a/t/t9400-git-cvsserver-server.sh b/t/t9400-git-cvsserver-server.sh index e499c7f955125e..4b45398bab2295 100755 --- a/t/t9400-git-cvsserver-server.sh +++ b/t/t9400-git-cvsserver-server.sh @@ -17,12 +17,13 @@ if ! test_have_prereq PERL; then skip_all='skipping git cvsserver tests, perl not available' test_done fi -cvs >/dev/null 2>&1 -if test $? -ne 1 + +if ! cvs version >/dev/null 2>&1 then skip_all='skipping git-cvsserver tests, cvs not found' test_done fi + perl -e 'use DBI; use DBD::SQLite' >/dev/null 2>&1 || { skip_all='skipping git-cvsserver tests, Perl SQLite interface unavailable' test_done diff --git a/t/t9401-git-cvsserver-crlf.sh b/t/t9401-git-cvsserver-crlf.sh index a34805acdc25cc..6b4cbb165131e0 100755 --- a/t/t9401-git-cvsserver-crlf.sh +++ b/t/t9401-git-cvsserver-crlf.sh @@ -60,12 +60,12 @@ check_status_options() { return $stat } -cvs >/dev/null 2>&1 -if test $? -ne 1 +if ! cvs version >/dev/null 2>&1 then skip_all='skipping git-cvsserver tests, cvs not found' test_done fi + if ! test_have_prereq PERL then skip_all='skipping git-cvsserver tests, perl not available' diff --git a/t/t9402-git-cvsserver-refs.sh b/t/t9402-git-cvsserver-refs.sh index 2ee41f9443eefb..65f2ceedecb2e7 100755 --- a/t/t9402-git-cvsserver-refs.sh +++ b/t/t9402-git-cvsserver-refs.sh @@ -68,12 +68,12 @@ check_diff() { ######### -cvs >/dev/null 2>&1 -if test $? -ne 1 +if ! cvs version >/dev/null 2>&1 then skip_all='skipping git-cvsserver tests, cvs not found' test_done fi + if ! test_have_prereq PERL then skip_all='skipping git-cvsserver tests, perl not available' diff --git a/t/test-lib-functions.sh b/t/test-lib-functions.sh index 5fd5494ef1030c..879ee1ee597715 100644 --- a/t/test-lib-functions.sh +++ b/t/test-lib-functions.sh @@ -1248,8 +1248,7 @@ test_might_fail () { test_expect_code () { want_code=$1 shift - "$@" 2>&7 - exit_code=$? + exit_code=0; "$@" 2>&7 || exit_code=$? if test $exit_code = $want_code then return 0 diff --git a/t/test-lib.sh b/t/test-lib.sh index 70fd3e9bafb800..de7d9e7b925049 100644 --- a/t/test-lib.sh +++ b/t/test-lib.sh @@ -143,8 +143,8 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME ################################################################ # It appears that people try to run tests without building... GIT_BINARY="${GIT_TEST_INSTALLED:-$GIT_BUILD_DIR}/git$X" -"$GIT_BINARY" >/dev/null -if test $? != 1 + +if ! "$GIT_BINARY" version >/dev/null then if test -n "$GIT_TEST_INSTALLED" then @@ -454,8 +454,10 @@ then # from any previous runs. >"$GIT_TEST_TEE_OUTPUT_FILE" - (GIT_TEST_TEE_STARTED=done ${TEST_SHELL_PATH} "$0" "$@" 2>&1; - echo $? >"$TEST_RESULTS_BASE.exit") | tee -a "$GIT_TEST_TEE_OUTPUT_FILE" + ( + ret=0 && GIT_TEST_TEE_STARTED=done ${TEST_SHELL_PATH} "$0" "$@" 2>&1 || ret=$? + echo "$ret" >"$TEST_RESULTS_BASE.exit" + ) | tee -a "$GIT_TEST_TEE_OUTPUT_FILE" test "$(cat "$TEST_RESULTS_BASE.exit")" = 0 exit fi From c1e29bcfa88fb8997379c3c7c888961b728e581d Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:20 +0200 Subject: [PATCH 079/608] t: prepare `test_when_finished ()`/`test_atexit()` for `set -e` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `test_when_finished ()` and `test_atexit ()` build up a chain of cleanup commands by prepending each new command to the existing cleanup string. To preserve the exit code of the test body across cleanup execution, we append the following logic: } && (exit "$eval_ret"); eval_ret=$?; ... The intent of this is to run the cleanup block and then unconditionally restore `eval_ret`. The original behaviour of this is is: +------------------+---------+------------------------------------+ |test body │ cleanup │ old behaviour │ +------------------+---------+------------------------------------+ │pass (eval_ret=0) | pass │ && taken -> (exit 0) -> eval_ret=0 | +------------------+---------+------------------------------------+ │pass (eval_ret=0) | fail │ && not taken -> eval_ret=$? | +------------------+---------+------------------------------------+ │fail (eval_ret=1) | pass │ && taken -> (exit 1) -> eval_ret=1 | +------------------+---------+------------------------------------+ │fail (eval_ret=1) | fail | && not taken -> eval_ret=$? | +------------------+---------+------------------------------------+ This logic will start to fail once we enable `set -e`. When `$eval_ret` is non-zero, the subshell we create will fail, and with `set -e` we'll thus bail out without evaluating the logic after the semicolon. Fix this issue by instead using `|| eval_ret=\$?; ...`. Besides being a bit simpler, it also retains the original behaviour: +------------------+---------+------------------------------------+ |test body │ cleanup │ old behaviour │ +------------------+---------+------------------------------------+ │pass (eval_ret=0) | pass │ || not taken -> eval_ret unchanged | +------------------+---------+------------------------------------+ │pass (eval_ret=0) | fail │ || taken -> eval_ret=$? | +------------------+---------+------------------------------------+ │fail (eval_ret=1) | pass │ || not taken -> eval_ret unchanged | +------------------+---------+------------------------------------+ │fail (eval_ret=1) | fail | || taken -> eval_ret=$? | +------------------+---------+------------------------------------+ Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/test-lib-functions.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/t/test-lib-functions.sh b/t/test-lib-functions.sh index 879ee1ee597715..502bb0ddcbca31 100644 --- a/t/test-lib-functions.sh +++ b/t/test-lib-functions.sh @@ -1512,7 +1512,7 @@ test_when_finished () { test "${BASH_SUBSHELL-0}" = 0 || BUG "test_when_finished does nothing in a subshell" test_cleanup="{ $* - } && (exit \"\$eval_ret\"); eval_ret=\$?; $test_cleanup" + } || eval_ret=\$?; $test_cleanup" } # This function can be used to schedule some commands to be run @@ -1540,7 +1540,7 @@ test_atexit () { test "${BASH_SUBSHELL-0}" = 0 || BUG "test_atexit does nothing in a subshell" test_atexit_cleanup="{ $* - } && (exit \"\$eval_ret\"); eval_ret=\$?; $test_atexit_cleanup" + } || eval_ret=\$?; $test_atexit_cleanup" } # Deprecated wrapper for "git init", use "git init" directly instead From b94900a0cf20e213038102a4cbf072dec5b1bb18 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:21 +0200 Subject: [PATCH 080/608] t0008: silence error in subshell when using `grep -v` In t0008 we use `grep -v` in a subshell, but expect that this command will sometimes not match anything. This would cause grep(1) to return an error code, but given that we don't run with `set -e` we swallow this error. We're about to enable `set -e`. Prepare for this by ignoring any errors. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/t0008-ignores.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/t/t0008-ignores.sh b/t/t0008-ignores.sh index e716b5cdfa1bee..d77a179bddcbed 100755 --- a/t/t0008-ignores.sh +++ b/t/t0008-ignores.sh @@ -122,8 +122,8 @@ test_expect_success_multiple () { fi testname="$1" expect_all="$2" code="$3" - expect_verbose=$( echo "$expect_all" | grep -v '^:: ' ) - expect=$( echo "$expect_verbose" | sed -e 's/.* //' ) + expect_verbose=$(echo "$expect_all" | grep -v '^:: ' || :) + expect=$(echo "$expect_verbose" | sed -e 's/.* //') test_expect_success $prereq "$testname${no_index_opt:+ with $no_index_opt}" ' expect "$expect" && From 4f917bbf718047dc1b3c4346a9c47c84a52a810b Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:22 +0200 Subject: [PATCH 081/608] t1301: don't fail in case setfacl(1) doesn't exist or fails In t1301 we're trying to remove any potentially-existing default ACLs that might exist on the transh directory by executing setfacl(1). According to 8ed0a740dd (t1301-shared-repo.sh: don't let a default ACL interfere with the test, 2008-10-16), this is done because we play around with permissions and umasks in this test suite. The setfacl(1) binary may not exist on some systems though, even though tests ultimately still pass. This doesn't matter currently, but will cause the test to fail once we start running with `set -e`. Silence such failures by ignoring failures here. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/t1301-shared-repo.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/t1301-shared-repo.sh b/t/t1301-shared-repo.sh index 630a47af21e655..0e0d07a1a1e7d8 100755 --- a/t/t1301-shared-repo.sh +++ b/t/t1301-shared-repo.sh @@ -12,7 +12,7 @@ TEST_CREATE_REPO_NO_TEMPLATE=1 . ./test-lib.sh # Remove a default ACL from the test dir if possible. -setfacl -k . 2>/dev/null +setfacl -k . 2>/dev/null || : # User must have read permissions to the repo -> failure on --shared=0400 test_expect_success 'shared = 0400 (faulty permission u-w)' ' From 090af9957c14915461f302f422d2c147b3e9175b Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:23 +0200 Subject: [PATCH 082/608] t6002: fix use of `expr` with `set -e` In `test_bisection_diff ()` we use `expr` to perform some math. This command has some gotchas though in that it will only return success when the result is neither null nor zero. In some of our cases though it actually _is_ zero, and that will cause the expressions to fail once we enable `set -e`. Prepare for this change by instead using `$(( ))`, which doesn't have the same issue. While at it, modernize the function a tiny bit. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/t6002-rev-list-bisect.sh | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/t/t6002-rev-list-bisect.sh b/t/t6002-rev-list-bisect.sh index daa009c9a1b4b6..f2de40b5ed8f14 100755 --- a/t/t6002-rev-list-bisect.sh +++ b/t/t6002-rev-list-bisect.sh @@ -27,13 +27,16 @@ test_bisection_diff() # Test if bisection size is close to half of list size within # tolerance. # - _bisect_err=$(expr $_list_size - $_bisection_size \* 2) - test "$_bisect_err" -lt 0 && _bisect_err=$(expr 0 - $_bisect_err) - _bisect_err=$(expr $_bisect_err / 2) ; # floor - - test_expect_success \ - "bisection diff $_bisect_option $_head $* <= $_max_diff" \ - 'test $_bisect_err -le $_max_diff' + _bisect_err=$(($_list_size - $_bisection_size * 2)) + if test "$_bisect_err" -lt 0 + then + _bisect_err=$((0 - $_bisect_err)) + fi + _bisect_err=$(($_bisect_err / 2)) ; # floor + + test_expect_success "bisection diff $_bisect_option $_head $* <= $_max_diff" ' + test $_bisect_err -le $_max_diff + ' } date >path0 From 1ecf6538263cf81c151f2e720cd73fde3d50a07e Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:24 +0200 Subject: [PATCH 083/608] t9902: fix use of `read` with `set -e` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In t9902 we're using the `read` builtin to read some values into a variable. This is done by using `-d ""`, which cause us to read until the end of the heredoc. As the read is terminated by EOF, the command will end up returning a non-zero error code. This hasn't been an issue until now as we didn't run with `set -e`, but that'll change in a subsequent commit. Prepare for this change by not using read at all, as we can simply store the multi-line value directly. Suggested-by: SZEDER Gábor Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- t/t9902-completion.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/t/t9902-completion.sh b/t/t9902-completion.sh index 2f9a597ec7f493..28f61f08fb4cec 100755 --- a/t/t9902-completion.sh +++ b/t/t9902-completion.sh @@ -590,12 +590,10 @@ test_expect_success '__gitcomp - doesnt fail because of invalid variable name' ' __gitcomp "$invalid_variable_name" ' -read -r -d "" refs <<-\EOF -main +refs='main maint next -seen -EOF +seen' test_expect_success '__gitcomp_nl - trailing space' ' test_gitcomp_nl "m" "$refs" <<-EOF From ffe8005b9d8e0cf1b5d5d796ca4da76fbe219011 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Tue, 21 Apr 2026 09:34:25 +0200 Subject: [PATCH 084/608] t: detect errors outside of test cases We have recently merged a patch series that had a simple misspelling of `test_expect_success`. Instead of making our tests fail though, this typo went completely undetected and all of our tests passed, which is of course unfortunate. This is a more general issue with our test suite: all commands that run outside of a specific test case can fail, and if we don't explicitly check for such failure then this failure will be silently ignored. Improve the status quo by enabling the errexit option so that any such unchecked failures will cause us to abort immediately. Note that for now, we only enable this option for Bash 5 and newer. This is because other shells have wildly different behaviour, and older versions of Bash (especially on macOS) are buggy. The list of enabled shells may be extended going forward. Helped-by: Jeff King Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- ci/run-build-and-tests.sh | 6 ++++++ t/test-lib.sh | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/ci/run-build-and-tests.sh b/ci/run-build-and-tests.sh index 28cfe730ee5aed..de08a08d59fc8f 100755 --- a/ci/run-build-and-tests.sh +++ b/ci/run-build-and-tests.sh @@ -7,6 +7,12 @@ export TEST_CONTRIB_TOO=yes +case "$jobname" in +almalinux-*|debian-*|fedora-*|linux-*) + export GIT_TEST_USE_SET_E=yes + ;; +esac + case "$jobname" in fedora-breaking-changes-musl|linux-breaking-changes) export WITH_BREAKING_CHANGES=YesPlease diff --git a/t/test-lib.sh b/t/test-lib.sh index de7d9e7b925049..cded7bd693e6f6 100644 --- a/t/test-lib.sh +++ b/t/test-lib.sh @@ -15,6 +15,31 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see https://www.gnu.org/licenses/ . +# Enable the use of errexit so that any unexpected failures will cause us to +# abort tests, even when outside of a specific test case. +# +# Note that we only enable this on Bash 5 and newer, or when explicitly +# requested by the user via `GIT_TEST_USE_SET_E=true`. This ib secause `set -e` +# has wildly different behaviour across shells. The list of default-enabled +# shells may be extended going forward. +if test -z "$GIT_TEST_USE_SET_E" && test "${BASH_VERSINFO:=0}" -ge 5 +then + GIT_TEST_USE_SET_E=true +fi + +# We cannot use `test-tool env-helper` here, as it's not yet available. +case "${GIT_TEST_USE_SET_E:-false}" in +1|on|true|yes) + set -e + ;; +0|off|false|no) + ;; +*) + echo "GIT_TEST_USE_SET_E requires a boolean" >&2 + exit 1 + ;; +esac + # Test the binaries we have just built. The tests are kept in # t/ subdirectory and are run in 'trash directory' subdirectory. if test -z "$TEST_DIRECTORY" From 7584d10bc289011cf005d453591a4c009d8b6508 Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Wed, 22 Apr 2026 23:45:17 +0200 Subject: [PATCH 085/608] Fix docs for format.commitListFormat When renaming the option --cover-letter-format to --commit-list-format we forgot to rename the opton in the section too. Fix it. Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- Documentation/config/format.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/config/format.adoc b/Documentation/config/format.adoc index dbd186290b501f..95d19134b26ea0 100644 --- a/Documentation/config/format.adoc +++ b/Documentation/config/format.adoc @@ -102,7 +102,7 @@ format.coverLetter:: Default is false. format.commitListFormat:: - When the `--cover-letter-format` option is not given, `format-patch` + When the `--commit-list-format` option is not given, `format-patch` uses the value of this variable to decide how to format the entry of each commit. Defaults to `shortlog`. From b2eec6663f10a53dead5e7a4e5e9b91220bf9473 Mon Sep 17 00:00:00 2001 From: Elijah Newren Date: Mon, 20 Apr 2026 22:30:14 +0000 Subject: [PATCH 086/608] merge-ort: handle cached rename & trivial resolution interaction better Back in commit a562d90a350d (merge-ort: fix failing merges in special corner case, 2025-11-03), we hit a rename assertion due to a trivial directory resolution affecting the parent of a cached rename. Since the path didn't need to be considered, we side-stepped it with if (!newinfo) continue; in process_renames(). We have since run into a case in production where a trivial resolution of a file affects the direct target of a cached rename rather than a parent directory of it. Add a testcase demonstrating this additional case. Now, if we were to follow the lead of commit a562d90a350d, we could resolve this alternate case with an extra condition on the above if: if (!newinfo || newinfo->merged.clean) continue; However, if we had done that earlier, we would have made 979ee83e8a90 (merge-ort: fix corner case recursive submodule/directory conflict handling, 2025-12-29) harder to find and fix, and this particular position for this condition isn't actually at the root of the issue but downstream from it. Instead, let's rip out this if-check from a562d90a350d and put in an alternative that more directly addresses trivially resolved paths that happen to be cached renames or parent directories thereof, which is a better fix for the original testcase and which also solves the newly added testcase as well. Signed-off-by: Elijah Newren Signed-off-by: Junio C Hamano --- merge-ort.c | 48 +++++++++---------- t/t6429-merge-sequence-rename-caching.sh | 60 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/merge-ort.c b/merge-ort.c index 00923ce3cd749b..544be9e466c9b5 100644 --- a/merge-ort.c +++ b/merge-ort.c @@ -2953,32 +2953,6 @@ static int process_renames(struct merge_options *opt, if (!oldinfo || oldinfo->merged.clean) continue; - /* - * Rename caching from a previous commit might give us an - * irrelevant rename for the current commit. - * - * Imagine: - * foo/A -> bar/A - * was a cached rename for the upstream side from the - * previous commit (without the directories being renamed), - * but the next commit being replayed - * * does NOT add or delete files - * * does NOT have directory renames - * * does NOT modify any files under bar/ - * * does NOT modify foo/A - * * DOES modify other files under foo/ (otherwise the - * !oldinfo check above would have already exited for - * us) - * In such a case, our trivial directory resolution will - * have already merged bar/, and our attempt to process - * the cached - * foo/A -> bar/A - * would be counterproductive, and lack the necessary - * information anyway. Skip such renames. - */ - if (!newinfo) - continue; - /* * diff_filepairs have copies of pathnames, thus we have to * use standard 'strcmp()' (negated) instead of '=='. @@ -3329,6 +3303,28 @@ static void use_cached_pairs(struct merge_options *opt, if (!new_name) new_name = old_name; + /* + * If this is a rename and the target path is either + * absent from opt->priv->paths (because a parent + * directory was trivially resolved) or already cleanly + * resolved (e.g. all three sides agree on its content), + * the cached rename is irrelevant for this commit. + * Skip it here rather than in process_renames() to + * preserve VERIFY_CI(newinfo)'s ability to catch bugs + * for non-cached renames (see 979ee83e8a90 (merge-ort: + * fix corner case recursive submodule/directory conflict + * handling, 2025-12-29) for an example of a bug that + * assertion caught). The rename remains in cached_pairs + * for use in subsequent commits. + */ + if (entry->value) { + struct merged_info *mi; + + mi = strmap_get(&opt->priv->paths, new_name); + if (!mi || mi->clean) + continue; + } + /* * cached_pairs has *copies* of old_name and new_name, * because it has to persist across merges. Since diff --git a/t/t6429-merge-sequence-rename-caching.sh b/t/t6429-merge-sequence-rename-caching.sh index 15dd2d94b75f0a..56ee9689898276 100755 --- a/t/t6429-merge-sequence-rename-caching.sh +++ b/t/t6429-merge-sequence-rename-caching.sh @@ -846,4 +846,64 @@ test_expect_success 'rename a file, use it on first pick, but irrelevant on seco ) ' +# +# In the following testcase: +# Base: subdir/file_1 +# Upstream: file_1 (renamed from subdir/file) +# Topic_1: subdir/file_2 (modified subdir/file) +# Topic_2: subdir/file_2, file_2 (added another "file" with same contents) +# Topic_3: file_2 (deleted subdir/file) +# +# +# This testcase presents no problems for git traditionally, but the fact that +# subdir/file -> file +# gets cached after the first pick presents a problem for the third commit +# to be replayed, because file has contents file_2 on all three sides and +# is thus trivially resolved early. The point of renames is to allow us to +# three-way merge contents across multiple filenames, but if the target is +# already resolved, we risk throwing an assertion. Verify that the code +# correctly drops the irrelevant rename in order to avoid hitting that +# assertion. +# +test_expect_success 'cached rename does not assert on trivially clean target' ' + git init cached-rename-trivially-clean-target && + ( + cd cached-rename-trivially-clean-target && + + mkdir subdir && + printf "%s\n" 1 2 3 >subdir/file && + git add subdir/file && + git commit -m orig && + + git branch upstream && + git branch topic && + + git switch upstream && + git mv subdir/file file && + git commit -m "rename subdir/file to file" && + + git switch topic && + + echo 4 >>subdir/file && + git add subdir/file && + git commit -m "modify subdir/file" && + + cp subdir/file file && + git add file && + git commit -m "copy subdir/file to file" && + + git rm subdir/file && + git commit -m "delete subdir/file" && + + git switch upstream && + git replay --onto HEAD upstream..topic && + git checkout topic && + + git ls-files >tracked-files && + test_line_count = 1 tracked-files && + printf "%s\n" 1 2 3 4 >expect && + test_cmp expect file + ) +' + test_done From 890229b3f3e635ff4dd9e9e7a3d95a4ac6e5e173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?SZEDER=20G=C3=A1bor?= Date: Tue, 21 Apr 2026 21:21:32 +0200 Subject: [PATCH 087/608] t6112: avoid tilde expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e987df5fe6 (list-objects-filter: implement composite filters, 2019-06-27) introduced a test to "t6112-rev-list-filters-objects.sh" that checks the output of a Git command with the following commands: grep ~$omitted_1 actual && grep ~$omitted_2 actual && grep ~$omitted_3 actual && Since the leading tilde in the pattern is not quoted/escaped, it is subject to tilde expansion. So if the system has a user whose username happens to be "$omitted_1", then "grep" would look for that user's home directory. Quote those words starting with a tilde to avoid this. Signed-off-by: SZEDER Gábor Signed-off-by: Junio C Hamano --- t/t6112-rev-list-filters-objects.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/t/t6112-rev-list-filters-objects.sh b/t/t6112-rev-list-filters-objects.sh index 0387f35a326d74..668e56bfccd07c 100755 --- a/t/t6112-rev-list-filters-objects.sh +++ b/t/t6112-rev-list-filters-objects.sh @@ -627,9 +627,9 @@ test_expect_success 'verify collecting omits in combined: filter' ' omitted_2=$(echo a | git hash-object --stdin) && omitted_3=$(echo abcde | git hash-object --stdin) && - grep ~$omitted_1 actual && - grep ~$omitted_2 actual && - grep ~$omitted_3 actual && + grep "~$omitted_1" actual && + grep "~$omitted_2" actual && + grep "~$omitted_3" actual && test_line_count = 3 actual ' From 8b44deebaf02246b40c267c06bf0c74ef71df292 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Wed, 22 Apr 2026 19:00:20 -0400 Subject: [PATCH 088/608] Revert "transport-helper, connect: use clean_on_exit to reap children on abnormal exit" This reverts commit dd3693eb0859274d62feac8047e1d486b3beaf31. The goal of that commit was to avoid zombie child processes hanging around when the parent git process is killed. But it doesn't quite work when the child command is run by the shell: 1. If there is a shell, then we kill and wait for the shell, not the process spawned by the shell. And so the child process, even if it eventually exits, will hang around as a zombie forever. And this is true of most (all?) shells: bash, dash, etc. So we are not really accomplishing our goal in the first place. 2. Not all shells will exit immediately upon receiving a signal. In particular, mksh will wait for its children to exit (but not actually propagate the signal to them!) leaving us with a potential deadlock: git is wait()ing on mksh, which is wait()ing on a child process, but that child process is waiting on git to produce more input (or EOF) over a pipe. You can see several examples of this deadlock in the test suite, for example by running: make SHELL_PATH=/bin/mksh cd t ./t5702-protocol-v2.sh Because this is a regression for mksh users, and because we did not achieve our goal even with other shells, let's revert the commit for now. If there is a more clever way of doing the same thing, we can consider applying it separately on top (or do nothing and just accept the zombies and rely on PID 1 to reap them). Reported-by: Jan Palus Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- connect.c | 4 ---- transport-helper.c | 2 -- 2 files changed, 6 deletions(-) diff --git a/connect.c b/connect.c index fcd35c5539a76e..a02583a1027241 100644 --- a/connect.c +++ b/connect.c @@ -1054,8 +1054,6 @@ static struct child_process *git_proxy_connect(int fd[2], char *host) strvec_push(&proxy->args, port); proxy->in = -1; proxy->out = -1; - proxy->clean_on_exit = 1; - proxy->wait_after_clean = 1; if (start_command(proxy)) die(_("cannot start proxy %s"), git_proxy_command); fd[0] = proxy->out; /* read from proxy stdout */ @@ -1517,8 +1515,6 @@ struct child_process *git_connect(int fd[2], const char *url, } strvec_push(&conn->args, cmd.buf); - conn->clean_on_exit = 1; - conn->wait_after_clean = 1; if (start_command(conn)) die(_("unable to fork")); diff --git a/transport-helper.c b/transport-helper.c index 570d7c6439569a..4d95d84f9e4d05 100644 --- a/transport-helper.c +++ b/transport-helper.c @@ -154,8 +154,6 @@ static struct child_process *get_helper(struct transport *transport) helper->trace2_child_class = helper->args.v[0]; /* "remote-" */ - helper->clean_on_exit = 1; - helper->wait_after_clean = 1; code = start_command(helper); if (code < 0 && errno == ENOENT) die(_("unable to find remote helper for '%s'"), data->name); From 21186cf9bbe5f1d8a039286027ebb4f33c7f85d1 Mon Sep 17 00:00:00 2001 From: Jonatan Holmgren Date: Fri, 24 Apr 2026 18:17:00 +0200 Subject: [PATCH 089/608] alias: restore support for simple dotted aliases Historically, config entries like alias.foo.bar expanded the alias "foo.bar". The subsection-based alias syntax introduced in ac1f12a9de (alias: support non-alphanumeric names via subsection syntax, 2026-02-18) broke that behavior by treating such entries as if they were subsection syntax. Restore support for the old dotted form by falling back to the full name when the final key is not "command". Add tests covering execution and help output for simple dotted aliases. Reported-by: Michael Grossfeld Helped-by: Jeff King Signed-off-by: Jonatan Holmgren Signed-off-by: Junio C Hamano --- alias.c | 16 ++++++++++++++-- help.c | 9 ++++++++- t/t0014-alias.sh | 12 ++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/alias.c b/alias.c index ec9833dd30f3cd..e737c49eddf35f 100644 --- a/alias.c +++ b/alias.c @@ -34,8 +34,20 @@ static int config_alias_cb(const char *var, const char *value, if (subsection && !subsection_len) subsection = NULL; - if (subsection && strcmp(key, "command")) - return 0; + if (subsection && strcmp(key, "command")) { + /* + * We have historically supported the "alias.name" form when + * "name" happens to contain dots (e.g., alias.foo.bar to allow + * "git foo.bar". But our parsing above would split that into + * subsection "foo". + * + * If we do not understand the final key in a subsection-style + * variable, fall back to treating it as a two-level alias. + */ + key = var + strlen("alias."); + subsection = NULL; + subsection_len = 0; + } if (data->alias) { int match; diff --git a/help.c b/help.c index 725e92a1958eeb..79150bf7a2d121 100644 --- a/help.c +++ b/help.c @@ -593,14 +593,21 @@ static int git_unknown_cmd_config(const char *var, const char *value, /* Also use aliases for command lookup */ if (!parse_config_key(var, "alias", &subsection, &subsection_len, &key)) { + size_t key_len = strlen(key); + if (subsection) { /* [alias "name"] command = value */ if (!strcmp(key, "command")) add_cmdname(&cfg->aliases, subsection, subsection_len); + else { + key = var + strlen("alias."); + key_len = strlen(key); + add_cmdname(&cfg->aliases, key, key_len); + } } else { /* alias.name = value */ - add_cmdname(&cfg->aliases, key, strlen(key)); + add_cmdname(&cfg->aliases, key, key_len); } } diff --git a/t/t0014-alias.sh b/t/t0014-alias.sh index 68b4903cbfa595..5144b0effd78aa 100755 --- a/t/t0014-alias.sh +++ b/t/t0014-alias.sh @@ -128,6 +128,12 @@ test_expect_success 'subsection syntax works' ' test_grep "ran-subsection" output ' +test_expect_success 'simple dotted alias syntax still works' ' + test_config alias.simple.dotted "!echo ran-simple-dotted" && + git simple.dotted >output && + test_grep "ran-simple-dotted" output +' + test_expect_success 'subsection syntax only accepts command key' ' test_config alias.invalid.notcommand value && test_must_fail git invalid 2>error && @@ -183,6 +189,12 @@ test_expect_success 'subsection aliases listed in help -a' ' test_grep "förgrena" output ' +test_expect_success 'simple dotted aliases listed in help -a' ' + test_config alias.simple.listed "!echo test" && + git help -a >output && + test_grep "simple.listed" output +' + test_expect_success 'empty subsection treated as no subsection' ' test_config "alias..something" "!echo foobar" && git something >actual && From 9ff4b5ab1b3b66d454a6c09e92d608c9be15a7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Scharfe?= Date: Fri, 24 Apr 2026 23:04:27 +0200 Subject: [PATCH 090/608] grep: fix --column --only-match for 2nd and later matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "git grep --column --only-match" shows the 1-based column number of the first match on each line, but confusing numbers for further matches. Example: $ echo 123456789012345678901234567890 >file $ for d in 1 2 3 4 5 6 7 8 9 0 do git grep --no-index --column --only-matching $d file | awk -v FS=: -v l=$d: '{l = l sprintf("%3s", $2)} END {print l}' done 1: 1 2 12 2: 2 4 14 3: 3 6 16 4: 4 8 18 5: 5 10 20 6: 6 12 22 7: 7 14 24 8: 8 16 26 9: 9 18 28 0: 10 20 30 Report the column number of each match instead: $ for d in 1 2 3 4 5 6 7 8 9 0 do ./git grep --no-index --column --only-matching $d file | awk -v FS=: -v l=$d: '{l = l sprintf("%3s", $2)} END {print l}' done 1: 1 11 21 2: 2 12 22 3: 3 13 23 4: 4 14 24 5: 5 15 25 6: 6 16 26 7: 7 17 27 8: 8 18 28 9: 9 19 29 0: 10 20 30 We need to adjust the test in t7810 as well. The file it uses has the following five lines; I add a line highlighting the matches and a ruler at the bottom here, to make it easier to see that the second "mmap" indeed starts at column 14: foo mmap bar foo_mmap bar foo_mmap bar mmap foo mmap bar_mmap foo_mmap bar mmap baz ==== ==== 123456789 123456789 1 Reported-by: Brandon Chinn Signed-off-by: René Scharfe Signed-off-by: Junio C Hamano --- grep.c | 3 ++- t/t7810-grep.sh | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/grep.c b/grep.c index c7e1dc1e0ee4fe..a54e5d86a96cfd 100644 --- a/grep.c +++ b/grep.c @@ -1267,6 +1267,7 @@ static void show_line(struct grep_opt *opt, regmatch_t match; enum grep_context ctx = GREP_CONTEXT_BODY; int eflags = 0; + const char *start = bol; if (want_color(opt->color)) { if (sign == ':') @@ -1285,6 +1286,7 @@ static void show_line(struct grep_opt *opt, if (match.rm_so == match.rm_eo) break; + cno = bol - start + match.rm_so + 1; if (opt->only_matching) show_line_header(opt, name, lno, cno, sign); else @@ -1294,7 +1296,6 @@ static void show_line(struct grep_opt *opt, if (opt->only_matching) opt->output(opt, "\n", 1); bol += match.rm_eo; - cno += match.rm_eo; rest -= match.rm_eo; eflags = REG_NOTBOL; } diff --git a/t/t7810-grep.sh b/t/t7810-grep.sh index 64ac4f04ee97ad..bd439563d6f149 100755 --- a/t/t7810-grep.sh +++ b/t/t7810-grep.sh @@ -322,11 +322,11 @@ do ${HC}file:1:5:mmap ${HC}file:2:5:mmap ${HC}file:3:5:mmap - ${HC}file:3:13:mmap + ${HC}file:3:14:mmap ${HC}file:4:5:mmap - ${HC}file:4:13:mmap + ${HC}file:4:14:mmap ${HC}file:5:5:mmap - ${HC}file:5:13:mmap + ${HC}file:5:14:mmap EOF git grep --column -n -o -e mmap $H >actual && test_cmp expected actual From 8e61175e81052a627e8ed6db1fb12e71a9b75e3b Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 26 Apr 2026 14:38:29 +0000 Subject: [PATCH 091/608] test-lib: allow bare repository access when breaking changes are enabled A future patch will change the `safe.bareRepository` default from `all` to `explicit` under `WITH_BREAKING_CHANGES`. At that point, every test that operates on a bare repository through implicit discovery would fail, regardless of whether the test is actually about discovery or about how a specific command behaves once inside a bare repository. The maintainer suggested [1] setting `safe.bareRepository=all` in the test environment's global config whenever `WITH_BREAKING_CHANGES` is in effect, rather than adjusting each affected test to access bare repositories explicitly (via `--git-dir`, `GIT_DIR`, or similar). This means the test suite continues to exercise only the historical default behavior even after the user-facing default changes, relying on a small number of dedicated tests in t0035 to validate the new, stricter default. Since `$HOME` points at the trash directory (which doubles as the test repository's working tree), writing to `$HOME/.gitconfig` also creates a file inside the working tree. Exclude it via `.git/info/exclude` to limit the fallout, though this does not help tests that use `git ls-files --others` without `--exclude-standard` or `git status --ignored`; those are addressed by subsequent commits. The `.git/info/exclude` write is guarded by `test -d .git/info` rather than using `mkdir -p`, because some tests (e.g. t0008) expect to create `.git/info/` themselves and would fail with Patrick Steinhardt's `set -e` preparation (ps/test-set-e-clean) if the directory already existed. For tests using `TEST_NO_CREATE_REPO` (where no `.git/` exists at all), the guard also handles that case. [1] https://lore.kernel.org/git/xmqqse98cc51.fsf@gitster.g/ Original-patch-by: Junio C Hamano Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- t/test-lib.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/t/test-lib.sh b/t/test-lib.sh index 70fd3e9bafb800..72ed87b7810b97 100644 --- a/t/test-lib.sh +++ b/t/test-lib.sh @@ -1597,6 +1597,19 @@ cd -P "$TRASH_DIRECTORY" || BAIL_OUT "cannot cd -P to \"$TRASH_DIRECTORY\"" TRASH_DIRECTORY=$(pwd) HOME="$TRASH_DIRECTORY" +if test -n "$WITH_BREAKING_CHANGES" +then + git config --global safe.bareRepository all && + # Only write to .git/info/exclude when the directory exists + # (i.e. when git init created the repo). If we mkdir -p it + # ourselves, tests that expect to create .git/info/ themselves + # (e.g. t0008) would fail. + if test -d .git/info + then + echo "/.gitconfig" >>.git/info/exclude + fi +fi + start_test_output "$0" # Convenience From a27fec3e7eb13a0941e75cd16a31e424c276b334 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 26 Apr 2026 14:38:30 +0000 Subject: [PATCH 092/608] t7900: do not let `$HOME/.gitconfig` interfere with XDG tests The XDG config tests for `git maintenance register/unregister` create a fresh `$XDG_CONFIG_HOME/git/config` and expect git to use that location. However, if `$HOME/.gitconfig` exists (which may happen when test-lib.sh writes global config, e.g. to set `safe.bareRepository`), git prefers `$HOME/.gitconfig` over the XDG location, and the `maintenance.repo` entry ends up in the wrong file. This is an inherent consequence of setting global config in test-lib.sh rather than adjusting individual tests: writing any entry to `$HOME/.gitconfig` has side effects beyond the intended setting, because the mere existence of that file changes which global config location git prefers for all subsequent writes. Individual per-test adjustments would not have this interaction. Fix this by overriding `HOME` to a non-existent directory inside the subshells that test XDG behavior. Since these subshells already override `XDG_CONFIG_HOME`, they do not need `$HOME/.gitconfig` at all, and the subshell scoping ensures the original `HOME` is restored automatically. Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- t/t7900-maintenance.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/t/t7900-maintenance.sh b/t/t7900-maintenance.sh index 4700beacc18281..4358df04245a9a 100755 --- a/t/t7900-maintenance.sh +++ b/t/t7900-maintenance.sh @@ -101,8 +101,12 @@ test_expect_success "maintenance.autoDetach overrides gc.autoDetach" ' test_expect_success 'register uses XDG_CONFIG_HOME config if it exists' ' test_when_finished rm -r .config/git/config && ( + # Override HOME so that .gitconfig (which test-lib.sh may + # have created, e.g. to set safe.bareRepository) does not + # take precedence over the XDG location. + HOME=$PWD/must-not-exist && XDG_CONFIG_HOME=.config && - export XDG_CONFIG_HOME && + export HOME XDG_CONFIG_HOME && mkdir -p $XDG_CONFIG_HOME/git && >$XDG_CONFIG_HOME/git/config && git maintenance register && @@ -124,8 +128,12 @@ test_expect_success 'register does not need XDG_CONFIG_HOME config to exist' ' test_expect_success 'unregister uses XDG_CONFIG_HOME config if it exists' ' test_when_finished rm -r .config/git/config && ( + # Override HOME so that .gitconfig (which test-lib.sh may + # have created, e.g. to set safe.bareRepository) does not + # take precedence over the XDG location. + HOME=$PWD/must-not-exist && XDG_CONFIG_HOME=.config && - export XDG_CONFIG_HOME && + export HOME XDG_CONFIG_HOME && mkdir -p $XDG_CONFIG_HOME/git && >$XDG_CONFIG_HOME/git/config && git maintenance register && From 5fe676f44823dce11011b9b3b2214824a876eba4 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 26 Apr 2026 14:38:31 +0000 Subject: [PATCH 093/608] t1300: remove global config settings injected by test-lib.sh Since test-lib.sh now writes `safe.bareRepository=all` to the global config when `WITH_BREAKING_CHANGES` is in effect, that entry shows up in `git config --list` output. Tests in t1300 that expect exact config contents then fail because of this unexpected extra line. Unlike the working-tree contamination fixed in the preceding commits, this is not about the file's existence but about its content leaking into test expectations. Since t1300 does not use bare repositories, simply remove the injected setting in a preparatory step. Signed-off-by: Johannes Schindelin Assisted-by: Claude Opus 4.6 Signed-off-by: Junio C Hamano --- t/t1300-config.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/t/t1300-config.sh b/t/t1300-config.sh index 128971ee12fa6c..11fc976f3ab271 100755 --- a/t/t1300-config.sh +++ b/t/t1300-config.sh @@ -11,6 +11,13 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME . ./test-lib.sh . "$TEST_DIRECTORY"/lib-terminal.sh +# test-lib.sh may have added global config (e.g. safe.bareRepository) +# that would appear in "git config --list" output and break tests +# that expect exact config contents. +test_expect_success 'remove global config from test-lib.sh' ' + test_might_fail git config --global --unset-all safe.bareRepository +' + for mode in legacy subcommands do From 59227c0e27b527062440200c074f63ee40fb7bf4 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 26 Apr 2026 14:38:32 +0000 Subject: [PATCH 094/608] t1305: use `--git-dir=.` for bare repo in include cycle test Earlier tests in t1305 overwrite `$HOME/.gitconfig` with their own content as part of testing config includes. This clobbers the `safe.bareRepository=all` entry that test-lib.sh writes when `WITH_BREAKING_CHANGES` is in effect, causing `git -C cycle config` to fail with "not in a git directory" when it tries to access the bare repository created by `git init --bare cycle`. Use `--git-dir=.` to access the bare repo explicitly, avoiding the dependency on global config for repository discovery. Assisted-by: Claude Opus 4.6 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- t/t1305-config-include.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/t/t1305-config-include.sh b/t/t1305-config-include.sh index 6e51f892f320bb..f3892578e4ff86 100755 --- a/t/t1305-config-include.sh +++ b/t/t1305-config-include.sh @@ -350,9 +350,9 @@ test_expect_success 'conditional include, onbranch, implicit /** for /' ' test_expect_success 'include cycles are detected' ' git init --bare cycle && - git -C cycle config include.path cycle && + git -C cycle --git-dir=. config include.path cycle && git config -f cycle/cycle include.path config && - test_must_fail git -C cycle config --get-all test.value 2>stderr && + test_must_fail git -C cycle --git-dir=. config --get-all test.value 2>stderr && grep "exceeded maximum include depth" stderr ' From d97f27bf01a1ba2cfa66cc8e55f03e701084240e Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 26 Apr 2026 14:38:33 +0000 Subject: [PATCH 095/608] t5601: restore `.gitconfig` after includeIf test One test in t5601 overwrites `$HOME/.gitconfig` with an `includeIf` configuration snippet and removes the file in its cleanup. This destroys the `safe.bareRepository=all` entry that test-lib.sh writes when `WITH_BREAKING_CHANGES` is in effect, causing later tests that use `git -C config` to fail with "not in a git directory". Back up `.gitconfig` before overwriting and restore it in the cleanup, so the global config survives into subsequent tests. Assisted-by: Claude Opus 4.6 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- t/t5601-clone.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/t/t5601-clone.sh b/t/t5601-clone.sh index d743d986c401a0..3dd229c1867244 100755 --- a/t/t5601-clone.sh +++ b/t/t5601-clone.sh @@ -813,7 +813,9 @@ test_expect_success 'clone with includeIf' ' test_when_finished "rm -rf repo \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\"" && git clone --bare --no-local src "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" && - test_when_finished "rm \"$HOME\"/.gitconfig" && + test_when_finished "cp \"$HOME\"/.gitconfig.bak \ + \"$HOME\"/.gitconfig 2>/dev/null || rm -f \"$HOME\"/.gitconfig" && + cp "$HOME"/.gitconfig "$HOME"/.gitconfig.bak 2>/dev/null && cat >"$HOME"/.gitconfig <<-EOF && [includeIf "onbranch:something"] path = /does/not/exist.inc From 83228f16611eae979421915836c5416eb4d02cf3 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 26 Apr 2026 14:38:34 +0000 Subject: [PATCH 096/608] ls-files tests: filter `.gitconfig` from `--others` output The global `safe.bareRepository=all` setting in test-lib.sh is written to `$HOME/.gitconfig`, which unfortunately lives inside the test repository's working tree. The `.git/info/exclude` entry added alongside it handles most commands, but `git ls-files --others` without `--exclude-standard` does not consult `info/exclude` at all, so the file appears in the output. Ideally, each test that accesses a bare repository would simply specify `--git-dir` or `GIT_DIR` explicitly, which would require no global config and produce no side effects in the working tree. As that approach was not taken, filter `.gitconfig` from the output before comparing against expected results. In t7104, the test already uses `--exclude-standard`, so it suffices to switch from the bare `git ls-files -o` to `git ls-files -o --exclude-standard` which respects the `info/exclude` entry; the other tests deliberately omit `--exclude-standard` because their purpose is to verify unfiltered `--others` output. Assisted-by: Claude Opus 4.6 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- t/t3000-ls-files-others.sh | 4 ++++ t/t3001-ls-files-others-exclude.sh | 3 +++ t/t3002-ls-files-dashpath.sh | 2 ++ t/t3009-ls-files-others-nonsubmodule.sh | 1 + t/t3011-common-prefixes-and-directory-traversal.sh | 3 ++- t/t7104-reset-hard.sh | 2 +- t/test-lib-functions.sh | 8 ++++++++ 7 files changed, 21 insertions(+), 2 deletions(-) diff --git a/t/t3000-ls-files-others.sh b/t/t3000-ls-files-others.sh index b41e7f0daa480d..b4f0fbfc55a0fe 100755 --- a/t/t3000-ls-files-others.sh +++ b/t/t3000-ls-files-others.sh @@ -53,16 +53,19 @@ test_expect_success 'setup: expected output' ' test_expect_success 'ls-files --others' ' git ls-files --others >output && + test_filter_gitconfig output && test_cmp expected1 output ' test_expect_success 'ls-files --others --directory' ' git ls-files --others --directory >output && + test_filter_gitconfig output && test_cmp expected2 output ' test_expect_success '--no-empty-directory hides empty directory' ' git ls-files --others --directory --no-empty-directory >output && + test_filter_gitconfig output && test_cmp expected3 output ' @@ -70,6 +73,7 @@ test_expect_success 'ls-files --others handles non-submodule .git' ' mkdir not-a-submodule && echo foo >not-a-submodule/.git && git ls-files -o >output && + test_filter_gitconfig output && test_cmp expected1 output ' diff --git a/t/t3001-ls-files-others-exclude.sh b/t/t3001-ls-files-others-exclude.sh index 4b676462852ab8..202fb8d9eacc2c 100755 --- a/t/t3001-ls-files-others-exclude.sh +++ b/t/t3001-ls-files-others-exclude.sh @@ -72,6 +72,7 @@ test_expect_success 'git ls-files --others with various exclude options.' ' --exclude-per-directory=.gitignore \ --exclude-from=.git/ignore \ >output && + test_filter_gitconfig output && test_cmp expect output ' @@ -84,6 +85,7 @@ test_expect_success 'git ls-files --others with \r\n line endings.' ' --exclude-per-directory=.gitignore \ --exclude-from=.git/ignore \ >output && + test_filter_gitconfig output && test_cmp expect output ' @@ -99,6 +101,7 @@ test_expect_success 'git ls-files --others with various exclude options.' ' --exclude-per-directory=.gitignore \ --exclude-from=.git/ignore \ >output && + test_filter_gitconfig output && test_cmp expect output ' diff --git a/t/t3002-ls-files-dashpath.sh b/t/t3002-ls-files-dashpath.sh index 31462cb441ef2f..6acaadbd672cde 100755 --- a/t/t3002-ls-files-dashpath.sh +++ b/t/t3002-ls-files-dashpath.sh @@ -24,6 +24,7 @@ test_expect_success 'setup' ' test_expect_success 'git ls-files without path restriction.' ' test_when_finished "rm -f expect" && git ls-files --others >output && + test_filter_gitconfig output && cat >expect <<-\EOF && -- -foo @@ -63,6 +64,7 @@ test_expect_success 'git ls-files with path restriction with -- --.' ' test_expect_success 'git ls-files with no path restriction.' ' test_when_finished "rm -f expect" && git ls-files --others -- >output && + test_filter_gitconfig output && cat >expect <<-\EOF && -- -foo diff --git a/t/t3009-ls-files-others-nonsubmodule.sh b/t/t3009-ls-files-others-nonsubmodule.sh index 963f3462b750b2..dc990c277bcaa8 100755 --- a/t/t3009-ls-files-others-nonsubmodule.sh +++ b/t/t3009-ls-files-others-nonsubmodule.sh @@ -36,6 +36,7 @@ test_expect_success 'setup: directories' ' test_expect_success 'ls-files --others handles untracked git repositories' ' git ls-files -o >output && + test_filter_gitconfig output && cat >expect <<-EOF && nonrepo-untracked-file/untracked output diff --git a/t/t3011-common-prefixes-and-directory-traversal.sh b/t/t3011-common-prefixes-and-directory-traversal.sh index 3da5b2b6e795ec..455e97954d7721 100755 --- a/t/t3011-common-prefixes-and-directory-traversal.sh +++ b/t/t3011-common-prefixes-and-directory-traversal.sh @@ -26,7 +26,7 @@ test_expect_success 'setup' ' ' test_expect_success 'git ls-files -o shows the right entries' ' - cat <<-EOF >expect && + cat >expect <<-EOF && .gitignore actual an_ignored_dir/ignored @@ -39,6 +39,7 @@ test_expect_success 'git ls-files -o shows the right entries' ' untracked_repo/ EOF git ls-files -o >actual && + test_filter_gitconfig actual && test_cmp expect actual ' diff --git a/t/t7104-reset-hard.sh b/t/t7104-reset-hard.sh index 7948ec392b3599..c23d6e3f526c85 100755 --- a/t/t7104-reset-hard.sh +++ b/t/t7104-reset-hard.sh @@ -21,7 +21,7 @@ test_expect_success setup ' rm -f hello && mkdir -p hello && >hello/world && - test "$(git ls-files -o)" = hello/world + test "$(git ls-files -o --exclude-standard)" = hello/world ' diff --git a/t/test-lib-functions.sh b/t/test-lib-functions.sh index f3af10fb7e0205..0505da78e83527 100644 --- a/t/test-lib-functions.sh +++ b/t/test-lib-functions.sh @@ -2069,3 +2069,11 @@ test_trailing_hash () { test_redact_non_printables () { tr -d "\n\r" | tr "[\001-\040][\177-\377]" "." } + +# Remove .gitconfig entries from a file in place. test-lib.sh may +# create $HOME/.gitconfig (e.g. to set safe.bareRepository) which +# can appear in ls-files or status output. +test_filter_gitconfig () { + sed "/\\.gitconfig/d" "$1" >"$1.filtered" && + mv "$1.filtered" "$1" +} From a09d2b27ff9331d03a629b5ffcf81cb16bbe6544 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 26 Apr 2026 14:38:35 +0000 Subject: [PATCH 097/608] status tests: filter `.gitconfig` from status output Since test-lib.sh creates `$HOME/.gitconfig` when `WITH_BREAKING_CHANGES` is in effect, the file appears in `git status` output as either untracked (`?? .gitconfig`) or ignored (`!! .gitconfig` / `! .gitconfig`, depending on porcelain version), because the `.git/info/exclude` entry causes git to treat it as an ignored file rather than hiding it entirely. In t7061 and t7521, which are pervasively affected, introduce a `filter_gitconfig` helper that strips all status-prefix variants of `.gitconfig` from the output before comparison. In the remaining scripts (t7060, t7064, t7508), apply targeted adjustments. Assisted-by: Claude Opus 4.6 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- t/t7060-wtstatus.sh | 3 +-- t/t7061-wtstatus-ignore.sh | 27 +++++++++++++++++++++++++++ t/t7064-wtstatus-pv2.sh | 1 + t/t7508-status.sh | 4 ++++ t/t7521-ignored-mode.sh | 1 + 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/t/t7060-wtstatus.sh b/t/t7060-wtstatus.sh index 0f4344c55e6421..942ddbbf0eca9e 100755 --- a/t/t7060-wtstatus.sh +++ b/t/t7060-wtstatus.sh @@ -9,6 +9,7 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME test_expect_success setup ' git config --global advice.statusuoption false && + echo "/.gitconfig" >>.git/info/exclude && test_commit A && test_commit B oneside added && git checkout A^0 && @@ -221,7 +222,6 @@ test_expect_success 'status --branch with detached HEAD' ' git status --branch --porcelain >actual && cat >expected <<-EOF && ## HEAD (no branch) - ?? .gitconfig ?? actual ?? expect ?? expected @@ -237,7 +237,6 @@ test_expect_success 'status --porcelain=v1 --branch with detached HEAD' ' git status --branch --porcelain=v1 >actual && cat >expected <<-EOF && ## HEAD (no branch) - ?? .gitconfig ?? actual ?? expect ?? expected diff --git a/t/t7061-wtstatus-ignore.sh b/t/t7061-wtstatus-ignore.sh index 2f9bea9793cec8..14ddaba2f3e7ea 100755 --- a/t/t7061-wtstatus-ignore.sh +++ b/t/t7061-wtstatus-ignore.sh @@ -18,6 +18,7 @@ test_expect_success 'status untracked directory with --ignored' ' : >untracked/ignored && : >untracked/uncommitted && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -27,6 +28,7 @@ test_expect_success 'same with gitignore starting with BOM' ' : >untracked/ignored && : >untracked/uncommitted && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -40,18 +42,22 @@ test_expect_success 'status untracked files --ignored with pathspec (no match)' test_expect_success 'status untracked files --ignored with pathspec (literal match)' ' git status --porcelain --ignored -- untracked/ignored >actual && echo "!! untracked/ignored" >expected && + test_filter_gitconfig actual && test_cmp expected actual && git status --porcelain --ignored -- untracked/uncommitted >actual && echo "?? untracked/uncommitted" >expected && + test_filter_gitconfig actual && test_cmp expected actual ' test_expect_success 'status untracked files --ignored with pathspec (glob match)' ' git status --porcelain --ignored -- untracked/i\* >actual && echo "!! untracked/ignored" >expected && + test_filter_gitconfig actual && test_cmp expected actual && git status --porcelain --ignored -- untracked/u\* >actual && echo "?? untracked/uncommitted" >expected && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -65,6 +71,7 @@ EOF test_expect_success 'status untracked directory with --ignored -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' cat >expected <<\EOF @@ -76,9 +83,11 @@ test_expect_success 'status of untracked directory with --ignored works with or git status --porcelain --ignored >tmp && grep untracked/ tmp >actual && rm tmp && + test_filter_gitconfig actual && test_cmp expected actual && git status --porcelain --ignored untracked/ >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -89,6 +98,7 @@ EOF test_expect_success 'status prefixed untracked sub-directory with --ignored -u' ' git status --porcelain --ignored -u untracked/ >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -104,6 +114,7 @@ test_expect_success 'status ignored directory with --ignore' ' mkdir ignored && : >ignored/uncommitted && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -116,6 +127,7 @@ EOF test_expect_success 'status ignored directory with --ignore -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -130,6 +142,7 @@ test_expect_success 'status empty untracked directory with --ignore' ' mkdir untracked-ignored && mkdir untracked-ignored/test && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -141,6 +154,7 @@ EOF test_expect_success 'status empty untracked directory with --ignore -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -155,6 +169,7 @@ test_expect_success 'status untracked directory with ignored files with --ignore : >untracked-ignored/ignored && : >untracked-ignored/test/ignored && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -168,6 +183,7 @@ EOF test_expect_success 'status untracked directory with ignored files with --ignore -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -185,6 +201,7 @@ test_expect_success 'status ignored tracked directory with --ignore' ' git commit -m. && echo "tracked" >.gitignore && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -196,6 +213,7 @@ EOF test_expect_success 'status ignored tracked directory with --ignore -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -208,6 +226,7 @@ EOF test_expect_success 'status ignored tracked directory and ignored file with --ignore' ' echo "committed" >>.gitignore && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -219,6 +238,7 @@ EOF test_expect_success 'status ignored tracked directory and ignored file with --ignore -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -233,6 +253,7 @@ test_expect_success 'status ignored tracked directory and uncommitted file with echo "tracked" >.gitignore && : >tracked/uncommitted && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -245,6 +266,7 @@ EOF test_expect_success 'status ignored tracked directory and uncommitted file with --ignore -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -260,6 +282,7 @@ test_expect_success 'status ignored tracked directory with uncommitted file in u mkdir tracked/ignored && : >tracked/ignored/uncommitted && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -272,6 +295,7 @@ EOF test_expect_success 'status ignored tracked directory with uncommitted file in untracked subdir with --ignore -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -287,6 +311,7 @@ test_expect_success 'status ignored tracked directory with uncommitted file in t git add -f tracked/ignored/committed && git commit -m. && git status --porcelain --ignored >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -299,6 +324,7 @@ EOF test_expect_success 'status ignored tracked directory with uncommitted file in tracked subdir with --ignore -u' ' git status --porcelain --ignored -u >actual && + test_filter_gitconfig actual && test_cmp expected actual ' @@ -310,6 +336,7 @@ test_expect_success 'status ignores submodule in excluded directory' ' git init tracked/submodule && test_commit -C tracked/submodule initial && git status --porcelain --ignored -u tracked/submodule >actual && + test_filter_gitconfig actual && test_cmp expected actual ' diff --git a/t/t7064-wtstatus-pv2.sh b/t/t7064-wtstatus-pv2.sh index 8bbc5ce6d9886c..be6c931a9624bc 100755 --- a/t/t7064-wtstatus-pv2.sh +++ b/t/t7064-wtstatus-pv2.sh @@ -231,6 +231,7 @@ test_expect_success 'ignored files are printed with --ignored' ' EOF git status --porcelain=v2 --ignored --untracked-files=all >actual && + test_filter_gitconfig actual && test_cmp expect actual ' diff --git a/t/t7508-status.sh b/t/t7508-status.sh index a5e21bf8bffb45..5f76ec62d8dc47 100755 --- a/t/t7508-status.sh +++ b/t/t7508-status.sh @@ -263,6 +263,7 @@ test_expect_success 'status with gitignore' ' !! untracked EOF git status -s --ignored >output && + test_filter_gitconfig output && test_cmp expect output && cat >expect <<\EOF && @@ -296,6 +297,7 @@ Ignored files: EOF git status --ignored >output && + test_filter_gitconfig output && test_cmp expect output ' @@ -328,6 +330,7 @@ test_expect_success 'status with gitignore (nothing untracked)' ' !! untracked EOF git status -s --ignored >output && + test_filter_gitconfig output && test_cmp expect output && cat >expect <<\EOF && @@ -358,6 +361,7 @@ Ignored files: EOF git status --ignored >output && + test_filter_gitconfig output && test_cmp expect output ' diff --git a/t/t7521-ignored-mode.sh b/t/t7521-ignored-mode.sh index a88b02b06ed342..7ea0b0d2f2a988 100755 --- a/t/t7521-ignored-mode.sh +++ b/t/t7521-ignored-mode.sh @@ -30,6 +30,7 @@ test_expect_success 'Verify behavior of status on directories with ignored files dir/ignored/ignored_1.ign dir/ignored/ignored_2.ign && git status --porcelain=v2 --ignored=matching --untracked-files=all >output && + test_filter_gitconfig output && test_cmp expect output ' From 985b38ca6cdbd04432a7072c790f8ddf9dcc717c Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 26 Apr 2026 14:38:36 +0000 Subject: [PATCH 098/608] safe.bareRepository: default to "explicit" with WITH_BREAKING_CHANGES When an attacker can convince a user to clone a crafted repository that contains an embedded bare repository with malicious hooks, any Git command the user runs after entering that subdirectory will discover the bare repository and execute the hooks. The user does not even need to run a Git command explicitly: many shell prompts run `git status` in the background to display branch and dirty state information, and `git status` in turn may invoke the fsmonitor hook if so configured, making the user vulnerable the moment they `cd` into the directory. The `safe.bareRepository` configuration variable (introduced in 8959555cee7e (setup_git_directory(): add an owner check for the top-level directory, 2022-03-02)) already provides protection against this attack vector by allowing users to set it to "explicit", but the default remained "all" for backwards compatibility. Since Git 3.0 is the natural point to change defaults to safer values, flip the default from "all" to "explicit" when built with `WITH_BREAKING_CHANGES`. This means Git will refuse to work with bare repositories that are discovered implicitly by walking up the directory tree. Bare repositories specified via `--git-dir` or `GIT_DIR` continue to work, and directories that look like `.git`, worktrees, or submodule directories are unaffected (the existing `is_implicit_bare_repo()` whitelist handles those cases). Users who rely on implicit bare repository discovery can restore the previous behavior by setting `safe.bareRepository=all` in their global or system configuration. The test for the "safe.bareRepository in the repository" scenario needed a more involved fix: it writes a `safe.bareRepository=all` entry into the bare repository's own config to verify that repo-local config does not override the protected (global) setting. Previously, `test_config -C` was used to write that entry, but its cleanup runs `git -C config --unset`, which itself fails when the default is "explicit" and the global config has already been cleaned up. Switching to direct git config --file access avoids going through repository discovery entirely. Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- Documentation/BreakingChanges.adoc | 24 ++++++++++++++++++++++++ Documentation/config/safe.adoc | 10 ++++++++-- setup.c | 4 ++++ t/t0035-safe-bare-repository.sh | 10 ++++++++-- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Documentation/BreakingChanges.adoc b/Documentation/BreakingChanges.adoc index af59c43f42c8e6..73bb939359c72e 100644 --- a/Documentation/BreakingChanges.adoc +++ b/Documentation/BreakingChanges.adoc @@ -216,6 +216,30 @@ would be significant, we may decide to defer this change to a subsequent minor release. This evaluation will also take into account our own experience with how painful it is to keep Rust an optional component. +* The default value of `safe.bareRepository` will change from `all` to + `explicit`. It is all too easy for an attacker to trick a user into cloning a + repository that contains an embedded bare repository with malicious hooks + configured. If the user enters that subdirectory and runs any Git command, Git + discovers the bare repository and the hooks fire. The user does not even need + to run a Git command explicitly: many shell prompts run `git status` in the + background to display branch and dirty state information, and `git status` in + turn may invoke the fsmonitor hook if so configured, making the user + vulnerable the moment they `cd` into the directory. The `safe.bareRepository` + configuration variable was introduced in 8959555cee (setup_git_directory(): + add an owner check for the top-level directory, 2022-03-02) with a default of + `all` to preserve backwards compatibility. ++ +Changing the default to `explicit` means that Git will refuse to work with bare +repositories that are discovered implicitly by walking up the directory tree. +Bare repositories specified explicitly via the `--git-dir` command-line option +or the `GIT_DIR` environment variable continue to work regardless of this +setting. Repositories that look like a `.git` directory, a worktree, or a +submodule directory are also unaffected. ++ +Users who rely on implicit discovery of bare repositories can restore the +previous behavior by setting `safe.bareRepository=all` in their global or +system configuration. + === Removals * Support for grafting commits has long been superseded by git-replace(1). diff --git a/Documentation/config/safe.adoc b/Documentation/config/safe.adoc index 2d45c98b12d951..5b1690aebe8f58 100644 --- a/Documentation/config/safe.adoc +++ b/Documentation/config/safe.adoc @@ -2,10 +2,12 @@ safe.bareRepository:: Specifies which bare repositories Git will work with. The currently supported values are: + -* `all`: Git works with all bare repositories. This is the default. +* `all`: Git works with all bare repositories. This is the default in + Git 2.x. * `explicit`: Git only works with bare repositories specified via the top-level `--git-dir` command-line option, or the `GIT_DIR` - environment variable (see linkgit:git[1]). + environment variable (see linkgit:git[1]). This will be the default + in Git 3.0. + If you do not use bare repositories in your workflow, then it may be beneficial to set `safe.bareRepository` to `explicit` in your global @@ -13,6 +15,10 @@ config. This will protect you from attacks that involve cloning a repository that contains a bare repository and running a Git command within that directory. + +If you use bare repositories regularly and want to preserve the current +behavior after upgrading to Git 3.0, set `safe.bareRepository` to `all` +in your global or system config. ++ This config setting is only respected in protected configuration (see <>). This prevents untrusted repositories from tampering with this value. diff --git a/setup.c b/setup.c index 7ec4427368a2a7..17c0662076487e 100644 --- a/setup.c +++ b/setup.c @@ -1485,7 +1485,11 @@ static int allowed_bare_repo_cb(const char *key, const char *value, static enum allowed_bare_repo get_allowed_bare_repo(void) { +#ifdef WITH_BREAKING_CHANGES + enum allowed_bare_repo result = ALLOWED_BARE_REPO_EXPLICIT; +#else enum allowed_bare_repo result = ALLOWED_BARE_REPO_ALL; +#endif git_protected_config(allowed_bare_repo_cb, &result); return result; } diff --git a/t/t0035-safe-bare-repository.sh b/t/t0035-safe-bare-repository.sh index ae7ef092abf6d9..1d3d19f5b476a0 100755 --- a/t/t0035-safe-bare-repository.sh +++ b/t/t0035-safe-bare-repository.sh @@ -44,11 +44,16 @@ test_expect_success 'setup an embedded bare repo, secondary worktree and submodu test_path_is_dir outer-repo/.git/modules/subn ' -test_expect_success 'safe.bareRepository unset' ' +test_expect_success !WITH_BREAKING_CHANGES 'safe.bareRepository unset' ' test_unconfig --global safe.bareRepository && expect_accepted_implicit -C outer-repo/bare-repo ' +test_expect_success WITH_BREAKING_CHANGES 'safe.bareRepository unset (defaults to explicit)' ' + test_unconfig --global safe.bareRepository && + expect_rejected -C outer-repo/bare-repo +' + test_expect_success 'safe.bareRepository=all' ' test_config_global safe.bareRepository all && expect_accepted_implicit -C outer-repo/bare-repo @@ -63,7 +68,8 @@ test_expect_success 'safe.bareRepository in the repository' ' # safe.bareRepository must not be "explicit", otherwise # git config fails with "fatal: not in a git directory" (like # safe.directory) - test_config -C outer-repo/bare-repo safe.bareRepository all && + test_when_finished "git config --file outer-repo/bare-repo/config --unset safe.bareRepository" && + git config --file outer-repo/bare-repo/config safe.bareRepository all && test_config_global safe.bareRepository explicit && expect_rejected -C outer-repo/bare-repo ' From 0cf4ad7cf555ac2ed6556a3169d30b2eabb68d41 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 27 Apr 2026 07:53:51 +0200 Subject: [PATCH 099/608] replay: allow callers to control what happens with empty commits When replaying commits it may happen that some of the commits become empty relative to their parent. Such commits are for now automatically dropped by the replay subsystem without much control from the user. Introduce a new enum that allows the caller to drop, keep or abort in this case. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- replay.c | 29 ++++++++++++++++++++++++----- replay.h | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/replay.c b/replay.c index f96f1f6551ae63..4ef8abb607770a 100644 --- a/replay.c +++ b/replay.c @@ -269,7 +269,8 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, - enum replay_mode mode) + enum replay_mode mode, + enum replay_empty_commit_action empty) { struct commit *base, *replayed_base; struct tree *pickme_tree, *base_tree, *replayed_base_tree; @@ -321,12 +322,25 @@ static struct commit *pick_regular_commit(struct repository *repo, } merge_opt->ancestor = NULL; merge_opt->branch2 = NULL; + if (!result->clean) return NULL; - /* Drop commits that become empty */ + + /* Handle 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; + !oideq(&pickme_tree->object.oid, &base_tree->object.oid)) { + switch (empty) { + case REPLAY_EMPTY_COMMIT_DROP: + return replayed_base; + case REPLAY_EMPTY_COMMIT_KEEP: + break; + case REPLAY_EMPTY_COMMIT_ABORT: + result->clean = error(_("commit %s became empty after replay"), + oid_to_hex(&pickme->object.oid)); + return NULL; + } + } + return create_commit(repo, result->tree, pickme, replayed_base, mode); } @@ -417,7 +431,7 @@ int replay_revisions(struct rev_info *revs, last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, mode == REPLAY_MODE_REVERT ? last_commit : onto, - &merge_opt, &result, mode); + &merge_opt, &result, mode, opts->empty); if (!last_commit) break; @@ -458,6 +472,11 @@ int replay_revisions(struct rev_info *revs, } } + if (result.clean < 0) { + ret = -1; + goto out; + } + if (!result.clean) { ret = 1; goto out; diff --git a/replay.h b/replay.h index 0ab74b9805af16..1851a07705ab03 100644 --- a/replay.h +++ b/replay.h @@ -6,6 +6,19 @@ struct repository; struct rev_info; +/* + * Controls what happens when a replayed commit becomes empty (i.e. its tree + * is identical to its parent's tree after the replay). + */ +enum replay_empty_commit_action { + /* Silently discard the empty commit. */ + REPLAY_EMPTY_COMMIT_DROP, + /* Keep the empty commit as-is. */ + REPLAY_EMPTY_COMMIT_KEEP, + /* Abort with an error. */ + REPLAY_EMPTY_COMMIT_ABORT, +}; + /* * A set of options that can be passed to `replay_revisions()`. */ @@ -43,6 +56,12 @@ struct replay_revisions_options { * Requires `onto` to be set. */ int contained; + + /* + * Controls what to do when a replayed commit becomes empty. + * Defaults to REPLAY_EMPTY_COMMIT_DROP. + */ + enum replay_empty_commit_action empty; }; /* This struct is used as an out-parameter by `replay_revisions()`. */ From 88191ea02330627a85664909026fb6953e0d8af8 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 27 Apr 2026 07:53:52 +0200 Subject: [PATCH 100/608] builtin/history: generalize function to commit trees The function `commit_tree_with_edited_message_ext()` can be used to commit a tree with a specific list of parents with an edited commit message. This function is useful outside of editing the commit message though, as it also performs the plumbing to extract the original commit message and strip some headers from it. Refactor the function to receive a flags field that allows the caller to control whether or not the commit message should be edited, or whether it should be retained as-is. This will be used in a subsequent commit. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/history.c | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/builtin/history.c b/builtin/history.c index 952693808574b7..549e352c746c87 100644 --- a/builtin/history.c +++ b/builtin/history.c @@ -91,13 +91,18 @@ static int fill_commit_message(struct repository *repo, return 0; } -static int commit_tree_with_edited_message_ext(struct repository *repo, - const char *action, - struct commit *commit_with_message, - const struct commit_list *parents, - const struct object_id *old_tree, - const struct object_id *new_tree, - struct commit **out) +enum commit_tree_flags { + COMMIT_TREE_EDIT_MESSAGE = (1 << 0), +}; + +static int commit_tree_ext(struct repository *repo, + const char *action, + struct commit *commit_with_message, + const struct commit_list *parents, + const struct object_id *old_tree, + const struct object_id *new_tree, + struct commit **out, + enum commit_tree_flags flags) { const char *exclude_gpgsig[] = { /* We reencode the message, so the encoding needs to be stripped. */ @@ -122,10 +127,14 @@ static int commit_tree_with_edited_message_ext(struct repository *repo, original_author = xmemdupz(ptr, len); find_commit_subject(original_message, &original_body); - ret = fill_commit_message(repo, old_tree, new_tree, - original_body, action, &commit_message); - if (ret < 0) - goto out; + if (flags & COMMIT_TREE_EDIT_MESSAGE) { + ret = fill_commit_message(repo, old_tree, new_tree, + original_body, action, &commit_message); + if (ret < 0) + goto out; + } else { + strbuf_addstr(&commit_message, original_body); + } original_extra_headers = read_commit_extra_headers(commit_with_message, exclude_gpgsig); @@ -168,8 +177,8 @@ static int commit_tree_with_edited_message(struct repository *repo, oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree); } - return commit_tree_with_edited_message_ext(repo, action, original, original->parents, - &parent_tree_oid, tree_oid, out); + return commit_tree_ext(repo, action, original, original->parents, + &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE); } enum ref_action { @@ -616,9 +625,8 @@ static int split_commit(struct repository *repo, * The first commit is constructed from the split-out tree. The base * that shall be diffed against is the parent of the original commit. */ - ret = commit_tree_with_edited_message_ext(repo, "split-out", original, - original->parents, &parent_tree_oid, - &split_tree->object.oid, &first_commit); + ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid, + &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE); if (ret < 0) { ret = error(_("failed writing first commit")); goto out; @@ -634,9 +642,8 @@ static int split_commit(struct repository *repo, old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid; new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid; - ret = commit_tree_with_edited_message_ext(repo, "split-out", original, - parents, old_tree_oid, - new_tree_oid, &second_commit); + ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid, + new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE); if (ret < 0) { ret = error(_("failed writing second commit")); goto out; From c6c225793003ffb8b376c8994d44ca63bc04ac40 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 27 Apr 2026 07:53:53 +0200 Subject: [PATCH 101/608] builtin/history: introduce "fixup" subcommand The newly introduced git-history(1) command provides functionality to easily edit commit history while also rebasing dependent branches. The functionality exposed by this command is still somewhat limited though. One common use case when editing commit history that is not yet covered is fixing up a specific commit. Introduce a new subcommand that allows the user to do exactly that by performing a three-way merge into the target's commit tree, using HEAD's tree as the merge base. The flow is thus essentially: $ echo changes >file $ git add file $ git history fixup HEAD~ Like with the other commands, this will automatically rebase dependent branches, as well. Unlike the other commands though: - The command does not work in a bare repository as it interacts with the index. - The command may run into merge conflicts. If so, the command will simply abort. Especially the second item limits the usefulness of this command a bit. But there are plans to introduce first-class conflicts into Git, which will help use cases like this one. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- Documentation/git-history.adoc | 78 +++- builtin/history.c | 246 +++++++++++- t/meson.build | 1 + t/t3453-history-fixup.sh | 680 +++++++++++++++++++++++++++++++++ 4 files changed, 999 insertions(+), 6 deletions(-) create mode 100755 t/t3453-history-fixup.sh diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc index 24dc907033b469..2ba812179533b8 100644 --- a/Documentation/git-history.adoc +++ b/Documentation/git-history.adoc @@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history SYNOPSIS -------- [synopsis] +git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] git history reword [--dry-run] [--update-refs=(branches|head)] git history split [--dry-run] [--update-refs=(branches|head)] [--] [...] @@ -22,8 +23,9 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE. This command is related to linkgit:git-rebase[1] in that both commands can be used to rewrite history. There are a couple of major differences though: -* linkgit:git-history[1] can work in a bare repository as it does not need to - touch either the index or the worktree. +* Most subcommands of linkgit:git-history[1] can work in a bare repository as + they do not need to touch either the index or the worktree. The `fixup` + subcommand is an exception to this, as it reads staged changes from the index. * linkgit:git-history[1] does not execute any linkgit:githooks[5] at the current point in time. This may change in the future. * linkgit:git-history[1] by default updates all branches that are descendants @@ -48,11 +50,28 @@ conflicts. This limitation is by design as history rewrites are not intended to be stateful operations. The limitation can be lifted once (if) Git learns about first-class conflicts. +When using `fixup` with `--empty=drop`, dropping the root commit is not yet +supported. + COMMANDS -------- The following commands are available to rewrite history in different ways: +`fixup `:: + Apply the currently staged changes to the specified commit. This is + similar in nature to `git commit --fixup=` followed by `git + rebase --autosquash ~`. Changes are applied to the target + commit by performing a three-way merge between the HEAD commit, the + target commit and the tree generated from staged changes. ++ +The commit message and authorship of the target commit are preserved by +default, unless you specify `--reedit-message`. ++ +If applying the staged changes would result in a conflict, the command +aborts with an error. All branches that are descendants of the original +commit are updated to point to the rewritten history. + `reword `:: Rewrite the commit message of the specified commit. All the other details of this commit remain unchanged. This command will spawn an @@ -87,6 +106,31 @@ OPTIONS objects will be written into the repository, so applying these printed ref updates is generally safe. +`--reedit-message`:: + Open an editor to modify the target commit's message. + +`--empty=(drop|keep|abort)`:: + Control what happens when a commit becomes empty as a result of the + fixup. This can happen in two situations: ++ +-- +* The fixup target itself becomes empty because the staged changes exactly + cancel out all changes introduced by that commit. + +* A descendant commit becomes empty during replay because it introduced the + same change that was just fixed up into an ancestor. +-- ++ +With `drop` (the default), empty commits are removed from the rewritten +history. Descendants of a dropped target commit are replayed directly onto +the target's parent. Note that dropping the root commit is not supported; +see LIMITATIONS. ++ +With `keep`, empty commits are retained in the rewritten history as-is. ++ +With `abort`, the command stops with an error if any commit would become +empty. + `--update-refs=(branches|head)`:: Control which references will be updated by the command, if any. With `branches`, all local branches that point to commits which are @@ -96,6 +140,36 @@ OPTIONS EXAMPLES -------- +Fixup a commit +~~~~~~~~~~~~~~ + +---------- +$ git log --oneline --stat +abc1234 (HEAD -> main) third + third.txt | 1 + +def5678 second + second.txt | 1 + +ghi9012 first + first.txt | 1 + + +$ echo "change" >>unrelated.txt +$ git add unrelated.txt +$ git history fixup ghi9012 + +$ git log --oneline --stat +jkl3456 (HEAD -> main) third + third.txt | 1 + +mno7890 second + second.txt | 1 + +pqr1234 first + first.txt | 1 + + unrelated.txt | 1 + +---------- + +The staged addition of `unrelated.txt` has been incorporated into the `first` +commit. All descendant commits have been replayed on top of the rewritten +history. + Split a commit ~~~~~~~~~~~~~~ diff --git a/builtin/history.c b/builtin/history.c index 549e352c746c87..0fc06fb2045814 100644 --- a/builtin/history.c +++ b/builtin/history.c @@ -10,6 +10,7 @@ #include "gettext.h" #include "hex.h" #include "lockfile.h" +#include "merge-ort.h" #include "oidmap.h" #include "parse-options.h" #include "path.h" @@ -23,6 +24,8 @@ #include "unpack-trees.h" #include "wt-status.h" +#define GIT_HISTORY_FIXUP_USAGE \ + N_("git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]") #define GIT_HISTORY_REWORD_USAGE \ N_("git history reword [--dry-run] [--update-refs=(branches|head)]") #define GIT_HISTORY_SPLIT_USAGE \ @@ -335,10 +338,13 @@ static int handle_reference_updates(struct rev_info *revs, struct commit *original, struct commit *rewritten, const char *reflog_msg, - int dry_run) + int dry_run, + enum replay_empty_commit_action empty) { const struct name_decoration *decoration; - struct replay_revisions_options opts = { 0 }; + struct replay_revisions_options opts = { + .empty = empty, + }; struct replay_result result = { 0 }; struct ref_transaction *transaction = NULL; struct strbuf err = STRBUF_INIT; @@ -434,6 +440,236 @@ static int handle_reference_updates(struct rev_info *revs, return ret; } +static int commit_became_empty(struct repository *repo, + struct commit *original, + struct tree *result) +{ + struct commit *parent = original->parents ? original->parents->item : NULL; + struct object_id parent_tree_oid; + + if (parent) { + if (repo_parse_commit(repo, parent)) + return error(_("unable to parse parent of %s"), + oid_to_hex(&original->object.oid)); + + parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid; + } else { + oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree); + } + + return oideq(&result->object.oid, &parent_tree_oid); +} + +static int parse_opt_empty(const struct option *opt, const char *arg, int unset) +{ + enum replay_empty_commit_action *value = opt->value; + + BUG_ON_OPT_NEG(unset); + + if (!strcmp(arg, "drop")) + *value = REPLAY_EMPTY_COMMIT_DROP; + else if (!strcmp(arg, "keep")) + *value = REPLAY_EMPTY_COMMIT_KEEP; + else if (!strcmp(arg, "abort")) + *value = REPLAY_EMPTY_COMMIT_ABORT; + else + die(_("unrecognized '--empty=' action '%s'; " + "valid values are \"drop\", \"keep\", and \"abort\"."), arg); + + return 0; +} + +static int cmd_history_fixup(int argc, + const char **argv, + const char *prefix, + struct repository *repo) +{ + const char * const usage[] = { + GIT_HISTORY_FIXUP_USAGE, + NULL, + }; + enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP; + enum ref_action action = REF_ACTION_DEFAULT; + enum commit_tree_flags flags = 0; + int dry_run = 0; + struct option options[] = { + OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)", + N_("control which refs should be updated"), + PARSE_OPT_NONEG, parse_ref_action), + OPT_BOOL('n', "dry-run", &dry_run, + N_("perform a dry-run without updating any refs")), + OPT_BIT(0, "reedit-message", &flags, + N_("open an editor to modify the commit message"), + COMMIT_TREE_EDIT_MESSAGE), + OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)", + N_("how to handle commits that become empty"), + PARSE_OPT_NONEG, parse_opt_empty), + OPT_END(), + }; + struct merge_result merge_result = { 0 }; + struct merge_options merge_opts = { 0 }; + struct strbuf reflog_msg = STRBUF_INIT; + struct commit *head_commit, *original, *rewritten; + struct tree *head_tree, *original_tree, *index_tree; + struct rev_info revs = { 0 }; + bool skip_commit = false; + int ret; + + argc = parse_options(argc, argv, prefix, options, usage, 0); + if (argc != 1) { + ret = error(_("command expects a single revision")); + goto out; + } + repo_config(repo, git_default_config, NULL); + + if (action == REF_ACTION_DEFAULT) + action = REF_ACTION_BRANCHES; + + if (is_bare_repository()) { + ret = error(_("cannot run fixup in a bare repository")); + goto out; + } + + /* Resolve the original commit, which is the one we want to fix up. */ + original = lookup_commit_reference_by_name(argv[0]); + if (!original) { + ret = error(_("commit cannot be found: %s"), argv[0]); + goto out; + } + + /* + * Resolve HEAD so we can use its tree as the merge base: the staged + * changes are expressed as a diff from HEAD's tree to the index tree. + */ + head_commit = lookup_commit_reference_by_name("HEAD"); + if (!head_commit) { + ret = error(_("cannot look up HEAD")); + goto out; + } + + head_tree = repo_get_commit_tree(repo, head_commit); + if (!head_tree) { + ret = error(_("cannot get tree for HEAD")); + goto out; + } + + if (repo_read_index(repo) < 0) { + ret = error(_("unable to read index")); + goto out; + } + + if (!repo_index_has_changes(repo, head_tree, NULL)) { + ret = error(_("nothing to fixup: no staged changes")); + goto out; + } + + /* + * Write the index as a tree object. This is the "theirs" side of the + * three-way merge: it is HEAD's tree with the staged changes applied. + */ + index_tree = write_in_core_index_as_tree(repo, repo->index); + if (!index_tree) { + ret = error(_("unable to write index as a tree")); + goto out; + } + + original_tree = repo_get_commit_tree(repo, original); + if (!original_tree) { + ret = error(_("cannot get tree for commit %s"), argv[0]); + goto out; + } + + /* + * Perform the three-way merge to reapply changes in the index onto the + * target commit. This is using basically the same logic as a + * cherry-pick, where the base commit is our HEAD, ours is the original + * tree and theirs is the index tree. + */ + init_basic_merge_options(&merge_opts, repo); + merge_opts.ancestor = "HEAD"; + merge_opts.branch1 = argv[0]; + merge_opts.branch2 = "staged"; + merge_incore_nonrecursive(&merge_opts, head_tree, + original_tree, index_tree, &merge_result); + + if (merge_result.clean < 0) { + ret = error(_("merge failed while applying fixup")); + goto out; + } + + if (!merge_result.clean) { + ret = error(_("fixup would produce conflicts; aborting")); + goto out; + } + + ret = commit_became_empty(repo, original, merge_result.tree); + if (ret < 0) + goto out; + if (ret > 0) { + switch (empty) { + case REPLAY_EMPTY_COMMIT_DROP: + /* + * Drop the target commit by replaying its descendants + * directly onto its parent. + */ + rewritten = original->parents ? original->parents->item : NULL; + + /* + * TODO: we don't yet have the ability to drop root + * commits, but there's ultimately no good reason for + * this restriction to exist other than a technical + * limitation. + */ + if (!rewritten) { + ret = error(_("cannot drop root commit %s: " + "it has no parent to replay onto"), + argv[0]); + goto out; + } + + skip_commit = true; + break; + case REPLAY_EMPTY_COMMIT_KEEP: + /* Proceed and record the empty commit. */ + break; + case REPLAY_EMPTY_COMMIT_ABORT: + ret = error(_("fixup makes commit %s empty"), argv[0]); + goto out; + } + } + + ret = setup_revwalk(repo, action, original, &revs); + if (ret) + goto out; + + if (!skip_commit) { + ret = commit_tree_ext(repo, "fixup", original, original->parents, + &original_tree->object.oid, &merge_result.tree->object.oid, + &rewritten, flags); + if (ret < 0) { + ret = error(_("failed writing fixed-up commit")); + goto out; + } + } + + strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]); + + ret = handle_reference_updates(&revs, action, original, rewritten, + reflog_msg.buf, dry_run, empty); + if (ret < 0) { + ret = error(_("failed replaying descendants")); + goto out; + } + + ret = 0; + +out: + merge_finalize(&merge_opts, &merge_result); + strbuf_release(&reflog_msg); + release_revisions(&revs); + return ret; +} + static int cmd_history_reword(int argc, const char **argv, const char *prefix, @@ -487,7 +723,7 @@ static int cmd_history_reword(int argc, strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]); ret = handle_reference_updates(&revs, action, original, rewritten, - reflog_msg.buf, dry_run); + reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT); if (ret < 0) { ret = error(_("failed replaying descendants")); goto out; @@ -724,7 +960,7 @@ static int cmd_history_split(int argc, strbuf_addf(&reflog_msg, "split: updating %s", argv[0]); ret = handle_reference_updates(&revs, action, original, rewritten, - reflog_msg.buf, dry_run); + reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT); if (ret < 0) { ret = error(_("failed replaying descendants")); goto out; @@ -745,12 +981,14 @@ int cmd_history(int argc, struct repository *repo) { const char * const usage[] = { + GIT_HISTORY_FIXUP_USAGE, GIT_HISTORY_REWORD_USAGE, GIT_HISTORY_SPLIT_USAGE, NULL, }; parse_opt_subcommand_fn *fn = NULL; struct option options[] = { + OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup), OPT_SUBCOMMAND("reword", &fn, cmd_history_reword), OPT_SUBCOMMAND("split", &fn, cmd_history_split), OPT_END(), diff --git a/t/meson.build b/t/meson.build index 7528e5cda5fef0..f502ad8ec97710 100644 --- a/t/meson.build +++ b/t/meson.build @@ -397,6 +397,7 @@ integration_tests = [ 't3450-history.sh', 't3451-history-reword.sh', 't3452-history-split.sh', + 't3453-history-fixup.sh', 't3500-cherry.sh', 't3501-revert-cherry-pick.sh', 't3502-cherry-pick-merge.sh', diff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh new file mode 100755 index 00000000000000..868298e248fe49 --- /dev/null +++ b/t/t3453-history-fixup.sh @@ -0,0 +1,680 @@ +#!/bin/sh + +test_description='tests for git-history fixup subcommand' + +. ./test-lib.sh + +fixup_with_message () { + cat >message && + write_script fake-editor.sh <<-\EOF && + cp message "$1" + EOF + test_set_editor "$(pwd)"/fake-editor.sh && + git history fixup --reedit-message "$@" && + rm fake-editor.sh message +} + +expect_changes () { + git log --format="%s" --numstat "$@" >actual.raw && + sed '/^$/d' actual && + cat >expect && + test_cmp expect actual +} + +test_expect_success 'errors on missing commit argument' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history fixup 2>err && + test_grep "command expects a single revision" err + ) +' + +test_expect_success 'errors on too many arguments' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history fixup HEAD HEAD 2>err && + test_grep "command expects a single revision" err + ) +' + +test_expect_success 'errors on unknown revision' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history fixup does-not-exist 2>err && + test_grep "commit cannot be found: does-not-exist" err + ) +' + +test_expect_success 'errors when nothing is staged' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history fixup HEAD 2>err && + test_grep "nothing to fixup: no staged changes" err + ) +' + +test_expect_success 'errors in a bare repository' ' + test_when_finished "rm -rf repo repo.git" && + git init repo && + test_commit -C repo initial && + git clone --bare repo repo.git && + test_must_fail git -C repo.git history fixup HEAD 2>err && + test_grep "cannot run fixup in a bare repository" err +' + +test_expect_success 'errors with invalid --empty= value' ' + test_when_finished "rm -rf repo" && + git init repo && + test_must_fail git -C repo history fixup --empty=bogus HEAD 2>err && + test_grep "unrecognized.*--empty.*bogus" err +' + +test_expect_success 'can fixup the tip commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + echo content >file.txt && + git add file.txt && + git commit -m "add file" && + + echo fix >>file.txt && + git add file.txt && + + expect_changes <<-\EOF && + add file + 1 0 file.txt + initial + 1 0 initial.t + EOF + + git symbolic-ref HEAD >branch-expect && + git history fixup HEAD && + git symbolic-ref HEAD >branch-actual && + test_cmp branch-expect branch-actual && + + expect_changes <<-\EOF && + add file + 2 0 file.txt + initial + 1 0 initial.t + EOF + + # Verify the fix is in the tip commit tree + git show HEAD:file.txt >actual && + printf "content\nfix\n" >expect && + test_cmp expect actual && + + git reflog >reflog && + test_grep "fixup: updating HEAD" reflog + ) +' + +test_expect_success 'can fixup a commit in the middle of history' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + echo content >file.txt && + git add file.txt && + git commit -m "add file" && + test_commit third && + + echo fix >>file.txt && + git add file.txt && + + expect_changes <<-\EOF && + third + 1 0 third.t + add file + 1 0 file.txt + first + 1 0 first.t + EOF + + git history fixup HEAD~ && + + expect_changes <<-\EOF && + third + 1 0 third.t + add file + 2 0 file.txt + first + 1 0 first.t + EOF + + # Verify the fix landed in the "add file" commit. + git show HEAD~:file.txt >actual && + printf "content\nfix\n" >expect && + test_cmp expect actual && + + # And verify that the replayed commit also has the change. + git show HEAD:file.txt >actual && + printf "content\nfix\n" >expect && + test_cmp expect actual + ) +' + +test_expect_success 'can fixup root commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + echo initial >root.txt && + git add root.txt && + git commit -m "root" && + test_commit second && + + expect_changes <<-\EOF && + second + 1 0 second.t + root + 1 0 root.txt + EOF + + echo fix >>root.txt && + git add root.txt && + git history fixup HEAD~ && + + expect_changes <<-\EOF && + second + 1 0 second.t + root + 2 0 root.txt + EOF + + git show HEAD~:root.txt >actual && + printf "initial\nfix\n" >expect && + test_cmp expect actual + ) +' + +test_expect_success 'preserves commit message and authorship' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + echo content >file.txt && + git add file.txt && + git commit --author="Original " -m "original message" && + + echo fix >>file.txt && + git add file.txt && + git history fixup HEAD && + + # Message preserved + git log -1 --format="%s" >actual && + echo "original message" >expect && + test_cmp expect actual && + + # Authorship preserved + git log -1 --format="%an <%ae>" >actual && + echo "Original " >expect && + test_cmp expect actual + ) +' + +test_expect_success 'updates all descendant branches by default' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit base && + git branch branch && + test_commit ours && + git switch branch && + test_commit theirs && + git switch main && + + echo fix >fix.txt && + git add fix.txt && + git history fixup base && + + expect_changes --branches <<-\EOF && + theirs + 1 0 theirs.t + ours + 1 0 ours.t + base + 1 0 base.t + 1 0 fix.txt + EOF + + # Both branches should have the fix in the base + git show main~:fix.txt >actual && + echo fix >expect && + test_cmp expect actual && + git show branch~:fix.txt >actual && + test_cmp expect actual + ) +' + +test_expect_success 'can fixup commit on a different branch' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + git branch theirs && + test_commit ours && + git switch theirs && + test_commit theirs && + + # Stage a change while on "theirs" + echo fix >fix.txt && + git add fix.txt && + + # Ensure that "ours" does not change, as it does not contain + # the commit in question. + git rev-parse ours >ours-before && + git history fixup theirs && + git rev-parse ours >ours-after && + test_cmp ours-before ours-after && + + git show HEAD:fix.txt >actual && + echo fix >expect && + test_cmp expect actual + ) +' + +test_expect_success '--dry-run prints ref updates without modifying repo' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit base && + git branch branch && + test_commit main-tip && + git switch branch && + test_commit branch-tip && + git switch main && + + echo fix >fix.txt && + git add fix.txt && + + git refs list >refs-before && + git history fixup --dry-run base >updates && + git refs list >refs-after && + test_cmp refs-before refs-after && + + test_grep "update refs/heads/main" updates && + test_grep "update refs/heads/branch" updates && + + expect_changes --branches <<-\EOF && + branch-tip + 1 0 branch-tip.t + main-tip + 1 0 main-tip.t + base + 1 0 base.t + EOF + + git update-ref --stdin fix.txt && + git add fix.txt && + + # Only HEAD (branch) should be updated + git history fixup --update-refs=head base && + + # The main branch should be unaffected. + expect_changes main <<-\EOF && + main-tip + 1 0 main-tip.t + base + 1 0 base.t + EOF + + # But the currently checked out branch should be modified. + expect_changes branch <<-\EOF + branch-tip + 1 0 branch-tip.t + base + 1 0 base.t + 1 0 fix.txt + EOF + ) +' + +test_expect_success '--update-refs=head refuses to rewrite commits not in HEAD ancestry' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit base && + git branch other && + test_commit main-tip && + git switch other && + test_commit other-tip && + + echo fix >fix.txt && + git add fix.txt && + + test_must_fail git history fixup --update-refs=head main-tip 2>err && + test_grep "rewritten commit must be an ancestor of HEAD" err + ) +' + +test_expect_success 'aborts when fixup would produce conflicts' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + echo "line one" >file.txt && + git add file.txt && + git commit -m "first" && + + echo "line two" >file.txt && + git add file.txt && + git commit -m "second" && + + echo "conflicting change" >file.txt && + git add file.txt && + + git refs list >refs-before && + test_must_fail git history fixup HEAD~ 2>err && + test_grep "fixup would produce conflicts" err && + git refs list >refs-after && + test_cmp refs-before refs-after + ) +' + +test_expect_success '--reedit-message opens editor for the commit message' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + echo content >file.txt && + git add file.txt && + git commit -m "add file" && + + echo fix >>file.txt && + git add file.txt && + + fixup_with_message HEAD <<-\EOF && + add file with fix + EOF + + expect_changes --branches <<-\EOF + add file with fix + 2 0 file.txt + initial + 1 0 initial.t + EOF + ) +' + +test_expect_success 'retains unstaged working tree changes after fixup' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + touch a b && + git add . && + git commit -m "initial commit" && + echo staged >a && + echo unstaged >b && + git add a && + git history fixup HEAD && + + # b is still modified in the worktree but not staged + cat >expect <<-\EOF && + M b + EOF + git status --porcelain --untracked-files=no >actual && + test_cmp expect actual + ) +' + +test_expect_success 'index is clean after fixup when target is HEAD' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + test_commit initial && + echo fix >fix.txt && + git add fix.txt && + git history fixup HEAD && + + git status --porcelain --untracked-files=no >actual && + test_must_be_empty actual + ) +' + +test_expect_success 'index is unchanged on conflict' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + echo base >file.txt && + git add file.txt && + git commit -m base && + echo change >file.txt && + git add file.txt && + git commit -m change && + + echo conflict >file.txt && + git add file.txt && + + git diff --cached >index-before && + test_must_fail git history fixup HEAD~ && + git diff --cached >index-after && + test_cmp index-before index-after + ) +' + +test_expect_success '--empty=drop removes target commit and replays descendants onto its parent' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + + test_commit first && + test_commit second && + test_commit third && + + git rm second.t && + git history fixup --empty=drop HEAD~ && + + expect_changes <<-\EOF && + third + 1 0 third.t + first + 1 0 first.t + EOF + test_must_fail git show HEAD:second.t + ) +' + +test_expect_success '--empty=drop errors out when dropping the root commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + test_commit first && + test_commit second && + + git rm first.t && + test_must_fail git history fixup --empty=drop HEAD~ 2>err && + test_grep "cannot drop root commit" err + ) +' + +test_expect_success '--empty=drop can drop the HEAD commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + test_commit first && + test_commit second && + + git rm second.t && + git history fixup --empty=drop HEAD && + + expect_changes <<-\EOF + first + 1 0 first.t + EOF + ) +' + +test_expect_success '--empty=drop drops empty replayed commits' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + touch base remove-me && + git add . && + git commit -m "base" && + git rm remove-me && + git commit -m "remove" && + touch reintroduce remove-me && + git add . && + git commit -m "reintroduce" && + + git rm remove-me && + git history fixup --empty=drop HEAD~2 && + + expect_changes <<-\EOF + reintroduce + 0 0 reintroduce + 0 0 remove-me + base + 0 0 base + EOF + ) +' + +test_expect_success '--empty=keep keeps commit when fixup target becomes empty' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + test_commit first && + test_commit second && + test_commit third && + + git rm second.t && + git history fixup --empty=keep HEAD~ && + + expect_changes <<-\EOF + third + 1 0 third.t + second + first + 1 0 first.t + EOF + ) +' + +test_expect_success '--empty=keep keeps commit when replayed commit becomes empty' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + touch base remove-me && + git add . && + git commit -m "base" && + git rm remove-me && + git commit -m "remove" && + touch reintroduce remove-me && + git add . && + git commit -m "reintroduce" && + + git rm remove-me && + git history fixup --empty=keep HEAD~2 && + + expect_changes <<-\EOF + reintroduce + 0 0 reintroduce + 0 0 remove-me + remove + base + 0 0 base + EOF + ) +' + +test_expect_success '--empty=abort errors out when fixup target becomes empty' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + test_commit first && + test_commit second && + + git rm first.t && + test_must_fail git history fixup --empty=abort HEAD~ 2>err && + test_grep "fixup makes commit.*empty" err + ) +' + +test_expect_success '--empty=abort errors out when a descendant becomes empty during replay' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + + touch base remove-me && + git add . && + git commit -m "base" && + git rm remove-me && + git commit -m "remove" && + touch reintroduce remove-me && + git add . && + git commit -m "reintroduce" && + + git rm remove-me && + test_must_fail git history fixup --empty=abort HEAD~2 2>err && + test_grep "became empty after replay" err + ) +' + +test_done From 31d19899c7a0e08e8cf843ea1abe7504eb3a1d34 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Mon, 27 Apr 2026 21:06:49 +0200 Subject: [PATCH 102/608] doc: log: fix --decorate description list 026f2e3b (doc: convert git-log to new documentation format, 2025-07-07) transformed the inline description of `--decorate` options to a description list: We also transform inline descriptions of possible values of option --decorate into a list, which is more readable and extensible. But a source code block was used instead of an open block. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-log.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Documentation/git-log.adoc b/Documentation/git-log.adoc index e304739c5e8011..1c95499060d149 100644 --- a/Documentation/git-log.adoc +++ b/Documentation/git-log.adoc @@ -36,14 +36,14 @@ OPTIONS Print out the ref names of any commits that are shown. Possible values are: + ----- +-- `short`;; the ref name prefixes `refs/heads/`, `refs/tags/` and `refs/remotes/` are not printed. `full`;; the full ref name (including prefix) is printed. `auto`:: if the output is going to a terminal, the ref names are shown as if `short` were given, otherwise no ref names are shown. ----- +-- + The option `--decorate` is short-hand for `--decorate=short`. Default to configuration value of `log.decorate` if configured, otherwise, `auto`. From b635fd0725dd74ae59a0467a3180624a8e9abdb0 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Mon, 27 Apr 2026 21:06:50 +0200 Subject: [PATCH 103/608] doc: log: use the same delimiter in description list We must use the same delimiter since this is a meant to be a flat list. Introducing a new legal delimiter like `::` makes an inner description list: ... full the full ref name ... auto if the output ... Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-log.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/git-log.adoc b/Documentation/git-log.adoc index 1c95499060d149..fb3ac112839cf7 100644 --- a/Documentation/git-log.adoc +++ b/Documentation/git-log.adoc @@ -40,7 +40,7 @@ OPTIONS `short`;; the ref name prefixes `refs/heads/`, `refs/tags/` and `refs/remotes/` are not printed. `full`;; the full ref name (including prefix) is printed. -`auto`:: if the output is going to a terminal, the ref names +`auto`;; if the output is going to a terminal, the ref names are shown as if `short` were given, otherwise no ref names are shown. -- From 1ddc0481cfb744d51e235a8cccf97d4afb498743 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 27 Apr 2026 22:18:33 +0000 Subject: [PATCH 104/608] commit: name UTF-8 function appropriately We have a function named verify_utf8, but it does more than verify, it modifies the buffer if it is not UTF-8. This is different from what most people would expect, so call the function ensure_utf8, since it mutates the buffer in some cases. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- commit.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commit.c b/commit.c index 80d8d078757dbc..790dd2faed77e0 100644 --- a/commit.c +++ b/commit.c @@ -1637,12 +1637,12 @@ static int find_invalid_utf8(const char *buf, int len) } /* - * This verifies that the buffer is in proper utf8 format. + * This ensures that the buffer is in proper utf8 format. * * If it isn't, it assumes any non-utf8 characters are Latin1, * and does the conversion. */ -static int verify_utf8(struct strbuf *buf) +static int ensure_utf8(struct strbuf *buf) { int ok = 1; long pos = 0; @@ -1819,7 +1819,7 @@ int commit_tree_extended(const char *msg, size_t msg_len, } /* And check the encoding. */ - if (encoding_is_utf8 && (!verify_utf8(&buffer) || !verify_utf8(&compat_buffer))) + if (encoding_is_utf8 && (!ensure_utf8(&buffer) || !ensure_utf8(&compat_buffer))) fprintf(stderr, _(commit_utf8_warn)); if (r->compat_hash_algo) { From 7735d7eee3a586181dc397afa5aa8f02e009833b Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 27 Apr 2026 22:18:34 +0000 Subject: [PATCH 105/608] commit: sign commit after mutating buffer The ensure_utf8 function can mutate the buffer to change its encoding, so we must call it before signing the buffer so that we do not invalidate the signature, which is made over raw bytes. Fix a bug which caused the compatibility code to not convert the compatibility buffer if the main buffer was invalid UTF-8. We expect both buffers to be valid UTF-8 or both invalid, since the only data that would differ between them would be hex object IDs, which are always valid UTF-8. Add a test for this case using 0xfe and 0xff, which are never valid in UTF-8. Reported-by: Kushal Das Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- commit.c | 15 +++++++++++---- t/t7510-signed-commit.sh | 10 ++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/commit.c b/commit.c index 790dd2faed77e0..e5d725fe933080 100644 --- a/commit.c +++ b/commit.c @@ -1726,6 +1726,7 @@ int commit_tree_extended(const char *msg, size_t msg_len, struct repository *r = the_repository; int result = 0; int encoding_is_utf8; + bool warned = false; struct strbuf buffer = STRBUF_INIT, compat_buffer = STRBUF_INIT; struct strbuf sig = STRBUF_INIT, compat_sig = STRBUF_INIT; struct object_id *parent_buf = NULL, *compat_oid = NULL; @@ -1747,6 +1748,13 @@ int commit_tree_extended(const char *msg, size_t msg_len, oidcpy(&parent_buf[i++], &p->item->object.oid); write_commit_tree(&buffer, msg, msg_len, tree, parent_buf, nparents, author, committer, extra); + + /* And check the encoding. */ + if (encoding_is_utf8 && !ensure_utf8(&buffer)) { + fprintf(stderr, _(commit_utf8_warn)); + warned = true; + } + if (sign_commit && sign_buffer(&buffer, &sig, sign_commit, SIGN_BUFFER_USE_DEFAULT_KEY)) { result = -1; @@ -1780,6 +1788,9 @@ int commit_tree_extended(const char *msg, size_t msg_len, free_commit_extra_headers(compat_extra); free(mapped_parents); + if (encoding_is_utf8 && !ensure_utf8(&compat_buffer) && !warned) + fprintf(stderr, _(commit_utf8_warn)); + if (sign_commit && sign_buffer(&compat_buffer, &compat_sig, sign_commit, SIGN_BUFFER_USE_DEFAULT_KEY)) { @@ -1818,10 +1829,6 @@ int commit_tree_extended(const char *msg, size_t msg_len, } } - /* And check the encoding. */ - if (encoding_is_utf8 && (!ensure_utf8(&buffer) || !ensure_utf8(&compat_buffer))) - fprintf(stderr, _(commit_utf8_warn)); - if (r->compat_hash_algo) { hash_object_file(r->compat_hash_algo, compat_buffer.buf, compat_buffer.len, OBJ_COMMIT, &compat_oid_buf); diff --git a/t/t7510-signed-commit.sh b/t/t7510-signed-commit.sh index 1201c85ba62af8..aa9108da54452d 100755 --- a/t/t7510-signed-commit.sh +++ b/t/t7510-signed-commit.sh @@ -462,4 +462,14 @@ test_expect_success 'custom `gpg.program`' ' git commit -S --allow-empty -m signed-commit ' +test_expect_success GPG 'commit verifies with non-UTF-8 commit message' ' + printf "I hate\\376\\377UTF-8\\n" >message && + echo unusual-message >file && + git add file && + test_tick && git commit -S -F message 2>err && + git verify-commit HEAD && + grep "commit message did not conform to UTF-8" err >lines && + test_line_count = 1 lines +' + test_done From 4a9e0972280d821990a672a11465f90cef60dfae Mon Sep 17 00:00:00 2001 From: Zakariyah Ali Date: Wed, 29 Apr 2026 11:36:06 +0100 Subject: [PATCH 106/608] t2000: consolidate second scenario into a single test block The second test scenario in t2000 consists of several fragmented test_expect_success blocks that handle data setup, tree writes, execution of git-checkout-index, and final state validation. Consolidate these nine separate blocks into a single self-contained test block. This follows the modern Git testing standard where setup, execution, and validation of a single logical scenario are kept together. As a result of this consolidation, the show_files() helper and its associated test_debug calls are no longer used and have been removed. This also removes a dependency on the non-portable 'find -ls' command. Helped-by: Karthik Nayak Helped-by: Junio C Hamano Signed-off-by: Zakariyah Ali Signed-off-by: Junio C Hamano --- t/t2000-conflict-when-checking-files-out.sh | 65 +++------------------ 1 file changed, 8 insertions(+), 57 deletions(-) diff --git a/t/t2000-conflict-when-checking-files-out.sh b/t/t2000-conflict-when-checking-files-out.sh index af199d81913f1e..7b613705498396 100755 --- a/t/t2000-conflict-when-checking-files-out.sh +++ b/t/t2000-conflict-when-checking-files-out.sh @@ -23,17 +23,6 @@ test_description='git conflicts when checking files out test.' . ./test-lib.sh -show_files() { - # show filesystem files, just [-dl] for type and name - find path? -ls | - sed -e 's/^[0-9]* * [0-9]* * \([-bcdl]\)[^ ]* *[0-9]* *[^ ]* *[^ ]* *[0-9]* [A-Z][a-z][a-z] [0-9][0-9] [^ ]* /fs: \1 /' - # what's in the cache, just mode and name - git ls-files --stage | - sed -e 's/^\([0-9]*\) [0-9a-f]* [0-3] /ca: \1 /' - # what's in the tree, just mode and name. - git ls-tree -r "$1" | - sed -e 's/^\([0-9]*\) [^ ]* [0-9a-f]* /tr: \1 /' -} test_expect_success 'prepare files path0 and path1/file1' ' date >path0 && @@ -83,59 +72,21 @@ test_expect_success SYMLINKS 'checkout-index -f twice with --prefix' ' # path path3 is occupied by a non-directory. With "-f" it should remove # the symlink path3 and create directory path3 and file path3/file1. -test_expect_success 'prepare path2/file0 and index' ' +test_expect_success 'checkout-index -f resolves symlink conflict on leading path' ' mkdir path2 && date >path2/file0 && - git update-index --add path2/file0 -' - -test_expect_success 'write tree with path2/file0' ' - tree1=$(git write-tree) -' - -test_debug 'show_files $tree1' - -test_expect_success 'prepare path3/file1 and index' ' + git update-index --add path2/file0 && + tree1=$(git write-tree) && mkdir path3 && date >path3/file1 && - git update-index --add path3/file1 -' - -test_expect_success 'write tree with path3/file1' ' - tree2=$(git write-tree) -' - -test_debug 'show_files $tree2' - -test_expect_success 'read previously written tree and checkout.' ' + git update-index --add path3/file1 && + tree2=$(git write-tree) && rm -fr path3 && git read-tree -m $tree1 && - git checkout-index -f -a -' - -test_debug 'show_files $tree1' - -test_expect_success 'add a symlink' ' - test_ln_s_add path2 path3 -' - -test_expect_success 'write tree with symlink path3' ' - tree3=$(git write-tree) -' - -test_debug 'show_files $tree3' - -# Morten says "Got that?" here. -# Test begins. - -test_expect_success 'read previously written tree and checkout.' ' + git checkout-index -f -a && + test_ln_s_add path2 path3 && git read-tree $tree2 && - git checkout-index -f -a -' - -test_debug 'show_files $tree2' - -test_expect_success 'checking out conflicting path with -f' ' + git checkout-index -f -a && test_path_is_dir_not_symlink path2 && test_path_is_dir_not_symlink path3 && test_path_is_file_not_symlink path2/file0 && From b33bea27a2fa5168b8881d73d6c9c6133b046b87 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 29 Apr 2026 08:22:54 +0000 Subject: [PATCH 107/608] t5564: use a short path for the SOCKS proxy socket The SOCKS proxy test introduced in 0ca365c2ed4 (http: do not ignore proxy path, 2024-08-02) creates a Unix domain socket in `$TRASH_DIRECTORY`. When the trash directory path is long (e.g. when running from a deeply nested worktree), the socket path can exceed the 108-character limit for `struct sockaddr_un.sun_path` on Linux, causing the test to fail with "Path length ... is longer than maximum supported length (108)". We cannot work around this using the chdir trick our own socket code employs, because both sides of the connection are outside our control: the socket is created by socks4-proxy.pl via Perl's IO::Socket::UNIX, and the client side is libcurl. Use `mktemp -d` to create a unique temporary directory with a short path, and place the socket inside it. This avoids collisions between concurrent test runs (e.g. `--stress`) and tmpdir-race vulnerabilities that a static `/tmp` path would be susceptible to. Helped-by: Jeff King Assisted-by: Claude Opus 4.6 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- t/t5564-http-proxy.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/t/t5564-http-proxy.sh b/t/t5564-http-proxy.sh index 3bcbdef409b25f..b4d95b12caeaf0 100755 --- a/t/t5564-http-proxy.sh +++ b/t/t5564-http-proxy.sh @@ -50,14 +50,20 @@ start_socks() { # The %30 tests that the correct amount of percent-encoding is applied to the # proxy string passed to curl. +# Use a short path for the socket to avoid exceeding the 108-character +# Unix domain socket limit when the trash directory path is long. +SOCKS_TMPDIR=$(mktemp -d) +SOCKS_SOCK="$SOCKS_TMPDIR/%30.sock" + test_lazy_prereq SOCKS_PROXY ' test_have_prereq PERL && - start_socks "$TRASH_DIRECTORY/%30.sock" + start_socks "$SOCKS_SOCK" ' test_atexit ' test ! -e "$TRASH_DIRECTORY/socks.pid" || kill "$(cat "$TRASH_DIRECTORY/socks.pid")" + rm -rf "$SOCKS_TMPDIR" ' # The below tests morally ought to be gated on a prerequisite that Git is @@ -70,7 +76,8 @@ old_libcurl_error() { test_expect_success SOCKS_PROXY 'clone via Unix socket' ' test_when_finished "rm -rf clone" && - test_config_global http.proxy "socks4://localhost$PWD/%2530.sock" && { + socks_proxy_url="socks4://localhost$(echo "$SOCKS_SOCK" | sed "s/%/%25/g")" && + test_config_global http.proxy "$socks_proxy_url" && { { GIT_TRACE_CURL=$PWD/trace \ GIT_TRACE_CURL_COMPONENTS=socks \ From 007062ab4e01b8a1b1fe497610b8be4a57b76f4e Mon Sep 17 00:00:00 2001 From: Scott Bauersfeld Date: Tue, 28 Apr 2026 14:47:40 +0000 Subject: [PATCH 108/608] index-pack, unpack-objects: increase input buffer from 4 KiB to 128 KiB index-pack and unpack-objects both read pack data from stdin through a 4 KiB static buffer. In index-pack, each fill() flushes consumed bytes to the pack file via write_or_die(), capping every write(2) at 4 KiB. unpack-objects uses the same buffer pattern for reads. On FUSE-backed filesystems every write(2) is a synchronous round trip through the FUSE protocol (userspace -> kernel -> userspace -> back), so the 4 KiB buffer turns a clone into many unnecessary tiny writes with noticeable latency overhead. Increase the buffer from 4 KiB to 128 KiB. Introduce a shared DEFAULT_IO_BUFFER_SIZE constant in git-compat-util.h (next to MAX_IO_SIZE) and use it in index-pack, unpack-objects, and the hashfile layer in csum-file (which already used 128 KiB but hardcoded the value). Pack file writes to a FUSE filesystem with writeback caching disabled during HTTPS clones of git/git (~293 MB pack): 74,958 -> 4,687 (94% fewer) Wall-clock time of git clone over HTTPS onto a FUSE passthrough filesystem with writeback caching disabled, 3 runs per variant: vscode (~1.26 GB pack): 84.5s -> 75.7s avg (10% faster) git/git (~306 MB pack): 22.6s -> 20.0s avg (11% faster) Signed-off-by: Scott Bauersfeld Signed-off-by: Junio C Hamano --- builtin/index-pack.c | 3 +-- builtin/unpack-objects.c | 3 +-- csum-file.c | 2 +- git-compat-util.h | 6 ++++++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/builtin/index-pack.c b/builtin/index-pack.c index ca7784dc2c4969..bb3639641cd5d7 100644 --- a/builtin/index-pack.c +++ b/builtin/index-pack.c @@ -145,8 +145,7 @@ static int check_self_contained_and_connected; static struct progress *progress; -/* We always read in 4kB chunks. */ -static unsigned char input_buffer[4096]; +static unsigned char input_buffer[DEFAULT_IO_BUFFER_SIZE]; static unsigned int input_offset, input_len; static off_t consumed_bytes; static off_t max_input_size; diff --git a/builtin/unpack-objects.c b/builtin/unpack-objects.c index e01cf6e360f6d1..af67d1a1d32a86 100644 --- a/builtin/unpack-objects.c +++ b/builtin/unpack-objects.c @@ -23,8 +23,7 @@ static int dry_run, quiet, recover, has_errors, strict; static const char unpack_usage[] = "git unpack-objects [-n] [-q] [-r] [--strict]"; -/* We always read in 4kB chunks. */ -static unsigned char buffer[4096]; +static unsigned char buffer[DEFAULT_IO_BUFFER_SIZE]; static unsigned int offset, len; static off_t consumed_bytes; static off_t max_input_size; diff --git a/csum-file.c b/csum-file.c index 9558177a11b49a..d7a682c2b62102 100644 --- a/csum-file.c +++ b/csum-file.c @@ -178,7 +178,7 @@ struct hashfile *hashfd_ext(const struct git_hash_algo *algop, f->algop = unsafe_hash_algo(algop); f->algop->init_fn(&f->ctx); - f->buffer_len = opts->buffer_len ? opts->buffer_len : 128 * 1024; + f->buffer_len = opts->buffer_len ? opts->buffer_len : DEFAULT_IO_BUFFER_SIZE; f->buffer = xmalloc(f->buffer_len); f->check_buffer = NULL; diff --git a/git-compat-util.h b/git-compat-util.h index ae1bdc90a4cd6a..5024814bd48799 100644 --- a/git-compat-util.h +++ b/git-compat-util.h @@ -712,6 +712,12 @@ static inline uint64_t u64_add(uint64_t a, uint64_t b) # endif #endif +/* + * Default buffer size for buffered I/O in index-pack, unpack-objects, + * and the hashfile layer in csum-file. + */ +#define DEFAULT_IO_BUFFER_SIZE (128 * 1024) + #ifdef HAVE_ALLOCA_H # include # define xalloca(size) (alloca(size)) From 13817db2746d48b6d3cfe68e3bf10a1be67865a9 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 28 Apr 2026 18:39:08 +0000 Subject: [PATCH 109/608] stash: add --label-ours, --label-theirs, --label-base for apply Allow callers of "git stash apply" to pass custom labels for conflict markers instead of the default "Updated upstream" and "Stashed changes". Document the new options and add a test. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-stash.adoc | 11 ++++++++++- builtin/stash.c | 28 ++++++++++++++++++++-------- t/t3903-stash.sh | 24 ++++++++++++++++++++++++ xdiff/xmerge.c | 6 +++--- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/Documentation/git-stash.adoc b/Documentation/git-stash.adoc index b05c990ecd8759..50bb89f48362a4 100644 --- a/Documentation/git-stash.adoc +++ b/Documentation/git-stash.adoc @@ -12,7 +12,7 @@ git stash list [] git stash show [-u | --include-untracked | --only-untracked] [] [] git stash drop [-q | --quiet] [] git stash pop [--index] [-q | --quiet] [] -git stash apply [--index] [-q | --quiet] [] +git stash apply [--index] [-q | --quiet] [--label-ours=