From b2faaaec1193c64f7366e26a569ca6e231cbd478 Mon Sep 17 00:00:00 2001 From: Pablo Sabater Date: Sat, 28 Mar 2026 01:11:11 +0100 Subject: [PATCH 01/29] 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 02/29] 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 03/29] 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 7cce609e086866d054a1433d0356fa71e55c108d Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 15 Apr 2026 13:27:25 +0000 Subject: [PATCH 04/29] 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 05/29] 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 06/29] 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 07/29] 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 08/29] 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 09/29] 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 10/29] 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 11/29] 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 12/29] 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 13/29] 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 14/29] 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 15/29] 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 16/29] 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 6da647d8522641f932528de0167413719a38a2c0 Mon Sep 17 00:00:00 2001 From: Siddharth Asthana Date: Thu, 16 Apr 2026 09:02:50 +0530 Subject: [PATCH 17/29] 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 99ac2324a52a2e37dcde6b8dd6c9fb44706a65a0 Mon Sep 17 00:00:00 2001 From: Jonas Rebmann Date: Thu, 14 May 2026 11:07:04 +0200 Subject: [PATCH 18/29] bisect: use selected alternate terms in status output Alternate bisect terms are helpful when the terms "good" and "bad" are confusing such as when bisecting for the resolution of an issue (the first good commit) rather than the introduction of a regression. These terms must be used when marking a commit (e.g. `git bisect new`), they will be used in reference names (e.g. refs/bisect/new) and they are used in parts of git's log output such as " was both old and new" in git bisect skip's output. However, hardcoded "good"/"bad" terms are still used in a few status messages and can cause confusion about the status of the bisect such as: $ git bisect old [sha] is the first new commit or about the required action such as: status: waiting for bad commit, 1 good commit known $ git bisect bad error: Invalid command: you're currently in a new/old bisect fatal: unknown command: 'bad' This commit updates all remaining output messages which use hardcoded "good" and "bad" terms to use the selected terms consistently across the bisect output and adds tests. Signed-off-by: Jonas Rebmann Signed-off-by: Junio C Hamano --- builtin/bisect.c | 23 +++++++++++++---------- t/t6030-bisect-porcelain.sh | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/builtin/bisect.c b/builtin/bisect.c index 4520e585d0677f..ee6a2c83b8b7ea 100644 --- a/builtin/bisect.c +++ b/builtin/bisect.c @@ -465,13 +465,16 @@ static void bisect_print_status(const struct bisect_terms *terms) return; if (!state.nr_good && !state.nr_bad) - bisect_log_printf(_("status: waiting for both good and bad commits\n")); + bisect_log_printf(_("status: waiting for both %s and %s commits\n"), + terms->term_good, terms->term_bad); else if (state.nr_good) - bisect_log_printf(Q_("status: waiting for bad commit, %d good commit known\n", - "status: waiting for bad commit, %d good commits known\n", - state.nr_good), state.nr_good); + bisect_log_printf(Q_("status: waiting for %s commit, %d %s commit known\n", + "status: waiting for %s commit, %d %s commits known\n", + state.nr_good), + terms->term_bad, state.nr_good, terms->term_good); else - bisect_log_printf(_("status: waiting for good commit(s), bad commit known\n")); + bisect_log_printf(_("status: waiting for %s commit(s), %s commit known\n"), + terms->term_good, terms->term_bad); } static int bisect_next_check(const struct bisect_terms *terms, @@ -1262,14 +1265,14 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv) int rc = verify_good(terms, command.buf); is_first_run = 0; if (rc < 0 || 128 <= rc) { - error(_("unable to verify %s on good" - " revision"), command.buf); + error(_("unable to verify %s on %s" + " revision"), command.buf, terms->term_good); res = BISECT_FAILED; break; } if (rc == res) { - error(_("bogus exit code %d for good revision"), - rc); + error(_("bogus exit code %d for %s revision"), + rc, terms->term_good); res = BISECT_FAILED; break; } @@ -1314,7 +1317,7 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv) puts(_("bisect run success")); res = BISECT_OK; } else if (res == BISECT_INTERNAL_SUCCESS_1ST_BAD_FOUND) { - puts(_("bisect found first bad commit")); + printf(_("bisect found first %s commit\n"), terms->term_bad); res = BISECT_OK; } else if (res) { error(_("bisect run failed: 'git bisect %s'" diff --git a/t/t6030-bisect-porcelain.sh b/t/t6030-bisect-porcelain.sh index cdc02706404b34..3d3e4039e8265d 100755 --- a/t/t6030-bisect-porcelain.sh +++ b/t/t6030-bisect-porcelain.sh @@ -1077,8 +1077,10 @@ test_expect_success 'bisect terms shows good/bad after start' ' test_expect_success 'bisect start with one term1 and term2' ' git bisect reset && - git bisect start --term-old term2 --term-new term1 && - git bisect term2 $HASH1 && + git bisect start --term-old term2 --term-new term1 >bisect_result && + grep "status: waiting for both term2 and term1 commits" bisect_result && + git bisect term2 $HASH1 >bisect_result && + grep "status: waiting for term1 commit, 1 term2 commit known" bisect_result && git bisect term1 $HASH4 && git bisect term1 && git bisect term1 >bisect_result && @@ -1103,6 +1105,16 @@ test_expect_success 'bisect replay with term1 and term2' ' git bisect reset ' +test_expect_success 'bisect run term1 term2' ' + git bisect reset && + git bisect start --term-new term1 --term-old term2 $HASH4 $HASH1 && + git bisect term1 && + git bisect run false >bisect_result && + grep "bisect found first term1 commit" bisect_result && + git bisect log >log_to_replay.txt && + git bisect reset +' + test_expect_success 'bisect start term1 term2' ' git bisect reset && git bisect start --term-new term1 --term-old term2 $HASH4 $HASH1 && From 0c0f93e7fa645c1058226a9bfde9e2b4441edf96 Mon Sep 17 00:00:00 2001 From: Jonas Rebmann Date: Thu, 14 May 2026 11:07:05 +0200 Subject: [PATCH 19/29] bisect: print bisect terms in single quotes As bisect terms can be arbitrarily chosen, they have been quoted in some status messages, and in even more by translators. To make the role of bisect terms more clear, including in translations, and for consistency, 'enquote' all occurrences of bisect terms in status messages. Signed-off-by: Jonas Rebmann Signed-off-by: Junio C Hamano --- bisect.c | 16 ++--- builtin/bisect.c | 24 ++++---- t/t6030-bisect-porcelain.sh | 114 ++++++++++++++++++------------------ 3 files changed, 77 insertions(+), 77 deletions(-) diff --git a/bisect.c b/bisect.c index ef17a442e55d2c..905a9afb057989 100644 --- a/bisect.c +++ b/bisect.c @@ -711,7 +711,7 @@ static enum bisect_error error_if_skipped_commits(struct commit_list *tried, return BISECT_OK; printf("There are only 'skip'ped commits left to test.\n" - "The first %s commit could be any of:\n", term_bad); + "The first '%s' commit could be any of:\n", term_bad); for ( ; tried; tried = tried->next) printf("%s\n", oid_to_hex(&tried->item->object.oid)); @@ -810,7 +810,7 @@ static enum bisect_error handle_bad_merge_base(void) "between %s and [%s].\n"), bad_hex, bad_hex, good_hex); } else { - fprintf(stderr, _("The merge base %s is %s.\n" + fprintf(stderr, _("The merge base %s is '%s'.\n" "This means the first '%s' commit is " "between %s and [%s].\n"), bad_hex, term_bad, term_good, bad_hex, good_hex); @@ -820,9 +820,9 @@ static enum bisect_error handle_bad_merge_base(void) return BISECT_MERGE_BASE_CHECK; } - fprintf(stderr, _("Some %s revs are not ancestors of the %s rev.\n" + fprintf(stderr, _("Some '%s' revs are not ancestors of the '%s' rev.\n" "git bisect cannot work properly in this case.\n" - "Maybe you mistook %s and %s revs?\n"), + "Maybe you mistook '%s' and '%s' revs?\n"), term_good, term_bad, term_good, term_bad); return BISECT_FAILED; } @@ -835,7 +835,7 @@ static void handle_skipped_merge_base(const struct object_id *mb) warning(_("the merge base between %s and [%s] " "must be skipped.\n" - "So we cannot be sure the first %s commit is " + "So we cannot be sure the first '%s' commit is " "between %s and %s.\n" "We continue anyway."), bad_hex, good_hex, term_bad, mb_hex, bad_hex); @@ -928,7 +928,7 @@ static enum bisect_error check_good_are_ancestors_of_bad(struct repository *r, struct commit **rev; if (!current_bad_oid) - return error(_("a %s revision is needed"), term_bad); + return error(_("a '%s' revision is needed"), term_bad); filename = repo_git_path(the_repository, "BISECT_ANCESTORS_OK"); @@ -1090,7 +1090,7 @@ enum bisect_error bisect_next_all(struct repository *r, const char *prefix) res = error_if_skipped_commits(tried, NULL); if (res < 0) goto cleanup; - printf(_("%s was both %s and %s\n"), + printf(_("%s was both '%s' and '%s'\n"), oid_to_hex(current_bad_oid), term_good, term_bad); @@ -1113,7 +1113,7 @@ enum bisect_error bisect_next_all(struct repository *r, const char *prefix) res = error_if_skipped_commits(tried, current_bad_oid); if (res) goto cleanup; - printf("%s is the first %s commit\n", oid_to_hex(bisect_rev), + printf("%s is the first '%s' commit\n", oid_to_hex(bisect_rev), term_bad); show_commit(revs.commits->item); diff --git a/builtin/bisect.c b/builtin/bisect.c index ee6a2c83b8b7ea..606698b21ef7fe 100644 --- a/builtin/bisect.c +++ b/builtin/bisect.c @@ -465,15 +465,15 @@ static void bisect_print_status(const struct bisect_terms *terms) return; if (!state.nr_good && !state.nr_bad) - bisect_log_printf(_("status: waiting for both %s and %s commits\n"), + bisect_log_printf(_("status: waiting for both '%s' and '%s' commits\n"), terms->term_good, terms->term_bad); else if (state.nr_good) - bisect_log_printf(Q_("status: waiting for %s commit, %d %s commit known\n", - "status: waiting for %s commit, %d %s commits known\n", + bisect_log_printf(Q_("status: waiting for '%s' commit, %d '%s' commit known\n", + "status: waiting for '%s' commit, %d '%s' commits known\n", state.nr_good), terms->term_bad, state.nr_good, terms->term_good); else - bisect_log_printf(_("status: waiting for %s commit(s), %s commit known\n"), + bisect_log_printf(_("status: waiting for '%s' commit(s), '%s' commit known\n"), terms->term_good, terms->term_bad); } @@ -516,8 +516,8 @@ static int bisect_terms(struct bisect_terms *terms, const char *option) return error(_("no terms defined")); if (!option) { - printf(_("Your current terms are %s for the old state\n" - "and %s for the new state.\n"), + printf(_("Your current terms are '%s' for the old state\n" + "and '%s' for the new state.\n"), terms->term_good, terms->term_bad); return 0; } @@ -635,7 +635,7 @@ static int bisect_skipped_commits(struct bisect_terms *terms) strbuf_reset(&commit_name); repo_format_commit_message(the_repository, commit, "%s", &commit_name, &pp); - fprintf(fp, "# possible first %s commit: [%s] %s\n", + fprintf(fp, "# possible first '%s' commit: [%s] %s\n", terms->term_bad, oid_to_hex(&commit->object.oid), commit_name.buf); } @@ -666,7 +666,7 @@ static int bisect_successful(struct bisect_terms *terms) repo_format_commit_message(the_repository, commit, "%s", &commit_name, &pp); - res = append_to_file(git_path_bisect_log(), "# first %s commit: [%s] %s\n", + res = append_to_file(git_path_bisect_log(), "# first '%s' commit: [%s] %s\n", terms->term_bad, oid_to_hex(&commit->object.oid), commit_name.buf); @@ -1265,13 +1265,13 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv) int rc = verify_good(terms, command.buf); is_first_run = 0; if (rc < 0 || 128 <= rc) { - error(_("unable to verify %s on %s" - " revision"), command.buf, terms->term_good); + error(_("unable to verify %s on '%s' revision"), + command.buf, terms->term_good); res = BISECT_FAILED; break; } if (rc == res) { - error(_("bogus exit code %d for %s revision"), + error(_("bogus exit code %d for '%s' revision"), rc, terms->term_good); res = BISECT_FAILED; break; @@ -1317,7 +1317,7 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv) puts(_("bisect run success")); res = BISECT_OK; } else if (res == BISECT_INTERNAL_SUCCESS_1ST_BAD_FOUND) { - printf(_("bisect found first %s commit\n"), terms->term_bad); + printf(_("bisect found first '%s' commit\n"), terms->term_bad); res = BISECT_OK; } else if (res) { error(_("bisect run failed: 'git bisect %s'" diff --git a/t/t6030-bisect-porcelain.sh b/t/t6030-bisect-porcelain.sh index 3d3e4039e8265d..561751a98a1d09 100755 --- a/t/t6030-bisect-porcelain.sh +++ b/t/t6030-bisect-porcelain.sh @@ -258,7 +258,7 @@ test_expect_success 'bisect skip: successful result' ' git bisect start $HASH4 $HASH1 && git bisect skip && git bisect bad > my_bisect_log.txt && - grep "$HASH2 is the first bad commit" my_bisect_log.txt + grep "$HASH2 is the first '\''bad'\'' commit" my_bisect_log.txt ' # $HASH1 is good, $HASH4 is bad, we skip $HASH3 and $HASH2 @@ -269,7 +269,7 @@ test_expect_success 'bisect skip: cannot tell between 3 commits' ' git bisect start $HASH4 $HASH1 && git bisect skip && test_expect_code 2 git bisect skip >my_bisect_log.txt && - grep "first bad commit could be any of" my_bisect_log.txt && + grep "first '\''bad'\'' commit could be any of" my_bisect_log.txt && ! grep $HASH1 my_bisect_log.txt && grep $HASH2 my_bisect_log.txt && grep $HASH3 my_bisect_log.txt && @@ -285,7 +285,7 @@ test_expect_success 'bisect skip: cannot tell between 2 commits' ' git bisect start $HASH4 $HASH1 && git bisect skip && test_expect_code 2 git bisect good >my_bisect_log.txt && - grep "first bad commit could be any of" my_bisect_log.txt && + grep "first '\''bad'\'' commit could be any of" my_bisect_log.txt && ! grep $HASH1 my_bisect_log.txt && ! grep $HASH2 my_bisect_log.txt && grep $HASH3 my_bisect_log.txt && @@ -304,7 +304,7 @@ test_expect_success 'bisect skip: with commit both bad and skipped' ' git bisect good $HASH1 && git bisect skip && test_expect_code 2 git bisect good >my_bisect_log.txt && - grep "first bad commit could be any of" my_bisect_log.txt && + grep "first '\''bad'\'' commit could be any of" my_bisect_log.txt && ! grep $HASH1 my_bisect_log.txt && ! grep $HASH2 my_bisect_log.txt && grep $HASH3 my_bisect_log.txt && @@ -348,8 +348,8 @@ test_expect_success 'git bisect run: args, stdout and stderr with no arguments' test_bisect_run_args <<-'EOF_ARGS' 6<<-EOF_OUT 7<<-'EOF_ERR' EOF_ARGS running './run.sh' - $HASH4 is the first bad commit - bisect found first bad commit + $HASH4 is the first 'bad' commit + bisect found first 'bad' commit EOF_OUT EOF_ERR " @@ -359,8 +359,8 @@ test_expect_success 'git bisect run: args, stdout and stderr: "--" argument' " <--> EOF_ARGS running './run.sh' '--' - $HASH4 is the first bad commit - bisect found first bad commit + $HASH4 is the first 'bad' commit + bisect found first 'bad' commit EOF_OUT EOF_ERR " @@ -373,8 +373,8 @@ test_expect_success 'git bisect run: args, stdout and stderr: "--log foo --no-lo EOF_ARGS running './run.sh' '--log' 'foo' '--no-log' 'bar' - $HASH4 is the first bad commit - bisect found first bad commit + $HASH4 is the first 'bad' commit + bisect found first 'bad' commit EOF_OUT EOF_ERR " @@ -384,8 +384,8 @@ test_expect_success 'git bisect run: args, stdout and stderr: "--bisect-start" a <--bisect-start> EOF_ARGS running './run.sh' '--bisect-start' - $HASH4 is the first bad commit - bisect found first bad commit + $HASH4 is the first 'bad' commit + bisect found first 'bad' commit EOF_OUT EOF_ERR " @@ -418,7 +418,7 @@ test_expect_success 'git bisect run: unable to verify on good' " fi EOF cat <<-'EOF' >expect && - unable to verify './fail.sh' on good revision + unable to verify './fail.sh' on 'good' revision EOF test_when_finished 'git bisect reset' && git bisect start && @@ -439,7 +439,7 @@ test_expect_success '"git bisect run" simple case' ' git bisect good $HASH1 && git bisect bad $HASH4 && git bisect run ./test_script.sh >my_bisect_log.txt && - grep "$HASH3 is the first bad commit" my_bisect_log.txt && + grep "$HASH3 is the first '\''bad'\'' commit" my_bisect_log.txt && git bisect reset ' @@ -461,7 +461,7 @@ test_expect_success '"git bisect run" with more complex "git bisect start"' ' EOF git bisect start $HASH4 $HASH1 && git bisect run ./test_script.sh >my_bisect_log.txt && - grep "$HASH4 is the first bad commit" my_bisect_log.txt && + grep "$HASH4 is the first '\''bad'\'' commit" my_bisect_log.txt && git bisect reset ' @@ -474,7 +474,7 @@ test_expect_success 'bisect run accepts exit code 126 as bad' ' git bisect good $HASH1 && git bisect bad $HASH4 && git bisect run ./test_script.sh >my_bisect_log.txt && - grep "$HASH3 is the first bad commit" my_bisect_log.txt + grep "$HASH3 is the first '\''bad'\'' commit" my_bisect_log.txt ' test_expect_success POSIXPERM 'bisect run fails with non-executable test script' ' @@ -485,7 +485,7 @@ test_expect_success POSIXPERM 'bisect run fails with non-executable test script' git bisect good $HASH1 && git bisect bad $HASH4 && test_must_fail git bisect run ./not-executable.sh >my_bisect_log.txt && - ! grep "is the first bad commit" my_bisect_log.txt + ! grep "is the first '\''bad'\'' commit" my_bisect_log.txt ' test_expect_success 'bisect run accepts exit code 127 as bad' ' @@ -497,7 +497,7 @@ test_expect_success 'bisect run accepts exit code 127 as bad' ' git bisect good $HASH1 && git bisect bad $HASH4 && git bisect run ./test_script.sh >my_bisect_log.txt && - grep "$HASH3 is the first bad commit" my_bisect_log.txt + grep "$HASH3 is the first '\''bad'\'' commit" my_bisect_log.txt ' test_expect_success 'bisect run fails with missing test script' ' @@ -507,7 +507,7 @@ test_expect_success 'bisect run fails with missing test script' ' git bisect good $HASH1 && git bisect bad $HASH4 && test_must_fail git bisect run ./does-not-exist.sh >my_bisect_log.txt && - ! grep "is the first bad commit" my_bisect_log.txt + ! grep "is the first '\''bad'\'' commit" my_bisect_log.txt ' # $HASH1 is good, $HASH5 is bad, we skip $HASH3 @@ -520,14 +520,14 @@ test_expect_success 'bisect skip: add line and then a new test' ' git bisect start $HASH5 $HASH1 && git bisect skip && git bisect good > my_bisect_log.txt && - grep "$HASH5 is the first bad commit" my_bisect_log.txt && + grep "$HASH5 is the first '\''bad'\'' commit" my_bisect_log.txt && git bisect log > log_to_replay.txt && git bisect reset ' test_expect_success 'bisect skip and bisect replay' ' git bisect replay log_to_replay.txt > my_bisect_log.txt && - grep "$HASH5 is the first bad commit" my_bisect_log.txt && + grep "$HASH5 is the first '\''bad'\'' commit" my_bisect_log.txt && git bisect reset ' @@ -541,7 +541,7 @@ test_expect_success 'bisect run & skip: cannot tell between 2' ' EOF git bisect start $HASH6 $HASH1 && test_expect_code 2 git bisect run ./test_script.sh >my_bisect_log.txt && - grep "first bad commit could be any of" my_bisect_log.txt && + grep "first '\''bad'\'' commit could be any of" my_bisect_log.txt && ! grep $HASH3 my_bisect_log.txt && ! grep $HASH6 my_bisect_log.txt && grep $HASH4 my_bisect_log.txt && @@ -560,7 +560,7 @@ test_expect_success 'bisect run & skip: find first bad' ' EOF git bisect start $HASH7 $HASH1 && git bisect run ./test_script.sh >my_bisect_log.txt && - grep "$HASH6 is the first bad commit" my_bisect_log.txt + grep "$HASH6 is the first '\''bad'\'' commit" my_bisect_log.txt ' test_expect_success 'bisect skip only one range' ' @@ -569,7 +569,7 @@ test_expect_success 'bisect skip only one range' ' git bisect skip $HASH1..$HASH5 && test "$HASH6" = "$(git rev-parse --verify HEAD)" && test_must_fail git bisect bad > my_bisect_log.txt && - grep "first bad commit could be any of" my_bisect_log.txt + grep "first '\''bad'\'' commit could be any of" my_bisect_log.txt ' test_expect_success 'bisect skip many ranges' ' @@ -578,7 +578,7 @@ test_expect_success 'bisect skip many ranges' ' git bisect skip $HASH2 $HASH2.. ..$HASH5 && test "$HASH6" = "$(git rev-parse --verify HEAD)" && test_must_fail git bisect bad > my_bisect_log.txt && - grep "first bad commit could be any of" my_bisect_log.txt + grep "first '\''bad'\'' commit could be any of" my_bisect_log.txt ' test_expect_success 'bisect starting with a detached HEAD' ' @@ -594,7 +594,7 @@ test_expect_success 'bisect starting with a detached HEAD' ' test_expect_success 'bisect errors out if bad and good are mistaken' ' git bisect reset && test_must_fail git bisect start $HASH2 $HASH4 2> rev_list_error && - test_grep "mistook good and bad" rev_list_error && + test_grep "mistook '\''good'\'' and '\''bad'\''" rev_list_error && git bisect reset ' @@ -610,7 +610,7 @@ test_expect_success 'bisect does not create a "bisect" branch' ' rev_hash6=$(git rev-parse --verify HEAD) && test "$rev_hash6" = "$HASH6" && git bisect good > my_bisect_log.txt && - grep "$HASH7 is the first bad commit" my_bisect_log.txt && + grep "$HASH7 is the first '\''bad'\'' commit" my_bisect_log.txt && git bisect reset && rev_hash6=$(git rev-parse --verify bisect) && test "$rev_hash6" = "$HASH6" && @@ -703,7 +703,7 @@ test_expect_success '"git bisect run --first-parent" simple case' ' git bisect good $HASH4 && git bisect bad $B_HASH && git bisect run ./test_script.sh >my_bisect_log.txt && - grep "$B_HASH is the first bad commit" my_bisect_log.txt && + grep "$B_HASH is the first '\''bad'\'' commit" my_bisect_log.txt && git bisect reset && test_path_is_missing .git/BISECT_FIRST_PARENT ' @@ -777,7 +777,7 @@ test_expect_success 'restricting bisection on one dir' ' para1=$(git rev-parse --verify HEAD) && test "$para1" = "$PARA_HASH1" && git bisect bad > my_bisect_log.txt && - grep "$PARA_HASH1 is the first bad commit" my_bisect_log.txt + grep "$PARA_HASH1 is the first '\''bad'\'' commit" my_bisect_log.txt ' test_expect_success 'restricting bisection on one dir and a file' ' @@ -795,7 +795,7 @@ test_expect_success 'restricting bisection on one dir and a file' ' para1=$(git rev-parse --verify HEAD) && test "$para1" = "$PARA_HASH1" && git bisect good > my_bisect_log.txt && - grep "$PARA_HASH4 is the first bad commit" my_bisect_log.txt + grep "$PARA_HASH4 is the first '\''bad'\'' commit" my_bisect_log.txt ' test_expect_success 'skipping away from skipped commit' ' @@ -826,7 +826,7 @@ test_expect_success 'test bisection on bare repo - --no-checkout specified' ' "test \$(git rev-list BISECT_HEAD ^$HASH2 --max-count=1 | wc -l) = 0" \ >../nocheckout.log ) && - grep "$HASH3 is the first bad commit" nocheckout.log + grep "$HASH3 is the first '\''bad'\'' commit" nocheckout.log ' @@ -841,7 +841,7 @@ test_expect_success 'test bisection on bare repo - --no-checkout defaulted' ' "test \$(git rev-list BISECT_HEAD ^$HASH2 --max-count=1 | wc -l) = 0" \ >../defaulted.log ) && - grep "$HASH3 is the first bad commit" defaulted.log + grep "$HASH3 is the first '\''bad'\'' commit" defaulted.log ' # @@ -969,7 +969,7 @@ cat > expected.bisect-log < into . git bisect good $HASH3 -# first bad commit: [$HASH4] Add <4: Ciao for now> into . +# first 'bad' commit: [$HASH4] Add <4: Ciao for now> into . EOF test_expect_success 'bisect log: successful result' ' @@ -988,8 +988,8 @@ git bisect start '$HASH4' '$HASH2' # skip: [$HASH3] Add <3: Another new day for git> into . git bisect skip $HASH3 # only skipped commits left to test -# possible first bad commit: [$HASH4] Add <4: Ciao for now> into . -# possible first bad commit: [$HASH3] Add <3: Another new day for git> into . +# possible first 'bad' commit: [$HASH4] Add <4: Ciao for now> into . +# possible first 'bad' commit: [$HASH3] Add <3: Another new day for git> into . EOF test_expect_success 'bisect log: only skip commits left' ' @@ -1031,21 +1031,21 @@ test_expect_success 'bisect start with one new and old' ' git bisect new $HASH4 && git bisect new && git bisect new >bisect_result && - grep "$HASH2 is the first new commit" bisect_result && + grep "$HASH2 is the first '\''new'\'' commit" bisect_result && git bisect log >log_to_replay.txt && git bisect reset ' test_expect_success 'bisect replay with old and new' ' git bisect replay log_to_replay.txt >bisect_result && - grep "$HASH2 is the first new commit" bisect_result && + grep "$HASH2 is the first '\''new'\'' commit" bisect_result && git bisect reset ' test_expect_success 'bisect replay with CRLF log' ' append_cr log_to_replay_crlf.txt && git bisect replay log_to_replay_crlf.txt >bisect_result_crlf && - grep "$HASH2 is the first new commit" bisect_result_crlf && + grep "$HASH2 is the first '\''new'\'' commit" bisect_result_crlf && git bisect reset ' @@ -1078,13 +1078,13 @@ test_expect_success 'bisect terms shows good/bad after start' ' test_expect_success 'bisect start with one term1 and term2' ' git bisect reset && git bisect start --term-old term2 --term-new term1 >bisect_result && - grep "status: waiting for both term2 and term1 commits" bisect_result && + test_grep "status: waiting for both '\''term2'\'' and '\''term1'\'' commits" bisect_result && git bisect term2 $HASH1 >bisect_result && - grep "status: waiting for term1 commit, 1 term2 commit known" bisect_result && + test_grep "status: waiting for '\''term1'\'' commit, 1 '\''term2'\'' commit known" bisect_result && git bisect term1 $HASH4 && git bisect term1 && git bisect term1 >bisect_result && - grep "$HASH2 is the first term1 commit" bisect_result && + test_grep "$HASH2 is the first '\''term1'\'' commit" bisect_result && git bisect log >log_to_replay.txt && git bisect reset ' @@ -1101,7 +1101,7 @@ test_expect_success 'bogus command does not start bisect' ' test_expect_success 'bisect replay with term1 and term2' ' git bisect replay log_to_replay.txt >bisect_result && - grep "$HASH2 is the first term1 commit" bisect_result && + grep "$HASH2 is the first '\''term1'\'' commit" bisect_result && git bisect reset ' @@ -1110,7 +1110,7 @@ test_expect_success 'bisect run term1 term2' ' git bisect start --term-new term1 --term-old term2 $HASH4 $HASH1 && git bisect term1 && git bisect run false >bisect_result && - grep "bisect found first term1 commit" bisect_result && + test_grep "bisect found first '\''term1'\'' commit" bisect_result && git bisect log >log_to_replay.txt && git bisect reset ' @@ -1120,7 +1120,7 @@ test_expect_success 'bisect start term1 term2' ' git bisect start --term-new term1 --term-old term2 $HASH4 $HASH1 && git bisect term1 && git bisect term1 >bisect_result && - grep "$HASH2 is the first term1 commit" bisect_result && + grep "$HASH2 is the first '\''term1'\'' commit" bisect_result && git bisect log >log_to_replay.txt && git bisect reset ' @@ -1154,8 +1154,8 @@ test_expect_success 'bisect start --term-* does store terms' ' git bisect start --term-bad=one --term-good=two && git bisect terms >actual && cat <<-EOF >expected && - Your current terms are two for the old state - and one for the new state. + Your current terms are '\''two'\'' for the old state + and '\''one'\'' for the new state. EOF test_cmp expected actual && git bisect terms --term-bad >actual && @@ -1212,7 +1212,7 @@ test_expect_success 'bisect handles annotated tags' ' git bisect good tag-one && git bisect bad tag-two >output && bad=$(git rev-parse --verify tag-two^{commit}) && - grep "$bad is the first bad commit" output + grep "$bad is the first '\''bad'\'' commit" output ' test_expect_success 'bisect run fails with exit code equals or greater than 128' ' @@ -1236,29 +1236,29 @@ test_expect_success 'bisect visualize with a filename with dash and space' ' test_expect_success 'bisect state output with multiple good commits' ' git bisect reset && git bisect start >output && - grep "waiting for both good and bad commits" output && + grep "waiting for both '\''good'\'' and '\''bad'\'' commits" output && git bisect log >output && - grep "waiting for both good and bad commits" output && + grep "waiting for both '\''good'\'' and '\''bad'\'' commits" output && git bisect good "$HASH1" >output && - grep "waiting for bad commit, 1 good commit known" output && + grep "waiting for '\''bad'\'' commit, 1 '\''good'\'' commit known" output && git bisect log >output && - grep "waiting for bad commit, 1 good commit known" output && + grep "waiting for '\''bad'\'' commit, 1 '\''good'\'' commit known" output && git bisect good "$HASH2" >output && - grep "waiting for bad commit, 2 good commits known" output && + grep "waiting for '\''bad'\'' commit, 2 '\''good'\'' commits known" output && git bisect log >output && - grep "waiting for bad commit, 2 good commits known" output + grep "waiting for '\''bad'\'' commit, 2 '\''good'\'' commits known" output ' test_expect_success 'bisect state output with bad commit' ' git bisect reset && git bisect start >output && - grep "waiting for both good and bad commits" output && + grep "waiting for both '\''good'\'' and '\''bad'\'' commits" output && git bisect log >output && - grep "waiting for both good and bad commits" output && + grep "waiting for both '\''good'\'' and '\''bad'\'' commits" output && git bisect bad "$HASH4" >output && - grep -F "waiting for good commit(s), bad commit known" output && + grep -F "waiting for '\''good'\'' commit(s), '\''bad'\'' commit known" output && git bisect log >output && - grep -F "waiting for good commit(s), bad commit known" output + grep -F "waiting for '\''good'\'' commit(s), '\''bad'\'' commit known" output ' test_expect_success 'verify correct error message' ' From cb559918253d636dc00afa8cb468a810c1f4347b Mon Sep 17 00:00:00 2001 From: Jonas Rebmann Date: Thu, 14 May 2026 11:07:06 +0200 Subject: [PATCH 20/29] rev-parse: use selected alternate terms to look up refs git rev-parse --bisect does not work when alternate bisect terms are used, simply listing no revisions at all. This is because a such bisect using e.g. "old" and "new" in place of "good" and "bad" will name refs "refs/bisect/old" (or new) accordingly so the hardcoded "refs/bisect/bad" (and good) yields no results in a bisect using alternate terms. Use the current bisect_terms to make rev-parse --bisect work in an alternate term bisect. Signed-off-by: Jonas Rebmann Signed-off-by: Junio C Hamano --- builtin/rev-parse.c | 15 +++++++++++++-- t/t1500-rev-parse.sh | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/builtin/rev-parse.c b/builtin/rev-parse.c index 01a62800e87938..e6bda8db6cd24e 100644 --- a/builtin/rev-parse.c +++ b/builtin/rev-parse.c @@ -10,6 +10,7 @@ #include "builtin.h" #include "abspath.h" +#include "bisect.h" #include "config.h" #include "commit.h" #include "environment.h" @@ -940,13 +941,23 @@ int cmd_rev_parse(int argc, continue; } if (!strcmp(arg, "--bisect")) { + char *prefix; + char *term_bad = NULL; + char *term_good = NULL; struct refs_for_each_ref_options opts = { 0 }; - opts.prefix = "refs/bisect/bad"; + read_bisect_terms(&term_bad, &term_good); + prefix = xstrfmt("refs/bisect/%s", term_bad); + opts.prefix = prefix; refs_for_each_ref_ext(get_main_ref_store(the_repository), show_reference, NULL, &opts); - opts.prefix = "refs/bisect/good"; + free(prefix); + prefix = xstrfmt("refs/bisect/%s", term_good); + opts.prefix = prefix; refs_for_each_ref_ext(get_main_ref_store(the_repository), anti_reference, NULL, &opts); + free(prefix); + free(term_good); + free(term_bad); continue; } if (opt_with_value(arg, "--branches", &arg)) { diff --git a/t/t1500-rev-parse.sh b/t/t1500-rev-parse.sh index 98c5a772bdad8d..38067d95f7f12b 100755 --- a/t/t1500-rev-parse.sh +++ b/t/t1500-rev-parse.sh @@ -337,6 +337,31 @@ test_expect_success 'rev-parse --bisect includes bad, excludes good' ' test_cmp expect actual ' +test_expect_success 'rev-parse --bisect works with alternate terms' ' + test_commit_bulk 6 && + + git bisect start --term-old=known --term-new=curious && + + git update-ref refs/bisect/curious-1 HEAD~1 && + git update-ref refs/bisect/bad HEAD~2 && + git update-ref refs/bisect/curious-3 HEAD~3 && + git update-ref refs/bisect/known-3 HEAD~3 && + git update-ref refs/bisect/curious-4 HEAD~4 && + git update-ref refs/bisect/good HEAD~4 && + + # Note: refs/bisect/bad and refs/bisect/goood should be ignored because this + # is a bisect with custom terms (known/curious) + cat >expect <<-EOF && + refs/bisect/curious-1 + refs/bisect/curious-3 + refs/bisect/curious-4 + ^refs/bisect/known-3 + EOF + + git rev-parse --symbolic-full-name --bisect >actual && + test_cmp expect actual +' + test_expect_success '--short= truncates to the actual hash length' ' git rev-parse HEAD >expect && git rev-parse --short=100 HEAD >actual && From d9982e829069afeae191254c5ad63d465ec7dade Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 15 May 2026 06:39:54 +0000 Subject: [PATCH 21/29] connected: close err_fd in promisor fast-path connected.h documents that err_fd is closed before check_connected() returns. It is, on three of four exit paths. The promisor-pack fast path added in 50033772d (connected: verify promisor-ness of partial clone, 2020-01-30) returns 0 without closing it. receive-pack uses err_fd as the write end of an async sideband muxer's pipe, and the muxer thread waits for EOF. The same omission has caused deadlocks there twice before: 49ecfa13f (receive-pack: close sideband fd on early pack errors, 2013-04-19) and 6cdad1f13 (receive-pack: fix deadlock when we cannot create tmpdir, 2017-03-07). Signed-off-by: Ethan Dickson Signed-off-by: Junio C Hamano --- connected.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/connected.c b/connected.c index 79403108dd8f57..6d7549a0f7ee58 100644 --- a/connected.c +++ b/connected.c @@ -91,6 +91,8 @@ int check_connected(oid_iterate_fn fn, void *cb_data, ; } while ((oid = fn(cb_data)) != NULL); free(new_pack); + if (opt->err_fd) + close(opt->err_fd); return 0; } From 088d9a1716c2b8cd31bf504371fd677fde1dddb1 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Fri, 15 May 2026 10:42:26 +0200 Subject: [PATCH 22/29] generate-configlist: collapse depfile for older Ninja The tools/generate-configlist.sh script generates two files: * config-list.h * config-list.h.d The former is included by the source code and the latter defines on which files the former depends. The contents of `config-list.h.d` consists of two sections: config-list.h: Documentation/config.adoc config-list.h: Documentation/git-config.adoc config-list.h: Documentation/config/add.adoc config-list.h: Documentation/config/advice.adoc config-list.h: Documentation/config/alias.adoc config-list.h: Documentation/config/am.adoc config-list.h: Documentation/config/apply.adoc ... This first section actually defines on which individual files `config-list.h` depends and thus needs to be rebuild if one of those changes. And the second section contains content like: Documentation/config.adoc: Documentation/git-config.adoc: Documentation/config/add.adoc: Documentation/config/advice.adoc: Documentation/config/alias.adoc: Documentation/config/am.adoc: Documentation/config/apply.adoc: ... These rules exist to ensure Make won't fail with the following error if one of the .adoc files is renamed or removed: make: *** No rule to make target 'Documentation/config.adoc', needed by 'config-list.h'. With the no-op targets defined in `config-list.h.d`, Make knows there's no work to be done to generate these files, so it doesn't error out if it doesn't exist. For the Makefile build system this works great. And since ebeea3c471 (build: regenerate config-list.h when Documentation changes, 2026-02-24) this script is also called from the Meson build system. Nevertheless, on AlmaLinux 8 the following build failure is seen: ninja: error: dependency cycle: config-list.h -> config-list.h This version of this distro uses Ninja 1.8.2 and it seems to have some issues with the format of the `config-list.h.d` file. Ninja versions before 1.10.0 do not reset the depfile parser state on newlines. This causes issues when the depfile has one dependency per line, like we have in `config-list.h.d`: config-list.h: Documentation/config.adoc config-list.h: Documentation/config/add.adoc The parser only recognizes the first "config-list.h:" as a target. On subsequent lines it is still in dependency-parsing mode, so the repeated output name is recorded as an input. This causes the error mentioned above. The bug in Ninja is fixed in 1.10, with commit ninja-build/ninja@1daa7470ab7e (depfile_parser: remove restriction on multiple outputs, 2019-11-20). To be compatible with older versions of Ninja, collapse the dependencies for `config-list.h` into a single line like: config-list.h: Documentation/config.adoc Documentation/config/add.adoc ... This works around the bug in older versions of Ninja, and is fully compatible Make and with more recent versions of Ninja. And while the no-op targets are not needed for Ninja, they also don't do any harm. Helped-by: Patrick Steinhardt Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- tools/generate-configlist.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/generate-configlist.sh b/tools/generate-configlist.sh index e28054f9e0e9ba..d1d2ba4bb7fef9 100755 --- a/tools/generate-configlist.sh +++ b/tools/generate-configlist.sh @@ -42,9 +42,12 @@ if test -n "$DEPFILE" then QUOTED_OUTPUT="$(printf '%s\n' "$OUTPUT" | sed 's,[&/\],\\&,g')" { + printf '%s' "$QUOTED_OUTPUT: " printf '%s\n' "$SOURCE_DIR"/Documentation/*config.adoc \ "$SOURCE_DIR"/Documentation/config/*.adoc | - sed -e 's/[# ]/\\&/g' -e "s/^/$QUOTED_OUTPUT: /" + sed -e 's/[# ]/\\&/g' | + tr '\n' ' ' + printf '\n' printf '%s:\n' "$SOURCE_DIR"/Documentation/*config.adoc \ "$SOURCE_DIR"/Documentation/config/*.adoc | sed -e 's/[# ]/\\&/g' From b80b462d7cf2225c25959ce89727fdd3334c0afc Mon Sep 17 00:00:00 2001 From: Kristofer Karlsson Date: Sat, 16 May 2026 15:59:40 +0000 Subject: [PATCH 23/29] commit-reach: use object flags for tips_reachable_from_bases() tips_reachable_from_bases() walks the commit graph from a set of base commits to find which tip commits are reachable. The inner loop does a linear scan over the tips array to check whether each visited commit is a tip, making the overall cost O(C * T) where C is commits walked and T is the number of tips. Use the RESULT object flag to mark tip commits, replacing the linear scan with a single flag test per visited commit. This reduces the per-commit tip check from O(T) to O(1) and the overall cost from O(C * T) to O(C + T). When multiple refs point to the same commit, the shared object gets the flag once, so all duplicates are handled automatically. The early-termination advancement loop checks the flag on the sorted commits array directly, which naturally handles duplicates since the flag is on the shared commit object. This also removes the index field from struct commit_and_index, since the indirection through the original tips array is no longer needed. This function is called by `git for-each-ref --merged` and `git branch/tag --contains/--no-contains` via reach_filter() in ref-filter.c. Benchmark on a merge-heavy monorepo (2.3M commits, 10,000 refs): Command Before After Speedup for-each-ref --merged HEAD 6.57s 1.59s 4.1x for-each-ref --no-merged HEAD 6.67s 1.66s 4.0x branch --merged HEAD 0.68s 0.61s 10% branch --no-merged HEAD 0.65s 0.61s 8% tag --merged HEAD 0.12s 0.12s - On linux.git with 10,000 synthetic branches at the root commit (worst case for the DFS walk): Command Before After Speedup for-each-ref --merged HEAD 1.35s 0.35s 3.9x for-each-ref --no-merged HEAD 1.82s 0.31s 5.9x The large speedup for for-each-ref is because it checks all 10,000 refs as tips, making the O(T) inner loop expensive. The branch subcommand only checks local branches (fewer tips), so the improvement is smaller. Signed-off-by: Kristofer Karlsson Signed-off-by: Junio C Hamano --- commit-reach.c | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/commit-reach.c b/commit-reach.c index d3a9b3ed6fe561..82614d24096304 100644 --- a/commit-reach.c +++ b/commit-reach.c @@ -1125,7 +1125,6 @@ void ahead_behind(struct repository *r, struct commit_and_index { struct commit *commit; - unsigned int index; timestamp_t generation; }; @@ -1165,7 +1164,6 @@ void tips_reachable_from_bases(struct repository *r, for (size_t i = 0; i < tips_nr; i++) { commits[i].commit = tips[i]; - commits[i].index = i; commits[i].generation = commit_graph_generation(tips[i]); } @@ -1173,6 +1171,9 @@ void tips_reachable_from_bases(struct repository *r, QSORT(commits, tips_nr, compare_commit_and_index_by_generation); min_generation = commits[0].generation; + for (size_t i = 0; i < tips_nr; i++) + commits[i].commit->object.flags |= RESULT; + while (bases) { repo_parse_commit(r, bases->item); commit_list_insert(bases->item, &stack); @@ -1183,20 +1184,16 @@ void tips_reachable_from_bases(struct repository *r, int explored_all_parents = 1; struct commit_list *p; struct commit *c = stack->item; - timestamp_t c_gen = commit_graph_generation(c); /* Does it match any of our tips? */ - for (size_t j = min_generation_index; j < tips_nr; j++) { - if (c_gen < commits[j].generation) - break; - - if (commits[j].commit == c) { - tips[commits[j].index]->object.flags |= mark; + { + if (c->object.flags & RESULT) { + c->object.flags |= mark; - if (j == min_generation_index) { - unsigned int k = j + 1; + if (commits[min_generation_index].commit->object.flags & mark) { + unsigned int k = min_generation_index + 1; while (k < tips_nr && - (tips[commits[k].index]->object.flags & mark)) + (commits[k].commit->object.flags & mark)) k++; /* Terminate early if all found. */ @@ -1232,6 +1229,8 @@ void tips_reachable_from_bases(struct repository *r, } done: + for (size_t i = 0; i < tips_nr; i++) + commits[i].commit->object.flags &= ~RESULT; free(commits); repo_clear_commit_marks(r, SEEN); commit_list_free(stack); From a30f132bcb62bc44053b1dba0940c2d4041c797a Mon Sep 17 00:00:00 2001 From: Kristofer Karlsson Date: Sat, 16 May 2026 15:59:41 +0000 Subject: [PATCH 24/29] t6600: add tests for duplicate tips in tips_reachable_from_bases() When multiple refs point to the same commit, the reachability check must handle them correctly. Add three tests: - duplicate tips, all reachable - duplicate tips, none reachable - duplicate tips at the minimum generation (exercises the early-termination advancement logic) Suggested-by: Derrick Stolee Signed-off-by: Kristofer Karlsson Signed-off-by: Junio C Hamano --- t/t6600-test-reach.sh | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/t/t6600-test-reach.sh b/t/t6600-test-reach.sh index dc0421ed2f3726..9486002866fb13 100755 --- a/t/t6600-test-reach.sh +++ b/t/t6600-test-reach.sh @@ -612,6 +612,51 @@ test_expect_success 'for-each-ref merged:none' ' --format="%(refname)" --stdin ' +test_expect_success 'for-each-ref merged:duplicate, all reachable' ' + git branch dup-a commit-3-3 && + git branch dup-b commit-3-3 && + cat >input <<-\EOF && + refs/heads/commit-1-1 + refs/heads/dup-a + refs/heads/dup-b + EOF + cat >expect <<-\EOF && + refs/heads/commit-1-1 + refs/heads/dup-a + refs/heads/dup-b + EOF + run_all_modes git for-each-ref --merged=commit-5-5 \ + --format="%(refname)" --stdin +' + +test_expect_success 'for-each-ref merged:duplicate, none reachable' ' + cat >input <<-\EOF && + refs/heads/dup-a + refs/heads/dup-b + refs/heads/commit-9-9 + EOF + >expect && + run_all_modes git for-each-ref --merged=commit-2-2 \ + --format="%(refname)" --stdin +' + +test_expect_success 'for-each-ref merged:duplicate at min generation' ' + git branch dup-c commit-1-1 && + git branch dup-d commit-1-1 && + cat >input <<-\EOF && + refs/heads/dup-c + refs/heads/dup-d + refs/heads/commit-5-5 + EOF + cat >expect <<-\EOF && + refs/heads/commit-5-5 + refs/heads/dup-c + refs/heads/dup-d + EOF + run_all_modes git for-each-ref --merged=commit-5-5 \ + --format="%(refname)" --stdin +' + # For get_branch_base_for_tip, we only care about # first-parent history. Here is the test graph with # second parents removed: From 3d8e4004c604b206d70240a087816907cce70a08 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Tue, 19 May 2026 02:15:34 -0400 Subject: [PATCH 25/29] commit: fall back to full read when maybe_tree is NULL When we load a commit object from the commit graph (rather than reading the object contents), we don't fill in its "maybe_tree" entry, but rather wait to lazy-load it. This goes back to 7b8a21dba1 (commit-graph: lazy-load trees for commits, 2018-04-06), and saves the work of instantiating tree objects that nobody cares about. But it creates a data dependency: now the commit struct depends on the graph file to do that lazy load. This is a problem if we close the graph file; now we have a commit struct that claims to be parsed but is missing some of its data. It's rare for this to be a problem in practice, because we don't tend to close the graph files at all, and if we do we don't tend to look at their commits afterward. But there is one case that is easy to trigger: git-clone's --dissociate option will close the object database before running the dissociate repack, and then afterwards still try to check out the working tree. This will yield an error like: fatal: unable to parse commit b29edc0babef41810f7b1c9ee1d74058f22e4080 warning: Clone succeeded, but checkout failed. What happens is that we expect repo_get_commit_tree() to lazy-load the tree, but commit_graph_position() returns COMMIT_NOT_FROM_GRAPH because the position slab has gone away (and even if it hadn't, we don't have the graph file itself available anymore). Let's try harder to find the tree in repo_get_commit_tree() by actually opening the commit object and parsing the tree line. This is extra work, but no more than we'd have to go to if we hadn't done the initial graph load in the first place. It does mean that a corrupt commit (e.g., one that points to a non-tree object for which we couldn't instantiate a struct) will repeatedly load the object from disk, once for each call to repo_get_commit_tree(). But such corruptions should be rare, and we don't tend to perform such calls repeatedly (usually we'd abort the operation upon seeing corruption). It also means we have to reimplement a bit of the commit parsing. We can't just use parse_commit_buffer() here, because it expects an unparsed struct and wants to load everything, including parent links. But we don't know if the parent list has been munged during traversal, so it's not safe for us to touch it. Fortunately, it's quite easy to load just the tree, as it is always the first line of the commit object. There is an alternative approach which I considered but rejected: "complete" each graph-loaded commit struct when we close the graph file by looking up and instantiating their trees at close time. This is the most elegant solution in some sense, as it resolves the data dependency at the moment it goes away. And it avoids ever opening the commit objects at all, which can be more efficient. But not always. The resolving effort scales with the number of graph-loaded commits, even though we may only later access one or a few. So the tradeoff depends on how many were loaded in total versus how many will be later accessed. And in most cases, we will not access any at all! Programs which close the object database before exiting will then do a bunch of work for no reason. This could be mitigated by requiring a separate function to resolve the graph structs before closing the file. But now each close call has to consider whether to call that resolving function. So we'd fix this case in git-clone, but we don't know what other cases (if any) are lurking. Moreover, this strategy does nothing if we lose access to the graph file unexpectedly (e.g., due to a system error). I'm not entirely sure this is possible now (we mmap it, so I'd guess any error would turn into SIGBUS anyway). But it feels like making the lazy-load more robust (which this patch does) is the best way to handle a wide variety of possible failure modes. Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- commit.c | 33 ++++++++++++++++++++++++++++++++- t/t5604-clone-reference.sh | 23 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/commit.c b/commit.c index 80d8d078757dbc..dad2f81ce0c6eb 100644 --- a/commit.c +++ b/commit.c @@ -434,6 +434,27 @@ static inline void set_commit_tree(struct commit *c, struct tree *t) c->maybe_tree = t; } +static void load_tree_from_commit_contents(struct repository *r, struct commit *commit) +{ + enum object_type type; + unsigned long size; + char *buf; + const char *p; + struct object_id tree_oid; + + buf = odb_read_object(r->objects, &commit->object.oid, &type, &size); + if (!buf) + return; + + if (type == OBJ_COMMIT && + skip_prefix(buf, "tree ", &p) && + !parse_oid_hex_algop(p, &tree_oid, &p, r->hash_algo) && + *p == '\n') + set_commit_tree(commit, lookup_tree(r, &tree_oid)); + + free(buf); +} + struct tree *repo_get_commit_tree(struct repository *r, const struct commit *commit) { @@ -443,7 +464,17 @@ struct tree *repo_get_commit_tree(struct repository *r, if (commit_graph_position(commit) != COMMIT_NOT_FROM_GRAPH) return get_commit_tree_in_graph(r, commit); - return NULL; + /* + * This is either a corrupt commit, or one which we partially loaded + * from a graph file but then subsequently threw away the graph data. + * + * Optimistically assume it's the latter and try to reload from + * scratch. This gives a performance penalty if it really is a corrupt + * commit, but presumably that happens rarely (and only once per + * process). + */ + load_tree_from_commit_contents(r, (struct commit *)commit); + return commit->maybe_tree; } struct object_id *get_commit_tree_oid(const struct commit *commit) diff --git a/t/t5604-clone-reference.sh b/t/t5604-clone-reference.sh index 470bfb610ce075..c232ab8c15a59d 100755 --- a/t/t5604-clone-reference.sh +++ b/t/t5604-clone-reference.sh @@ -360,4 +360,27 @@ test_expect_success SYMLINKS 'clone repo with symlinked objects directory' ' grep "is a symlink, refusing to clone with --local" err ' +test_expect_success 'dissociate from repo with commit graph' ' + git init orig && + # We are trying to make sure the dissociated repo can + # find the tree of the tip commit, so the test could still + # serve its purpose with an empty tree. But having actual + # content future-proofs us against any kind of internal + # empty-tree optimizations. + echo content >orig/file && + git -C orig add . && + git -C orig commit -m foo && + + # We will use graph.git as our "local" source to dissociate + # from. + git clone --bare orig graph.git && + git -C graph.git commit-graph write --reachable && + + # And then finally clone orig, using graph.git to get our objects. This + # must be non-bare so that we perform the checkout step, which will + # need to access the tree of HEAD, which we will have originally loaded + # via the commit graph. + git clone --no-local --reference graph.git --dissociate orig clone +' + test_done From f4d7eb3d1c5e5d5fcbfeebd93e214bba35186868 Mon Sep 17 00:00:00 2001 From: Siddh Raman Pant Date: Thu, 21 May 2026 10:58:41 +0530 Subject: [PATCH 26/29] Documentation/git-range-diff: add missing notes options in synopsis git-range-diff supports note options which are also mentioned later in the help, but they are missing from the synopsis. Let's fix that. Signed-off-by: Siddh Raman Pant Signed-off-by: Junio C Hamano --- Documentation/git-range-diff.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/git-range-diff.adoc b/Documentation/git-range-diff.adoc index b5e85d37f1bee7..af1a0bf5f3a57e 100644 --- a/Documentation/git-range-diff.adoc +++ b/Documentation/git-range-diff.adoc @@ -11,7 +11,7 @@ SYNOPSIS 'git range-diff' [--color=[]] [--no-color] [] [--no-dual-color] [--creation-factor=] [--left-only | --right-only] [--diff-merges=] - [--remerge-diff] + [--remerge-diff] [--no-notes | --notes[=]] ( | ... | ) [[--] ...] From c9d708b7fce6f910d77f5cb499796712f3bdfd04 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Thu, 21 May 2026 10:59:24 +0200 Subject: [PATCH 27/29] gitlab-ci: upgrade macOS runners We're currently using M1-based runners for our macOS jobs. GitLab has since introduced a new M2 Pro-based runner type that is available for all GitLab tiers [1], which upgrades from 4 to 6 cores and from 8 to 16 GB RAM. Upgrade to this new runner type, which results in some nice speedups: - osx-clang goes from 26 minutes to 16 minutes. - osx-meson goes from 19 minutes to 13 minutes. - osx-reftable goes from 23 minutes to 14 mintues. [1]: https://docs.gitlab.com/ci/runners/hosted_runners/macos/ Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 83ec786c5a49d0..1c6777acf37977 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -79,7 +79,7 @@ test:osx: stage: test needs: [ ] tags: - - saas-macos-medium-m1 + - saas-macos-large-m2pro variables: TEST_OUTPUT_DIRECTORY: "/Volumes/RAMDisk" before_script: From 62319b49bbe7e52df2cf90d8c4a5121112135784 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Thu, 21 May 2026 10:59:25 +0200 Subject: [PATCH 28/29] gitlab-ci: update macOS image The GitLab CI jobs for macOS are all using the macOS 15 images. While these images are not deprecated yet, there is a new image for macOS 26 generally available by now [1]. Switch two of our jobs to use the new image. The third job still continues to use the old image. This ensures broader test coverage until this old image gets deprecated. [1]: https://docs.gitlab.com/ci/runners/hosted_runners/macos/ Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1c6777acf37977..e0b9a0d82b684f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -104,10 +104,10 @@ test:osx: image: macos-15-xcode-16 CC: clang - jobname: osx-reftable - image: macos-15-xcode-16 + image: macos-26-xcode-26 CC: clang - jobname: osx-meson - image: macos-15-xcode-16 + image: macos-26-xcode-26 CC: clang artifacts: paths: From 1666c1265231b0bc5f613fbbf3f0a9896cdef76e Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Sun, 31 May 2026 10:00:11 +0900 Subject: [PATCH 29/29] The 10th batch Signed-off-by: Junio C Hamano --- Documentation/RelNotes/2.55.0.adoc | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Documentation/RelNotes/2.55.0.adoc b/Documentation/RelNotes/2.55.0.adoc index 36e49d73803abf..2d3871cf34712e 100644 --- a/Documentation/RelNotes/2.55.0.adoc +++ b/Documentation/RelNotes/2.55.0.adoc @@ -39,6 +39,15 @@ UI, Workflows & Features * The command line parser for "git diff" learned a few options take only non-negative integers. + * The graph output from commands like "git log --graph" can now be + limited to a specified number of lanes, preventing overly wide output + in repositories with many branches. + + * The fsmonitor daemon has been implemented for Linux. + + * "git cat-file --batch" learns an in-line command "mailmap" + that lets the user toggle use of mailmap. + Performance, Internal Implementation, Development Support etc. -------------------------------------------------------------- @@ -98,6 +107,14 @@ Performance, Internal Implementation, Development Support etc. * Many uses of the_repository has been updated to use a more appropriate struct repository instance in setup.c codepath. + * Revision traversal optimization. + + * Build update. + + * The logic to lazy-load trees from the commit-graph has been made + more robust by falling back to reading the commit object when + the commit-graph is no longer available. + Fixes since v2.54 ----------------- @@ -206,6 +223,13 @@ Fixes since v2.54 (i.e., "https://example.com" not "https://example.com/"). (merge b92387cd55 jk/dumb-http-alternate-fix later to maint). + * "git bisect" now uses the selected terms (e.g., old/new) more + consistently in its output. + (merge cb55991825 jr/bisect-custom-terms-in-output later to maint). + + * Update GitLab CI jobs that exercise macOS. + (merge 62319b49bb ps/gitlab-ci-macOS-improvements later to maint). + * Other code cleanup, docfix, build fix, etc. (merge 80f4b802e9 ja/doc-difftool-synopsis-style later to maint). (merge b96490241e jc/doc-timestamps-in-stat later to maint). @@ -224,3 +248,6 @@ Fixes since v2.54 (merge 499f9048e0 ps/t3903-cover-stash-include-untracked later to maint). (merge b56ab270aa jk/sq-dequote-cleanup later to maint). (merge 29d9fdcf10 rs/use-builtin-add-overflow-explicitly-on-clang later to maint). + (merge d9982e8290 ed/check-connected-close-err-fd-2.53 later to maint). + (merge 1740cc35d0 ed/check-connected-close-err-fd later to maint). + (merge f4d7eb3d1c sp/doc-range-diff-takes-notes later to maint).