Git development
 help / color / mirror / Atom feed
* [PATCH v4 3/8] t6099, t6600: add side-exhaustion regression tests
From: Kristofer Karlsson via GitGitGadget @ 2026-06-28 12:25 UTC (permalink / raw)
  To: git; +Cc: Derrick Stolee, Elijah Newren, Kristofer Karlsson,
	Kristofer Karlsson
In-Reply-To: <pull.2149.v4.git.1782649547.gitgitgadget@gmail.com>

From: Kristofer Karlsson <krka@spotify.com>

Add t6099 to test the case where multiple merge-base candidates exist
and one is an ancestor of another. This exercises the side-exhaustion
optimization in paint_down_to_common together with the
remove_redundant safety net in get_merge_bases_many_0.

Add a mixed finite/INFINITY test to t6600 where one tip is outside
the commit-graph (INFINITY generation) and the other is inside.
This exercises the region transition: the walk starts in the
INFINITY region where side-exhaustion is disabled, then crosses
into the finite region where it can fire.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
---
 t/meson.build                         |  1 +
 t/t6099-merge-base-side-exhaustion.sh | 82 +++++++++++++++++++++++++++
 t/t6600-test-reach.sh                 | 25 ++++++++
 3 files changed, 108 insertions(+)
 create mode 100755 t/t6099-merge-base-side-exhaustion.sh

diff --git a/t/meson.build b/t/meson.build
index 3219264fe7..ee6ebdffb9 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -786,6 +786,7 @@ integration_tests = [
   't6041-bisect-submodule.sh',
   't6050-replace.sh',
   't6060-merge-index.sh',
+  't6099-merge-base-side-exhaustion.sh',
   't6100-rev-list-in-order.sh',
   't6101-rev-parse-parents.sh',
   't6102-rev-list-unexpected-objects.sh',
diff --git a/t/t6099-merge-base-side-exhaustion.sh b/t/t6099-merge-base-side-exhaustion.sh
new file mode 100755
index 0000000000..4f1e0d50ef
--- /dev/null
+++ b/t/t6099-merge-base-side-exhaustion.sh
@@ -0,0 +1,82 @@
+#!/bin/sh
+
+test_description='merge-base with ancestor among merge-base candidates
+
+Test that merge-base --all correctly handles cases where
+multiple merge-base candidates exist and one is an ancestor
+of another. The side-exhaustion optimization in
+paint_down_to_common may exit before STALE propagation
+removes the ancestor, but remove_redundant catches it.
+
+Graph shape (parents are below children):
+
+   A ----------- X
+   |\           /|
+   | B---------/ |
+   | |           |
+   e2 \         f2
+   |   |         |
+   e1 d1        f1
+    \  |        /
+     \ |       /
+      \|      /
+       C
+
+A and X are the two tips.
+B and C are both reachable from A and X.
+B reaches C through d1.
+Only B should appear in merge-base --all output.
+'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+TEST_PASSES_SANITIZE_LEAK=true
+. ./test-lib.sh
+
+test_expect_success 'setup ancestor merge-base candidate' '
+	test_commit C &&
+
+	git checkout -b d-chain HEAD &&
+	test_commit d1 &&
+	test_commit B &&
+
+	git checkout -b e-path C &&
+	test_commit e1 &&
+	test_commit e2 &&
+
+	git checkout -b f-path C &&
+	test_commit f1 &&
+	test_commit f2 &&
+
+	git checkout -b branch-A e-path &&
+	test_merge A B &&
+
+	git checkout -b branch-X f-path &&
+	test_merge X B &&
+
+	git commit-graph write --reachable
+'
+
+test_expect_success 'merge-base --all excludes ancestor candidate' '
+	git rev-parse B >expected &&
+	git merge-base --all A X >actual &&
+	test_cmp expected actual
+'
+
+test_expect_success 'merge-base (single) finds shallowest' '
+	git rev-parse B >expected &&
+	git merge-base A X >actual &&
+	test_cmp expected actual
+'
+
+# Without commit-graph: generation numbers are INFINITY,
+# side-exhaustion optimization does not fire.
+test_expect_success 'merge-base --all without commit-graph' '
+	rm -f .git/objects/info/commit-graph &&
+	git rev-parse B >expected &&
+	git merge-base --all A X >actual &&
+	test_cmp expected actual
+'
+
+test_done
diff --git a/t/t6600-test-reach.sh b/t/t6600-test-reach.sh
index c2e091aad1..4b771b4c58 100755
--- a/t/t6600-test-reach.sh
+++ b/t/t6600-test-reach.sh
@@ -294,6 +294,31 @@ test_expect_success 'get_merge_bases_many:infinity-both-sides' '
 	test_all_modes get_merge_bases_many
 '
 
+test_expect_success 'setup mixed finite/INFINITY topology' '
+	# Create a commit outside all saved commit-graph files so it always
+	# has INFINITY generation, while its parent (ps-X) is in the graph
+	# with a finite generation. Use the ps-* orphan topology so we do
+	# not pollute the grid-based rev-list tests.
+	git checkout ps-X &&
+	test_env GIT_TEST_COMMIT_GRAPH= test_commit pm-INF
+'
+
+test_expect_success 'get_merge_bases_many:mixed-finite-infinity' '
+	# One tip (pm-INF) is outside the commit-graph with INFINITY
+	# generation; the other (ps-B) is in the graph with finite
+	# generation. The walk starts in the INFINITY region and crosses
+	# into the finite region where side-exhaustion can fire.
+	cat >input <<-\EOF &&
+	A:pm-INF
+	X:ps-B
+	EOF
+	{
+		echo "get_merge_bases_many(A,X):" &&
+		git rev-parse ps-X
+	} >expect &&
+	test_all_modes get_merge_bases_many
+'
+
 test_expect_success 'reduce_heads' '
 	cat >input <<-\EOF &&
 	X:commit-1-10
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v4 2/8] t6600: add test cases for side-exhaustion edge cases
From: Elijah Newren via GitGitGadget @ 2026-06-28 12:25 UTC (permalink / raw)
  To: git; +Cc: Derrick Stolee, Elijah Newren, Kristofer Karlsson, Elijah Newren
In-Reply-To: <pull.2149.v4.git.1782649547.gitgitgadget@gmail.com>

From: Elijah Newren <newren@gmail.com>

Add test cases to t6600-test-reach.sh that exercise edge cases in the
side-exhaustion optimization for paint_down_to_common():

 - in_merge_bases_many:self: commit is both A and one of the X inputs
 - get_merge_bases_many:duplicate-twos: duplicate entries in X list
 - get_merge_bases_many:pending-stale: STALE transition on an
   already-painted commit (ps-* diamond topology)
 - get_merge_bases_many:infinity-both-sides: both tips outside the
   commit-graph with non-monotonic dates (pi-* topology)

Signed-off-by: Elijah Newren <newren@gmail.com>
Signed-off-by: Kristofer Karlsson <krka@spotify.com>
---
 t/t6600-test-reach.sh | 111 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 111 insertions(+)

diff --git a/t/t6600-test-reach.sh b/t/t6600-test-reach.sh
index b5b314e570..c2e091aad1 100755
--- a/t/t6600-test-reach.sh
+++ b/t/t6600-test-reach.sh
@@ -49,6 +49,62 @@ test_expect_success 'setup' '
 			git tag -a -m "$x-$i" tag-$x-$i commit-$x-$i || return 1
 		done
 	done &&
+
+	# Build a small side topology to exercise the (PARENT1|PARENT2) ->
+	# (PARENT1|PARENT2|STALE) transition in paint_down_to_common(); the
+	# 10x10 grid above does not exercise it because no merge-base candidate
+	# there is a descendant of another, so STALE never reaches a
+	# still-pending candidate.
+	#
+	#       ps-X
+	#       /|\
+	#      / | \
+	#   ps-Z ps-B ps-W
+	#     |  / \  |
+	#     | /   \ |
+	#     |/     \|
+	#   ps-T1   ps-T2
+	#
+	# where ps-T1=merge(ps-Z,ps-B), ps-T2=merge(ps-W,ps-B), so
+	# merge-base(ps-T1,ps-T2) = ps-B. During the walk, ps-X transitions
+	# to (PARENT1|PARENT2) via ps-Z and ps-W before ps-B is dequeued;
+	# then the STALE-walk from ps-B transitions ps-X to
+	# (PARENT1|PARENT2|STALE).
+	git checkout --orphan ps-orphan &&
+	test_commit ps-X &&
+	git checkout -b ps-B-br ps-X && test_commit ps-B &&
+	git checkout -b ps-Z-br ps-X && test_commit ps-Z &&
+	git checkout -b ps-W-br ps-X && test_commit ps-W &&
+	git checkout -b ps-T1 ps-Z &&
+	git merge --no-ff -m ps-T1 ps-B &&
+	git checkout -b ps-T2 ps-W &&
+	git merge --no-ff -m ps-T2 ps-B &&
+
+	# Build a side topology that lives entirely outside the half
+	# commit-graph and has non-monotonic commit dates, to exercise the
+	# INFINITY-gate in paint_down_to_common. With both tips outside
+	# the graph, generation is INFINITY and the queue falls back to
+	# commit-date order, which here is non-monotonic.
+	#
+	#   pi-X (date 500, PARENT1 tip) --> pi-P, pi-D
+	#   pi-D (date 480) --> pi-C
+	#   pi-C (date 200) --> pi-B
+	#   pi-B (date 100, PARENT2 tip) --> pi-P
+	#   pi-P (date 450, root)
+	#
+	# merge-base(pi-X, pi-B) = pi-B (it is an ancestor of pi-X and is
+	# itself one of the queried tips).
+	git checkout --orphan pi-orphan &&
+	test_commit --date "@450 +0000" pi-P &&
+	test_commit --date "@100 +0000" pi-B &&
+	test_commit --date "@200 +0000" pi-C &&
+	test_commit --date "@480 +0000" pi-D &&
+	GIT_AUTHOR_DATE="@500 +0000" GIT_COMMITTER_DATE="@500 +0000" \
+		git commit-tree -p pi-D -p pi-P -m pi-X pi-D^{tree} >pi-X-oid &&
+	pi_x="$(cat pi-X-oid)" &&
+	git branch -f pi-X-br "$pi_x" &&
+	git tag pi-X "$pi_x" &&
+
 	git commit-graph write --reachable &&
 	mv .git/objects/info/commit-graph commit-graph-full &&
 	chmod u+w commit-graph-full &&
@@ -146,6 +202,16 @@ test_expect_success 'in_merge_bases_many:miss-heuristic' '
 	test_all_modes in_merge_bases_many
 '
 
+test_expect_success 'in_merge_bases_many:self' '
+	cat >input <<-\EOF &&
+	A:commit-6-8
+	X:commit-5-9
+	X:commit-6-8
+	EOF
+	echo "in_merge_bases_many(A,X):1" >expect &&
+	test_all_modes in_merge_bases_many
+'
+
 test_expect_success 'is_descendant_of:hit' '
 	cat >input <<-\EOF &&
 	A:commit-5-7
@@ -183,6 +249,51 @@ test_expect_success 'get_merge_bases_many' '
 	test_all_modes get_merge_bases_many
 '
 
+test_expect_success 'get_merge_bases_many:duplicate-twos' '
+	cat >input <<-\EOF &&
+	A:commit-5-7
+	X:commit-4-8
+	X:commit-4-8
+	X:commit-6-6
+	X:commit-6-6
+	X:commit-8-3
+	EOF
+	{
+		echo "get_merge_bases_many(A,X):" &&
+		git rev-parse commit-5-6 \
+			      commit-4-7 | sort
+	} >expect &&
+	test_all_modes get_merge_bases_many
+'
+
+test_expect_success 'get_merge_bases_many:pending-stale' '
+	# Exercises the (PARENT1|PARENT2) -> (...|STALE) transition path in
+	# paint_down_to_common(). See the topology comment in the setup test.
+	cat >input <<-\EOF &&
+	A:ps-T1
+	X:ps-T2
+	EOF
+	{
+		echo "get_merge_bases_many(A,X):" &&
+		git rev-parse ps-B
+	} >expect &&
+	test_all_modes get_merge_bases_many
+'
+
+test_expect_success 'get_merge_bases_many:infinity-both-sides' '
+	# Exercises the push-time INFINITY-gate in paint_down_to_common(). See
+	# the pi-* topology comment in the setup test.
+	cat >input <<-\EOF &&
+	A:pi-X
+	X:pi-B
+	EOF
+	{
+		echo "get_merge_bases_many(A,X):" &&
+		git rev-parse pi-B
+	} >expect &&
+	test_all_modes get_merge_bases_many
+'
+
 test_expect_success 'reduce_heads' '
 	cat >input <<-\EOF &&
 	X:commit-1-10
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v4 1/8] Documentation/technical: add paint-down-to-common doc
From: Kristofer Karlsson via GitGitGadget @ 2026-06-28 12:25 UTC (permalink / raw)
  To: git; +Cc: Derrick Stolee, Elijah Newren, Kristofer Karlsson,
	Kristofer Karlsson
In-Reply-To: <pull.2149.v4.git.1782649547.gitgitgadget@gmail.com>

From: Kristofer Karlsson <krka@spotify.com>

Add a technical document describing the paint_down_to_common()
algorithm used for merge-base computation, covering the paint
walk, generation number regions, and termination conditions.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
---
 Documentation/Makefile                        |   1 +
 Documentation/technical/meson.build           |   1 +
 .../technical/paint-down-to-common.adoc       | 135 ++++++++++++++++++
 commit-reach.c                                |   6 +-
 4 files changed, 142 insertions(+), 1 deletion(-)
 create mode 100644 Documentation/technical/paint-down-to-common.adoc

diff --git a/Documentation/Makefile b/Documentation/Makefile
index 2699f0b24a..f8dea4b395 100644
--- a/Documentation/Makefile
+++ b/Documentation/Makefile
@@ -129,6 +129,7 @@ TECH_DOCS += technical/long-running-process-protocol
 TECH_DOCS += technical/multi-pack-index
 TECH_DOCS += technical/packfile-uri
 TECH_DOCS += technical/pack-heuristics
+TECH_DOCS += technical/paint-down-to-common
 TECH_DOCS += technical/parallel-checkout
 TECH_DOCS += technical/partial-clone
 TECH_DOCS += technical/platform-support
diff --git a/Documentation/technical/meson.build b/Documentation/technical/meson.build
index ec07088c57..9ce11d5e48 100644
--- a/Documentation/technical/meson.build
+++ b/Documentation/technical/meson.build
@@ -18,6 +18,7 @@ articles = [
   'multi-pack-index.adoc',
   'packfile-uri.adoc',
   'pack-heuristics.adoc',
+  'paint-down-to-common.adoc',
   'parallel-checkout.adoc',
   'partial-clone.adoc',
   'platform-support.adoc',
diff --git a/Documentation/technical/paint-down-to-common.adoc b/Documentation/technical/paint-down-to-common.adoc
new file mode 100644
index 0000000000..a4dfcba038
--- /dev/null
+++ b/Documentation/technical/paint-down-to-common.adoc
@@ -0,0 +1,135 @@
+Merge-Base Computation and paint_down_to_common()
+==================================================
+
+The function `paint_down_to_common()` in `commit-reach.c` computes merge
+bases by walking the commit graph backwards from two sets of tips and
+finding where their ancestry meets.
+
+Use cases
+---------
+
+Computing merge bases is used in two different ways:
+
+ 1. *Finding all merge bases* (`merge-base --all`, `merge-tree`,
+    `merge`, `rebase`). A merge base is a common ancestor that is
+    not itself an ancestor of another common ancestor.
+
+ 2. *Ancestry checks* (`in_merge_bases`, used by `merge-base
+    --is-ancestor`, `branch -d`, `fetch`). These ask: "is commit A
+    an ancestor of commit B?" If a common ancestor equals one of the
+    inputs, that input is necessarily the only merge base -- no other
+    common ancestor can be both as recent and not an ancestor of it.
+
+Both use cases share the same algorithm and implementation.
+
+Algorithm
+---------
+
+Given a commit `one` and a set of commits `twos[]`, the walk paints
+commits with two colors:
+
+  - PARENT1: reachable from `one`
+  - PARENT2: reachable from any commit in `twos[]`
+
+The walk uses a priority queue ordered by generation number (falling
+back to commit date when generation numbers are unavailable). Each
+step dequeues the highest-priority commit (this is when we say a
+commit is "visited") and propagates its paint flags to its parents,
+enqueuing them if they gained new flags. When a commit receives
+both PARENT1 and PARENT2, it is a merge-base candidate. A candidate
+gains the STALE flag so its ancestors propagate staleness -- any
+deeper common ancestor is necessarily redundant.
+
+INFINITY and finite generation regions
+--------------------------------------
+
+The commit-graph stores a generation number for each commit. Commits
+not in the commit-graph have generation `GENERATION_NUMBER_INFINITY`. The
+graph is closed under reachability: if a commit is in the graph, all
+its ancestors are too. This partitions the commit graph into two regions:
+
+....
+    +---------------------------------------+
+    |          INFINITY region              |
+    |  generation = INFINITY                |
+    |  queue order: heuristic (commit date) |
+    +---------------------------------------+
+                    |
+                    v
+    +---------------------------------------+
+    |          Finite region                |
+    |  generation = finite                  |
+    |  queue order: topological             |
+    +---------------------------------------+
+....
+
+When the commit-graph is enabled, the INFINITY region is typically
+very small -- it only contains commits added since the last
+commit-graph refresh.
+
+All reachable INFINITY-generation commits are visited before any
+finite-generation commit, because INFINITY is larger than any finite
+value. Once the walk crosses into the finite region, it stays there.
+
+In the finite region, generation ordering guarantees topological
+traversal: children are always visited before their parents. This
+means that paint on already-visited commits is final -- no future
+traversal step can add paint to them.
+
+In the INFINITY region, commit-date ordering can violate this: a
+parent with a later date can be visited before a child with an earlier
+date. Paint flags are therefore NOT final at visit time, and a
+commit visited with only one side's paint may later gain the other.
+
+Paint flags are only added, never removed. Since each flag can be set
+at most once per commit, the number of times a commit can be
+re-enqueued is bounded by the number of flag transitions.
+
+Termination
+-----------
+
+The walk uses a `nonstale_queue` wrapper around `prio_queue` that
+tracks `max_nonstale`: the lowest-priority non-stale commit enqueued
+so far. Once that commit is dequeued, every remaining entry is known
+to be STALE and the loop terminates. Specifically, the main loop
+ends when one of the following conditions holds:
+
+  1. The queue is empty.
+  2. `max_nonstale` has been dequeued, meaning the queue only contains
+     STALE entries.
+  3. Generation cutoff: the dequeued commit's generation is below
+     a caller-supplied `min_generation` threshold.
+  4. Single result: the caller only needs one merge base, one has
+     been found, and the walk has entered the finite-generation
+     region.
+
+Stale entry condition
+~~~~~~~~~~~~~~~~~~~~~
+Once all queued entries are stale, no new merge-base candidates can
+be discovered -- that requires at least one non-stale commit from
+each side meeting. Continuing the walk could still invalidate
+existing candidates by proving one is an ancestor of another, but
+`remove_redundant()` handles that as a post-processing step, so it
+is safe to exit early.
+
+Generation cutoff
+~~~~~~~~~~~~~~~~~
+Some callers (notably `remove_redundant()`) supply a `min_generation`
+threshold -- the minimum generation of the input commits. No merge
+base can have a generation below this threshold, so the walk
+terminates as soon as it dequeues such a commit.
+
+Single result
+~~~~~~~~~~~~~
+When only one merge base is needed and the walk is in the
+finite-generation region, the first candidate found is necessarily
+the highest-generation common ancestor. No remaining commit in the
+queue can be a descendant of this candidate (generation ordering
+guarantees children are visited first), so it cannot be redundant
+and the walk can stop immediately.
+
+Related documentation
+---------------------
+
+  - `Documentation/technical/commit-graph.adoc` -- generation numbers
+    and the reachability closure property.
diff --git a/commit-reach.c b/commit-reach.c
index 5df471a313..a9483759e0 100644
--- a/commit-reach.c
+++ b/commit-reach.c
@@ -96,7 +96,11 @@ static struct commit *nonstale_queue_get_dedup(struct nonstale_queue *queue)
 	return commit;
 }
 
-/* all input commits in one and twos[] must have been parsed! */
+/*
+ * See Documentation/technical/paint-down-to-common.adoc
+ *
+ * All input commits in one and twos[] must have been parsed!
+ */
 static int paint_down_to_common(struct repository *r,
 				struct commit *one, int n,
 				struct commit **twos,
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v4 0/8] commit-reach: terminate merge-base walk when one side is exhausted
From: Kristofer Karlsson via GitGitGadget @ 2026-06-28 12:25 UTC (permalink / raw)
  To: git; +Cc: Derrick Stolee, Elijah Newren, Kristofer Karlsson
In-Reply-To: <pull.2149.v3.git.1782479286.gitgitgadget@gmail.com>

commit-reach: terminate merge-base walk when one paint side is exhausted

Optimize paint_down_to_common() for merge-base queries that hit large
one-sided histories.

When the walk from one side reaches a commit with a very low generation
number that the other side never paints, the walk is forced to drain most of
the graph. A common trigger is a repository import that grafts a separate
history with its own root, but any merge that introduces a low-generation
commit never painted by the other side has the same effect.

A new merge-base candidate can only be discovered when exclusive PARENT1 and
PARENT2 paint meet. This series teaches paint_down_to_common() to stop as
soon as one side has no exclusive commits left in the queue; once one side
is exhausted, no further candidates can appear.

  origin/HEAD  o   o  PR HEAD
               |   |
     (import)  o   :
              / \ /
             |   o  merge-base
             |   |
             :   :  (~2.5M commits)
             |   |
  import root   main root


In the RFC thread [1], Derrick Stolee provided a criss-cross counterexample
that sharpened the halt condition, and Elijah Newren independently
discovered the same optimization and shared an implementation in PR #2150
[2]. Patches 2-3 incorporate test cases from Elijah's branch.

This series implements the optimization only after the walk enters the
finite-generation region, where generation ordering guarantees that paint on
visited commits is final.

Patch layout:

1/8 Documentation/technical: add paint-down-to-common doc 2/8 t6600: add
test cases for side-exhaustion edge cases 3/8 t6099, t6600: add
side-exhaustion regression tests 4/8 commit-reach: add trace2
instrumentation to paint_down_to_common() 5/8 commit-reach: introduce struct
paint_state with per-side counters 6/8 commit-reach: remove unused
nonstale_queue dedup wrappers 7/8 commit-reach: terminate merge-base walk
when one paint side is exhausted 8/8 commit-reach: move min_generation check
into paint_queue_get()

Benchmarks

Step counts are deterministic (measured via trace2_data_intmax added in
patch 4). Wall-clock times are best-of-11 runs.

2.6M-commit monorepo with commit-graph:

                                        steps              wall-clock
  merge-base --all  (across import)  2143438 ->      3     3.67s ->    5ms
  merge-base --all  (1000 apart)     2692915 ->   1035     4.41s ->    7ms
  merge-base --all  (5000 apart)     2692915 ->   6401     4.45s ->   13ms
  merge-base --all  (HEAD vs import) 2698872 ->  45960     4.50s ->   79ms
  merge-tree        (across import)  2143438 ->      3     4.42s ->   11ms


git.git (88k commits, commit-graph):

                                        steps              wall-clock
  merge-base --all v2.0.0 v2.55.0-rc1 72264 ->  44589      110ms ->   68ms
  merge-base --all HEAD HEAD~1000      9891 ->   3828       18ms ->   10ms
  merge-base --all HEAD HEAD~10000    72303 ->  41487      101ms ->   50ms


Changes since v3:

 * Fixed BUG assertion that was accidentally made unconditional in v3:
   restored the min_generation guard so it only fires when generation-based
   ordering is active.

 * Moved generation cutoff and single-result termination conditions into the
   documentation in patch 1/8, since they describe existing behavior.

 * Renamed paint_state counter fields for clarity: p1_count ->
   parent1_count, p2_count -> parent2_count, pending_merge_bases ->
   mb_candidate_count. Changed counter types from int to size_t. (Suggested
   by Rene Scharfe.)

Changes since v2:

 * New patch 8/8: moved the min_generation termination check and the
   last_gen monotonicity assertion into paint_queue_get(), consolidating
   halt conditions. commit_graph_generation() is now called once per
   dequeued commit and shared across all checks.

 * Moved all halt conditions inside paint_queue_get() with the "pop first"
   form: pop, check, then decrement counters. This keeps the optimization
   commit's diff minimal (just inserting the new checks between pop and
   decrement).

 * Shortened the doc comment on paint_queue_get() to describe what it does
   rather than how. Inline comments on each return NULL explain the specific
   halt condition.

 * Replaced the manual commit-graph setup in the step-count test with
   run_all_modes, which now sets GIT_TRACE2_EVENT per mode and produces
   trace-mode-{none,full,half,no-gdat}.txt files.

 * Added a test_paint_down_steps helper for concise 4-mode step assertions
   with diagnostic output on mismatch (prints "expected X, got Y" instead of
   a silent grep failure).

 * Added step-count assertions to the single-walk edge-case tests:
   in_merge_bases_many:self, pending-stale, infinity-both-sides,
   mixed-finite-infinity.

 * Included step counts alongside wall-clock times in the benchmark tables.

Changes since v1:

 * Reordered patches: documentation first (describing the existing
   algorithm), tests before code changes, so they demonstrate passing with
   old logic first.

 * Dropped the ahead_behind decoupling patch. paint_state is now a NEW
   struct alongside nonstale_queue instead of replacing it. ahead_behind()
   is completely untouched.

 * Removed nonstale_queue_put_dedup() and nonstale_queue_get_dedup() (dead
   code after the conversion) in a separate commit.

 * Renamed: struct paint_queue -> paint_state, field pq -> queue,
   paint_count_add/remove -> paint_count_update (single function with signed
   delta parameter).

 * Split the old paint_count_transition (which handled both old and new
   flags in one call) into separate remove/add calls with a signed delta.
   This eliminates the need for the case 0 handler (which tracked "not in
   the queue") and allows an exhaustive switch on (PARENT1 | PARENT2 |
   STALE) that documents all valid flag combinations, with BUG() in default.

 * Added trace2_data_intmax() instrumentation to report the number of
   commits visited per paint walk (separate commit), with deterministic
   step-count assertions in t6600.

 * Expanded switch statements to multi-line format per .clang-format.

 * Used !count style throughout instead of count == 0.

 * Updated technical documentation alongside code changes.

[1]
https://lore.kernel.org/git/CAL71e4Ps-2_0+uuZu43N9pFnXBemoAohPs_eyRJf8taXHJPAXQ@mail.gmail.com/T/#u
[2] https://github.com/gitgitgadget/git/pull/2150

Elijah Newren (1):
  t6600: add test cases for side-exhaustion edge cases

Kristofer Karlsson (7):
  Documentation/technical: add paint-down-to-common doc
  t6099, t6600: add side-exhaustion regression tests
  commit-reach: add trace2 instrumentation to paint_down_to_common()
  commit-reach: introduce struct paint_state with per-side counters
  commit-reach: remove unused nonstale_queue dedup wrappers
  commit-reach: terminate merge-base walk when one paint side is
    exhausted
  commit-reach: move min_generation check into paint_queue_get()

 Documentation/Makefile                        |   1 +
 Documentation/technical/meson.build           |   1 +
 .../technical/paint-down-to-common.adoc       | 149 ++++++++++++++
 commit-reach.c                                | 147 ++++++++++----
 t/meson.build                                 |   1 +
 t/t6099-merge-base-side-exhaustion.sh         |  82 ++++++++
 t/t6600-test-reach.sh                         | 181 ++++++++++++++++--
 7 files changed, 513 insertions(+), 49 deletions(-)
 create mode 100644 Documentation/technical/paint-down-to-common.adoc
 create mode 100755 t/t6099-merge-base-side-exhaustion.sh


base-commit: 6c3d7b73556db708feb3b16232fab1efc4353428
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2149%2Fspkrka%2Fside-exhaust-pr-v4
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2149/spkrka/side-exhaust-pr-v4
Pull-Request: https://github.com/gitgitgadget/git/pull/2149

Range-diff vs v3:

 1:  2593866bce ! 1:  3efb095b03 Documentation/technical: add paint-down-to-common doc
     @@ Documentation/technical/paint-down-to-common.adoc (new)
      +  1. The queue is empty.
      +  2. `max_nonstale` has been dequeued, meaning the queue only contains
      +     STALE entries.
     ++  3. Generation cutoff: the dequeued commit's generation is below
     ++     a caller-supplied `min_generation` threshold.
     ++  4. Single result: the caller only needs one merge base, one has
     ++     been found, and the walk has entered the finite-generation
     ++     region.
      +
      +Stale entry condition
      +~~~~~~~~~~~~~~~~~~~~~
     @@ Documentation/technical/paint-down-to-common.adoc (new)
      +`remove_redundant()` handles that as a post-processing step, so it
      +is safe to exit early.
      +
     ++Generation cutoff
     ++~~~~~~~~~~~~~~~~~
     ++Some callers (notably `remove_redundant()`) supply a `min_generation`
     ++threshold -- the minimum generation of the input commits. No merge
     ++base can have a generation below this threshold, so the walk
     ++terminates as soon as it dequeues such a commit.
     ++
     ++Single result
     ++~~~~~~~~~~~~~
     ++When only one merge base is needed and the walk is in the
     ++finite-generation region, the first candidate found is necessarily
     ++the highest-generation common ancestor. No remaining commit in the
     ++queue can be a descendant of this candidate (generation ordering
     ++guarantees children are visited first), so it cannot be redundant
     ++and the walk can stop immediately.
     ++
      +Related documentation
      +---------------------
      +
 2:  9efc084850 = 2:  1a0154b406 t6600: add test cases for side-exhaustion edge cases
 3:  14b0d86b93 = 3:  017bf156c5 t6099, t6600: add side-exhaustion regression tests
 4:  2592264cda = 4:  df3b090a2b commit-reach: add trace2 instrumentation to paint_down_to_common()
 5:  e82e0c72b6 ! 5:  fed9f2c368 commit-reach: introduce struct paint_state with per-side counters
     @@ Documentation/technical/paint-down-to-common.adoc: re-enqueued is bounded by the
      -  2. `max_nonstale` has been dequeued, meaning the queue only contains
      -     STALE entries.
      +  2. The queue contains only stale entries.
     - 
     - Stale entry condition
     - ~~~~~~~~~~~~~~~~~~~~~
     +   3. Generation cutoff: the dequeued commit's generation is below
     +      a caller-supplied `min_generation` threshold.
     +   4. Single result: the caller only needs one merge base, one has
      
       ## commit-reach.c ##
      @@ commit-reach.c: static struct commit *nonstale_queue_get_dedup(struct nonstale_queue *queue)
     @@ commit-reach.c: static struct commit *nonstale_queue_get_dedup(struct nonstale_q
      + */
      +struct paint_state {
      +	struct prio_queue queue;
     -+	int p1_count;
     -+	int p2_count;
     -+	int pending_merge_bases;
     ++	size_t parent1_count;
     ++	size_t parent2_count;
     ++	size_t mb_candidate_count;
      +};
      +
      +static void paint_count_update(struct paint_state *state,
     @@ commit-reach.c: static struct commit *nonstale_queue_get_dedup(struct nonstale_q
      +{
      +	switch (flags & (PARENT1 | PARENT2 | STALE)) {
      +	case PARENT1:
     -+		state->p1_count += delta;
     ++		state->parent1_count += delta;
      +		break;
      +
      +	case PARENT2:
     -+		state->p2_count += delta;
     ++		state->parent2_count += delta;
      +		break;
      +
      +	case PARENT1 | PARENT2:
     -+		state->pending_merge_bases += delta;
     ++		state->mb_candidate_count += delta;
      +		break;
      +
      +	case PARENT1 | PARENT2 | STALE:
     @@ commit-reach.c: static struct commit *nonstale_queue_get_dedup(struct nonstale_q
      +
      +	commit->object.flags &= ~ENQUEUED;
      +
     -+	if (!state->p1_count && !state->p2_count &&
     -+	    !state->pending_merge_bases)
     ++	if (!state->parent1_count && !state->parent2_count &&
     ++	    !state->mb_candidate_count)
      +		return NULL;
      +
      +	paint_count_update(state, commit->object.flags, -1);
 6:  e6181bf3c1 = 6:  4db485b48a commit-reach: remove unused nonstale_queue dedup wrappers
 7:  f3572a8a89 ! 7:  4506780649 commit-reach: terminate merge-base walk when one paint side is exhausted
     @@ Commit message
          commit-reach: terminate merge-base walk when one paint side is exhausted
      
          Add an early termination check to paint_down_to_common() using the
     -    per-side counters introduced earlier. Once the walk enters the
     +    per-side counters introduced earlier.  Once the walk enters the
          finite-generation region, terminate early when one side's exclusive
          count drops to zero -- no new merge-base can form without both paint
          sides meeting.
     @@ Commit message
      
          The INFINITY gate ensures correctness: commits without a commit-graph
          entry have GENERATION_NUMBER_INFINITY and are ordered by commit date,
     -    which is not topologically reliable. The optimization only fires
     +    which is not topologically reliable.  The optimization only fires
          once the walk enters the finite-generation region where ordering
          guarantees hold.
      
     -    Widen the existing generation-monotonicity BUG assertion to fire
     -    unconditionally, not only when min_generation is set. The
     -    side-exhaustion optimization depends on correct generation ordering,
     -    so the assertion should always be active.
     -
          Step counts measured with trace2 on git.git with commit-graph:
      
            merge-base --all v2.0.0 v2.55.0-rc1:
     @@ Commit message
      
       ## Documentation/technical/paint-down-to-common.adoc ##
      @@ Documentation/technical/paint-down-to-common.adoc: ends when one of the following conditions holds:
     - 
     -   1. The queue is empty.
     -   2. The queue contains only stale entries.
     -+  3. Side exhaustion: no pure PARENT1 or pure PARENT2 commits
     +   4. Single result: the caller only needs one merge base, one has
     +      been found, and the walk has entered the finite-generation
     +      region.
     ++  5. Side exhaustion: no pure PARENT1 or pure PARENT2 commits
      +     remain in the queue, no pending merge-base candidates exist,
      +     and the walk has entered the finite-generation region.
       
     @@ Documentation/technical/paint-down-to-common.adoc: existing candidates by provin
      +commit-date ordering can violate this guarantee, so the check is
      +skipped.
      +
     - Related documentation
     - ---------------------
     - 
     + Generation cutoff
     + ~~~~~~~~~~~~~~~~~
     + Some callers (notably `remove_redundant()`) supply a `min_generation`
      
       ## commit-reach.c ##
      @@ commit-reach.c: static void paint_queue_put(struct paint_state *state,
     @@ commit-reach.c: static struct commit *paint_queue_get(struct paint_state *state)
       
       	commit->object.flags &= ~ENQUEUED;
       
     --	if (!state->p1_count && !state->p2_count &&
     --	    !state->pending_merge_bases)
     +-	if (!state->parent1_count && !state->parent2_count &&
     +-	    !state->mb_candidate_count)
      -		return NULL;
     -+	if (!state->pending_merge_bases) {
     ++	if (!state->mb_candidate_count) {
      +		/* only stale entries remain */
     -+		if (!state->p1_count && !state->p2_count)
     ++		if (!state->parent1_count && !state->parent2_count)
      +			return NULL;
      +
      +		/* one side is exhausted */
     -+		if ((!state->p1_count || !state->p2_count) &&
     ++		if ((!state->parent1_count || !state->parent2_count) &&
      +		    commit_graph_generation(commit) < GENERATION_NUMBER_INFINITY)
      +			return NULL;
      +	}
       
       	paint_count_update(state, commit->object.flags, -1);
       	return commit;
     -@@ commit-reach.c: static int paint_down_to_common(struct repository *r,
     - 		timestamp_t generation = commit_graph_generation(commit);
     - 		steps++;
     - 
     --		if (min_generation && generation > last_gen)
     -+		if (generation > last_gen)
     - 			BUG("bad generation skip %"PRItime" > %"PRItime" at %s",
     - 			    generation, last_gen,
     - 			    oid_to_hex(&commit->object.oid));
      
       ## t/t6600-test-reach.sh ##
      @@ t/t6600-test-reach.sh: test_expect_success 'in_merge_bases_many:self' '
 8:  4b9f192d98 ! 8:  8dd15d44e6 commit-reach: move min_generation check into paint_queue_get()
     @@ Commit message
          Move last_gen into struct paint_state so that
          commit_graph_generation() is called exactly once per dequeued commit
          and the result is shared across all termination checks and the
     -    monotonicity BUG assertion.  The loop body in paint_down_to_common()
     -    reads state.last_gen instead of recomputing the generation number.
     +    monotonicity BUG assertion.
      
          Signed-off-by: Kristofer Karlsson <krka@spotify.com>
      
     - ## Documentation/technical/paint-down-to-common.adoc ##
     -@@ Documentation/technical/paint-down-to-common.adoc: ends when one of the following conditions holds:
     -   3. Side exhaustion: no pure PARENT1 or pure PARENT2 commits
     -      remain in the queue, no pending merge-base candidates exist,
     -      and the walk has entered the finite-generation region.
     -+  4. Generation cutoff: the dequeued commit's generation is below
     -+     a caller-supplied `min_generation` threshold.
     - 
     - Stale entry condition
     - ~~~~~~~~~~~~~~~~~~~~~
     -@@ Documentation/technical/paint-down-to-common.adoc: time and an exhausted side cannot reappear. In the INFINITY region,
     - commit-date ordering can violate this guarantee, so the check is
     - skipped.
     - 
     -+Generation cutoff
     -+~~~~~~~~~~~~~~~~~
     -+Some callers (notably `remove_redundant()`) supply a `min_generation`
     -+threshold -- the minimum generation of the input commits. No merge
     -+base can have a generation below this threshold, so the walk
     -+terminates as soon as it dequeues such a commit.
     -+
     - Related documentation
     - ---------------------
     - 
     -
       ## commit-reach.c ##
      @@ commit-reach.c: struct paint_state {
     - 	int p1_count;
     - 	int p2_count;
     - 	int pending_merge_bases;
     + 	size_t parent1_count;
     + 	size_t parent2_count;
     + 	size_t mb_candidate_count;
      +	timestamp_t min_generation;
      +	timestamp_t last_gen;
       };
     @@ commit-reach.c: static void paint_queue_put(struct paint_state *state,
       	commit->object.flags &= ~ENQUEUED;
      +	generation = commit_graph_generation(commit);
      +
     -+	if (generation > state->last_gen)
     ++	if (state->min_generation && generation > state->last_gen)
      +		BUG("bad generation skip %"PRItime" > %"PRItime" at %s",
      +		    generation, state->last_gen,
      +		    oid_to_hex(&commit->object.oid));
     @@ commit-reach.c: static void paint_queue_put(struct paint_state *state,
      +	if (generation < state->min_generation)
      +		return NULL;
       
     - 	if (!state->pending_merge_bases) {
     + 	if (!state->mb_candidate_count) {
       		/* only stale entries remain */
      @@ commit-reach.c: static struct commit *paint_queue_get(struct paint_state *state)
       
       		/* one side is exhausted */
     - 		if ((!state->p1_count || !state->p2_count) &&
     + 		if ((!state->parent1_count || !state->parent2_count) &&
      -		    commit_graph_generation(commit) < GENERATION_NUMBER_INFINITY)
      +		    generation < GENERATION_NUMBER_INFINITY)
       			return NULL;
     @@ commit-reach.c: static int paint_down_to_common(struct repository *r,
      -		timestamp_t generation = commit_graph_generation(commit);
       		steps++;
       
     --		if (generation > last_gen)
     +-		if (min_generation && generation > last_gen)
      -			BUG("bad generation skip %"PRItime" > %"PRItime" at %s",
      -			    generation, last_gen,
      -			    oid_to_hex(&commit->object.oid));

-- 
gitgitgadget

^ permalink raw reply

* Re: [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits
From: Johannes Schindelin @ 2026-06-28 12:20 UTC (permalink / raw)
  To: Toon Claes; +Cc: git, Elijah Newren
In-Reply-To: <20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com>

Hi Toon,

On Fri, 26 Jun 2026, Toon Claes wrote:

> - (BIGGEST CHANGE) When working on a refactor to undo the enum->bool
>   patch, I extended the code comments to explain how things work. This
>   made me realize the use of the "replayed_base" was incorrect when
>   multiple branches are rebased with --onto. This is fixed now and a
>   test is added for this scenario.

I am not quite certain that this results in the desired outcome when
working with a single branch that contains a merge commit. Take for
example this topology (master~2..master at the time of writing):

  *   6c3d7b73556d Merge branch 'ps/t4216-tap-fix'
  |\
  | * f0411a4c717e t4216: fix no-op test that breaks TAP output
  * | ab776a62a785 Git 2.55-rc2
  o | 1ea786d14a1b Merge branch 'hn/macos-linker-warning'
   /
  o 08b6ae38c602 t4216: test changed path filters with high bit paths

Running `git replay --linearize --onto master~2 master~2..master` used to
result in this:

  * 3ec7cc3e73c0 t4216: fix no-op test that breaks TAP output
  * 8dca9f98dc05 Git 2.55-rc2
  o 1ea786d14a1b Merge branch 'hn/macos-linker-warning'

which is what I would expect. But now, due to the dropped `replayed_base`,
that tip commit is replayed directly on top of `onto` and the first
replayed commit ("Git 2.55-rc2") is simply (and inadvertently) dropped:

  * 5e4899a3e03c t4216: fix no-op test that breaks TAP output
  o 1ea786d14a1b Merge branch 'hn/macos-linker-warning'

I had originally introduced that `replayed_base` specifically to prevent
this commit-dropping.

As to the question what should happen if multiple branches are replayed at
the same time with `--linearize`: This is a very tricky problem. Naively,
one would want all of those branches to be linearized _individually_. But
that idea breaks down when you replay three branches, two of them with
distinct commits, and the third branch a merge of the first two:

  * Branch C: merge branches A and B
  |\
  | * Branch B
  * | Branch A
  |/
  o onto

What should the replayed branch C look like? Should it have A' and B' in
that order? I.e. share the rewritten commit with the replayed branch A?
But then B' could not be the replayed B because that needs to be directly
on top of onto.

So I fear that the `replayed_base` design _is_ needed, and the only way
`git replay --linearize` can work with multiple branches is by linearizing
all of the replayed commits into one single, linear commit topology.

Obviously, there are ways one could _try_ to rescue the previous idea, so
that at least replaying just branches A and B would keep the replayed
commits non-reachable from each other, but I strongly suspect that any
such design will invariably surprise users in nasty ways when the logic
has to fall back to the simple idea I outlined anyway.

Ciao,
Johannes

^ permalink raw reply

* [GIT PULL] l10n updates for Git 2.55.0
From: Jiang Xin @ 2026-06-28 11:35 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Jiang Xin, Git List, Aindriú Mac Giolla Eoin,
	Alexander Shopov, Arkadii Yakovets, Bagas Sanjaya,
	Dimitriy Ryazantcev, Emir SARI, Emir SARI, Jean-Noël Avila,
	lilydjwg, Lumynous, Matteo Beniamino, Mikel Forcada,
	Mikel Forcada, Peter Krefting, Ralf Thielow,
	Vũ Tiến Hưng, Yi-Jyun Pan

Hi Junio,

Please pull the following l10n updates for Git 2.55.0.

The following changes since commit 6c3d7b73556db708feb3b16232fab1efc4353428:

  Merge branch 'ps/t4216-tap-fix' (2026-06-25 19:49:01 -0700)

are available in the Git repository at:

  git@github.com:git-l10n/git-po.git tags/l10n-2.55.0-v1

for you to fetch changes up to 08621c32d5536babd139ab1a9086349b3672edd6:

  Merge branch '2.55-uk-pr' of github.com:arkid15r/git-ukrainian-l10n (2026-06-28 19:25:08 +0800)

----------------------------------------------------------------
l10n-2.55.0-v1
-----BEGIN PGP SIGNATURE-----

iQJPBAABCAA5FiEE37vMEzKDqYvVxs51k24VDd1FMtUFAmpBBXgbFIAAAAAABAAO
bWFudTIsMi41KzEuMTIsMCwzAAoJEJNuFQ3dRTLVKN8QAJfOm5oM21vsH6ONo4QO
u89j0ynacQI0rvQNLa9yGrR7vZumPQQKETwBulWTZLpQ0BrWev69LwpTZFVjQBp0
JxXvUW5FiHKQx+tSPT2SeNkMR3eHWxEFcyCP3QMnAbV5GFRRTnOr9ajRVI/b3Fi3
5xP2dgQW0F8oiuMAX/6osqwjjqO8qAAxtnX/+ecw2KPQ8ddgJsWPdAkWnAG7Ctnu
ff5jxOJ4ECAvsV5eywr2Ea1MOT032nsRX6Yaf6JIfdpU2oZzhCaVXgJwVkjKn/8c
mdEvaEZGSXPn969PEV3bsUEUEV7kgBt5wdWHRX5bGjwfXv/MKVnXZz/OQjlIa/pP
Pzpbi8jRSVCNXBi1kOpqVIOP3yndrYPh103juue5LEwyxsctmmvOtvB2t8Q3pY6G
/baSLDKp8GGi8x0h61D+lGAToK05YWafx95pZlxuwdd27ShJWaE6cknMkirJl9yD
IkihgCWxrCZmRX32YstZsV3FrvsT1GUwLEXm31IHfFRLp+76oVympgQh1r2qttCo
zMRPdo8ECZ9jwZ7OeCjshmAzgGzhuiwURZnYaPG/BC9wKqPDRmaUp8oKSjYreNgy
IuBUt/bOJO/y8ut6KZvMhpbJnOlULrOnHWDWqm2IfY+7+PLSD6uoodtbWf7ISUeP
k4eqg5pJX/q0t+uo8I0Z9Xqb
=M5pa
-----END PGP SIGNATURE-----

----------------------------------------------------------------
Aindriú Mac Giolla Eoin (1):
      l10n: ga.po: update for Git 2.55

Alexander Shopov (1):
      l10n: bg.po: Updated Bulgarian translation (6322t)

Arkadii Yakovets (1):
      l10n: uk: add 2.55 translation

Bagas Sanjaya (1):
      l10n: po-id for 2.55

Emir SARI (1):
      l10n: tr: Update Turkish translations

Jean-Noël Avila (2):
      l10n: fr: version 2.55
      l10n: fr: mass fix of typos

Jiang Xin (12):
      l10n: AGENTS.md: add quotation mark preservation guidelines
      Merge branch 'master' of github.com:mbeniamino/git-po
      Merge branch 'master' of github.com:nafmo/git-l10n-sv
      Merge branch 'fr_v2.55' of github.com:jnavila/git
      Merge branch 'master' of github.com:alshopov/git-po
      Merge branch 'po-id' of github.com:bagasme/git-po
      Merge branch 'tr-l10n' of github.com:bitigchi/git-po
      Merge branch 'zh_CN-2.55' of github.com:lilydjwg/git-po
      Merge branch 'ca-20260624-b' of github.com:Softcatala/git-po
      Merge branch 'l10n/zh-TW/2026-06-26' of github.com:l10n-tw/git-po
      Merge branch 'l10n-ga-2.55' of github.com:aindriu80/git-po
      Merge branch '2.55-uk-pr' of github.com:arkid15r/git-ukrainian-l10n

Lumynous (1):
      l10n: zh-TW.po: Update Chinese (Traditional) translation

Matteo Beniamino (1):
      l10n: it: fix italian usage messages alignment

Mikel Forcada (1):
      l10n: ca.po: update Catalan translation

Peter Krefting (1):
      l10n: sv.po: Update Swedish translation

lilydjwg (2):
      l10n: TEAMS: change Simplified Chinese team leader
      l10n: zh_CN: updated translation for 2.55

 po/AGENTS.md |   51 +-
 po/TEAMS     |    6 +-
 po/bg.po     |  796 ++++++++++++++++----
 po/ca.po     | 1421 +++++++++++++++++++++++-----------
 po/fr.po     |  836 ++++++++++++++------
 po/ga.po     |  864 ++++++++++++++++-----
 po/id.po     | 2322 ++++++++++++++++++++++++++++++++++++++++++--------------
 po/it.po     |    2 +-
 po/sv.po     |  784 ++++++++++++++-----
 po/tr.po     |  752 +++++++++++++-----
 po/uk.po     | 2384 +++++++++++++++++++++++++++++++++++++++++++---------------
 po/zh_CN.po  | 1034 ++++++++++++++++++-------
 po/zh_TW.po  | 2261 +++++++++++++++++++++++++++++++------------------------
 13 files changed, 9725 insertions(+), 3788 deletions(-)

--
Jiang Xin

^ permalink raw reply

* Re: [PATCH] reftable: fix unlikely leak on API error
From: Jeff King @ 2026-06-28  9:06 UTC (permalink / raw)
  To: git; +Cc: Patrick Steinhardt
In-Reply-To: <20260628090314.GA661068@coredump.intra.peff.net>

On Sun, Jun 28, 2026 at 05:03:14AM -0400, Jeff King wrote:

> Noticed by Coverity as a "new" problem, though it has been there for
> over a year.  Presumably the nearby changes from 44f46f2be5 (reftable:
> split up write options, 2026-06-25) confused it. There's a backlog of
> hundreds of Coverity problems, most of which are garbage, so I tend to
> only look at the ones it marks as new.

This does conflict textually with 44f46f2be5, which adds a new line
nearby. Resolving like:

diff --cc reftable/writer.c
index f850e9d599,1bd4aa388b..d969a6a021
--- a/reftable/writer.c
+++ b/reftable/writer.c
@@@ -161,9 -158,10 +157,13 @@@ int reftable_writer_new(struct reftable
  	if (opts.block_size >= (1 << 24))
  		return REFTABLE_API_ERROR;
  
 +	if (!hash_id)
 +		hash_id = REFTABLE_HASH_SHA1;
 +
+ 	wp = reftable_calloc(1, sizeof(*wp));
+ 	if (!wp)
+ 		return REFTABLE_OUT_OF_MEMORY_ERROR;
+ 
  	reftable_buf_init(&wp->block_writer_data.last_key);
  	reftable_buf_init(&wp->last_key);
  	reftable_buf_init(&wp->scratch);

makes sense to me, as it keeps the hash_id setting with the "opts"
setup.

-Peff

^ permalink raw reply

* [PATCH] reftable: fix unlikely leak on API error
From: Jeff King @ 2026-06-28  9:03 UTC (permalink / raw)
  To: git; +Cc: Patrick Steinhardt

If the reftable writer sees a bogus block size, we return with
REFTABLE_API_ERROR, leaking the reftable_writer struct we previously
allocated. Originally this case was a BUG(), but it became a regular
return in 445f9f4f35 (reftable: stop using `BUG()` in trivial cases,
2025-02-18).

We could obviously fix it by calling "reftable_free(wp)". But we can
observe that we never use the allocated "wp" until after we've validated
the input options. So let's just bump the allocation down. That fixes
the leak, and I think makes the flow of the function more logical
(we validate our inputs before doing any work).

Signed-off-by: Jeff King <peff@peff.net>
---
Noticed by Coverity as a "new" problem, though it has been there for
over a year.  Presumably the nearby changes from 44f46f2be5 (reftable:
split up write options, 2026-06-25) confused it. There's a backlog of
hundreds of Coverity problems, most of which are garbage, so I tend to
only look at the ones it marks as new.

 reftable/writer.c | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/reftable/writer.c b/reftable/writer.c
index 0133b64975..1bd4aa388b 100644
--- a/reftable/writer.c
+++ b/reftable/writer.c
@@ -152,16 +152,16 @@ int reftable_writer_new(struct reftable_writer **out,
 	struct reftable_write_options opts = {0};
 	struct reftable_writer *wp;
 
-	wp = reftable_calloc(1, sizeof(*wp));
-	if (!wp)
-		return REFTABLE_OUT_OF_MEMORY_ERROR;
-
 	if (_opts)
 		opts = *_opts;
 	options_set_defaults(&opts);
 	if (opts.block_size >= (1 << 24))
 		return REFTABLE_API_ERROR;
 
+	wp = reftable_calloc(1, sizeof(*wp));
+	if (!wp)
+		return REFTABLE_OUT_OF_MEMORY_ERROR;
+
 	reftable_buf_init(&wp->block_writer_data.last_key);
 	reftable_buf_init(&wp->last_key);
 	reftable_buf_init(&wp->scratch);
-- 
2.55.0.rc2.353.gf769b6597e

^ permalink raw reply related

* Re: [PATCH] meson: wire up USE_NSEC build knob
From: Jeff King @ 2026-06-28  8:48 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: D. Ben Knoble, git, brian m . carlson, Junio C Hamano,
	Ramsay Jones
In-Reply-To: <20260628081806.GA3594700@coredump.intra.peff.net>

On Sun, Jun 28, 2026 at 04:18:07AM -0400, Jeff King wrote:

> I tried with a vfat mount, and it also works: we don't have nanoseconds
> either before or after. That makes sense, and implies that modern Linux
> will always be OK (because it limits the cached VFS response to what the
> underlying filesystem can handle).
> 
> So...maybe this is just a non-issue these days, at least on Linux?

Oh, I also ran across this old thread:

  https://public-inbox.org/git/5605D88A.20104%40gmail.com/

that implies similar:

  * In-core file times may not be properly rounded to on-disk
    precision, causing spurious file time changes when the cache is
    refreshed from disk. This was fixed for typical Unix file systems
    in kernel 2.6.11. The fix for CEPH, CIFS, NTFS, UFS and FUSE will
    be in kernel 4.3. There's no fix for FAT-based file systems yet.

I also tested with CIFS on my system and it is fine. It looks like FAT
systems were fixed since 2015. ;)

But there is another interesting question raised there, which is how
different implementations may interact (e.g., two versions of Git
without and without USE_NSEC, or JGit which may have to use
millisecond-resolution APIs, etc). It should all work correctly as long
as each implementation consistently uses its own resolution (so JGit
would have to compare in millisecond-space and treat ties as racy). And
I think that is _probably_ what is happening now, since we already store
nanoseconds unconditionally (and only use them with USE_NSEC).

Though the opposite case is a performance problem but not a correctness
one: if JGit writes out an index with milliseconds and USE_NSEC Git
tries to read it, we will consider everything stat-dirty and re-read the
contents.

I don't know if these would be a problem in practice or not, but it's an
interesting potential gotcha. And one that nobody may have noticed,
because probably hardly anybody bothers to build with USE_NSEC now.

-Peff

^ permalink raw reply

* Re: [PATCH v4 1/1] environment: move excludes_file into repo_config_values
From: Junio C Hamano @ 2026-06-28  8:40 UTC (permalink / raw)
  To: Tian Yuchen
  Cc: git, cirnovskyv, szeder.dev, Christian Couder, Ayush Chandekar,
	Olamide Caleb Bello
In-Reply-To: <eabb8169-2c13-4961-9b21-f44b1fa66f70@malon.dev>

Tian Yuchen <cat@malon.dev> writes:

>> Wouldn't we rather want to try to be more strict and say
>> 
>> 	if (!repo || !repo->initialized)
>> 		BUG("repo must be an initialied repository");
>> 
>> here?  Aren't all the callers of this function supposed to be
>> dealing with an already initialized repository?
>
> That makes sense, but from my point of view...
>
> 'repo_config_values()' already has a check for 'repo->initialized'. If 
> we're absolutely certain that the 'repo' is initialized, wouldn't it be 
> better to simply remove all the checks inside the getter and leave the 
> judgment to 'repo_config_values()'?

Yes, that was what I was getting at ;-).

^ permalink raw reply

* Re: git-diff in a worktree is an order of magnitude slower?
From: Jeff King @ 2026-06-28  8:36 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: D. Ben Knoble, Git
In-Reply-To: <xmqqik7a4vhp.fsf@gitster.g>

On Mon, Jun 22, 2026 at 05:20:34AM -0700, Junio C Hamano wrote:

> Jeff King <peff@peff.net> writes:
> 
> > Yes, though that implies comparing the index and file mtimes with
> > nanosecond precision.  We have that precision stored (at least
> > when the system supports it) but I'm not sure if that comparison would
> > run afoul of the reasons USE_NSEC was not the default in the first
> > place.
> >
> > I guess not? The problem there is that the nanosecond portion would
> > sometimes get wiped if the entry was dropped from the kernel's in-memory
> > cache. And then stat-matching would not work. But if we are talking
> > about strictly asking "is this mtime later than that mtime", then I
> > think the worst case is that we fall back to the current behavior.
> 
> Right, and you are right to point out that for the purpose of
> comparing mtimes of files' and the index file, this would make it
> unworkable.  I can imagine that a file and the index may have been
> written within the same millisecond but we can tell that the former
> slightly earlier than the latter (or the other way around) with
> nanoseconds resolution, then only one of the two lose the sub millisecond
> resolution but not the other due to its in-core inode evicted out of
> the cache.  Depending on which one survives (and keeps a non-zero
> sub millisecond part), they can compare differently.

Hmm, yeah. I was thinking there might be some mitigating factor because
we're comparing stat information that is stored in the index, and not
against a fresh stat() call. But that's not true.

We are using stat() information stored in the index from a file that was
written in the same second as that index (otherwise we do not care about
nanoseconds at all). But the index does not store its own mtime. At the
time of reading, we will fstat() it fresh, so we may see the truncated
mtime value.

In that case we'd always see the index as older than it really is. Which
I think does fail in the favorable direction for us (we assume the
too-new file is possibly racy and err on the side of caution).

But I don't think it rules out seeing the truncation in the other
direction. The original index write would have to be done in the same
second that the tracked file is written (because we only care about
nanoseconds when that is true). So it implies that the tracked file was
written, had its inode evicted, and then was re-read from disk all in
the same second that the index is being written, and the index inode
itself is never evicted. That seems unlikely but not impossible.

Anyway, it's all sufficiently scary that I think it should stay
conditional on USE_NSEC. I do suspect that USE_NSEC is safe at least on
Linux these days (see my response to Patrick elsewhere).

-Peff

^ permalink raw reply

* [PATCH v6 4/4] history: re-edit a squash with every message
From: Harald Nordgren via GitGitGadget @ 2026-06-28  8:29 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

By default "git history squash" reuses the oldest commit's message.
When --reedit-message is given it only reopened that one message, so the
messages of the folded-in commits were lost.

Gather the messages of every commit in the range, oldest first, and use
them as the editor template when re-editing, mirroring how "git rebase
-i" presents a squash.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-history.adoc |  5 +--
 builtin/history.c              | 61 +++++++++++++++++++++++++++++++++-
 t/t3455-history-squash.sh      | 37 +++++++++++++++++++++
 3 files changed, 100 insertions(+), 3 deletions(-)

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 123ad5d4bc..8d4398ab1b 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -114,8 +114,9 @@ arguments to linkgit:git-rev-list[1], so several arguments may be given,
 for example `@~3.. ^topic` to additionally exclude what is already on
 `topic`.
 +
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
+The oldest commit's message and authorship are preserved by default. With
+`--reedit-message`, an editor opens pre-filled with the messages of all the
+folded commits so you can combine them. A merge commit inside the range is
 folded like any other, but the range must have a single base, so a range
 that reaches more than one entry point (for example a side branch that
 forked before the range and was later merged into it) is rejected.
diff --git a/builtin/history.c b/builtin/history.c
index 5a1b42c063..1c31ea9118 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1097,6 +1097,56 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
 	return 0;
 }
 
+static int build_squash_message(struct repository *repo,
+				struct commit *base,
+				struct commit *tip,
+				struct strbuf *out)
+{
+	struct rev_info revs;
+	struct commit *commit;
+	struct strvec args = STRVEC_INIT;
+	int n = 0, ret;
+
+	repo_init_revisions(repo, &revs, NULL);
+	strvec_push(&args, "ignored");
+	strvec_push(&args, "--reverse");
+	strvec_push(&args, "--topo-order");
+	strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+		     oid_to_hex(&tip->object.oid));
+	setup_revisions_from_strvec(&args, &revs, NULL);
+
+	if (prepare_revision_walk(&revs) < 0) {
+		ret = error(_("error preparing revisions"));
+		goto out;
+	}
+
+	while ((commit = get_revision(&revs))) {
+		const char *message, *body;
+		struct strbuf one = STRBUF_INIT;
+
+		message = repo_logmsg_reencode(repo, commit, NULL, NULL);
+		find_commit_subject(message, &body);
+		strbuf_addstr(&one, body);
+		strbuf_trim_trailing_newline(&one);
+
+		if (n++)
+			strbuf_addch(out, '\n');
+		strbuf_addbuf(out, &one);
+		strbuf_addch(out, '\n');
+
+		strbuf_release(&one);
+		repo_unuse_commit_buffer(repo, commit, message);
+	}
+
+	ret = 0;
+
+out:
+	reset_revision_walk();
+	release_revisions(&revs);
+	strvec_clear(&args);
+	return ret;
+}
+
 static int cmd_history_squash(int argc,
 			      const char **argv,
 			      const char *prefix,
@@ -1121,6 +1171,7 @@ static int cmd_history_squash(int argc,
 		OPT_END(),
 	};
 	struct strbuf reflog_msg = STRBUF_INIT;
+	struct strbuf message = STRBUF_INIT;
 	struct oidset interior = OIDSET_INIT;
 	struct commit *base, *oldest, *tip, *rewritten;
 	const struct object_id *base_tree_oid, *tip_tree_oid;
@@ -1160,6 +1211,12 @@ static int cmd_history_squash(int argc,
 		}
 	}
 
+	if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+		ret = build_squash_message(repo, base, tip, &message);
+		if (ret < 0)
+			goto out;
+	}
+
 	ret = setup_revwalk(repo, action, tip, &revs);
 	if (ret < 0)
 		goto out;
@@ -1168,7 +1225,8 @@ static int cmd_history_squash(int argc,
 	tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
 	commit_list_append(base, &parents);
 
-	ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+	ret = commit_tree_ext(repo, "squash", oldest,
+			      message.len ? message.buf : NULL, parents,
 			      base_tree_oid, tip_tree_oid, &rewritten, flags);
 	if (ret < 0) {
 		ret = error(_("failed writing squashed commit"));
@@ -1189,6 +1247,7 @@ static int cmd_history_squash(int argc,
 
 out:
 	strbuf_release(&reflog_msg);
+	strbuf_release(&message);
 	oidset_clear(&interior);
 	commit_list_free(parents);
 	release_revisions(&revs);
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 94ee54eb24..5ef6768826 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -164,6 +164,43 @@ test_expect_success 'preserves authorship of the oldest commit' '
 	test_cmp expect actual
 '
 
+test_expect_success '--reedit-message offers every folded-in message' '
+	git reset --hard start &&
+	echo b >file &&
+	git add file &&
+	git commit -m "re-one subject" -m "re-one body line" &&
+	test_commit --no-tag re-two file c &&
+	test_commit re-three file d &&
+
+	write_script editor <<-\EOF &&
+	cp "$1" buffer &&
+	echo combined >"$1"
+	EOF
+	test_set_editor "$(pwd)/editor" &&
+	git history squash --reedit-message start.. &&
+
+	test_grep "re-one subject" buffer &&
+	test_grep "re-one body line" buffer &&
+	test_grep re-two buffer &&
+	test_grep re-three buffer &&
+	git log --format="%s" -1 >actual &&
+	echo combined >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+	git reset --hard three &&
+	head_before=$(git rev-parse HEAD) &&
+
+	write_script editor <<-\EOF &&
+	>"$1"
+	EOF
+	test_set_editor "$(pwd)/editor" &&
+	test_must_fail git history squash --reedit-message start.. &&
+
+	test_cmp_rev "$head_before" HEAD
+'
+
 test_expect_success '--dry-run predicts the rewrite without performing it' '
 	git reset --hard three &&
 	head_before=$(git rev-parse HEAD) &&
-- 
gitgitgadget

^ permalink raw reply related

* [PATCH v6 3/4] history: add squash subcommand to fold a range
From: Harald Nordgren via GitGitGadget @ 2026-06-28  8:29 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".

Add "git history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
message and authorship and taking the tree of the newest commit, then
replays the commits above the range on top. fixup!, squash! and amend!
commits are folded like any other and are not interpreted, so the
squashed message comes from the oldest commit, or from an editor with
--reedit-message.

The range is read like the arguments to "git rev-list", so several
arguments such as "@~3.. ^topic" are allowed. A merge inside the range
is folded when its other parent is reachable from the base, otherwise
the range has more than one base and is rejected. By default the command
also refuses when a ref points at a commit that the fold would discard.
Use --update-refs=head to rewrite only the current branch instead.

Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  28 ++
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 224 ++++++++++++++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 513 +++++++++++++++++++++++++++++++
 7 files changed, 772 insertions(+)
 create mode 100755 t/t3455-history-squash.sh

diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 257db58918..f4d692d136 100644
--- a/Documentation/config/advice.adoc
+++ b/Documentation/config/advice.adoc
@@ -55,6 +55,10 @@ all advice messages.
 	forceDeleteBranch::
 		Shown when the user tries to delete a not fully merged
 		branch without the force option set.
+	historyUpdateRefs::
+		Shown when `git history squash` refuses because a ref points
+		into the range being folded, to tell the user about
+		`--update-refs=head`.
 	ignoredHook::
 		Shown when a hook is ignored because the hook is not
 		set as executable.
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 2ba8121795..123ad5d4bc 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
 git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
 git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
 git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
 
 DESCRIPTION
 -----------
@@ -97,6 +98,33 @@ linkgit:gitglossary[7].
 It is invalid to select either all or no hunks, as that would lead to
 one of the commits becoming empty.
 
+`squash <revision-range>`::
+	Fold all commits in _<revision-range>_ into the oldest commit of that
+	range. The resulting commit keeps the oldest commit's message and
+	authorship and takes the tree of the range's newest commit, so the
+	whole range collapses into a single commit. Commits above the range
+	are replayed on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash @~3..` folds the three most recent commits into one, and
+`git history squash @~5..@~2` squashes an interior range while leaving
+the two newest commits in place. _<revision-range>_ is read like the
+arguments to linkgit:git-rev-list[1], so several arguments may be given,
+for example `@~3.. ^topic` to additionally exclude what is already on
+`topic`.
++
+The oldest commit's message and authorship are preserved by default,
+unless you specify `--reedit-message`. A merge commit inside the range is
+folded like any other, but the range must have a single base, so a range
+that reaches more than one entry point (for example a side branch that
+forked before the range and was later merged into it) is rejected.
++
+The folded commits disappear from the history, so with the default
+`--update-refs=branches` the command refuses when another ref points at
+one of them. Rerun with `--update-refs=head` to rewrite only the current
+branch and leave those refs pointing at the old commits.
+
 OPTIONS
 -------
 
diff --git a/advice.c b/advice.c
index 0018501b7b..5c6ff95e31 100644
--- a/advice.c
+++ b/advice.c
@@ -58,6 +58,7 @@ static struct {
 	[ADVICE_FETCH_SHOW_FORCED_UPDATES]		= { "fetchShowForcedUpdates" },
 	[ADVICE_FORCE_DELETE_BRANCH]			= { "forceDeleteBranch" },
 	[ADVICE_GRAFT_FILE_DEPRECATED]			= { "graftFileDeprecated" },
+	[ADVICE_HISTORY_UPDATE_REFS]			= { "historyUpdateRefs" },
 	[ADVICE_IGNORED_HOOK]				= { "ignoredHook" },
 	[ADVICE_IMPLICIT_IDENTITY]			= { "implicitIdentity" },
 	[ADVICE_MERGE_CONFLICT]				= { "mergeConflict" },
diff --git a/advice.h b/advice.h
index 8def280688..911b4e4643 100644
--- a/advice.h
+++ b/advice.h
@@ -25,6 +25,7 @@ enum advice_type {
 	ADVICE_FETCH_SHOW_FORCED_UPDATES,
 	ADVICE_FORCE_DELETE_BRANCH,
 	ADVICE_GRAFT_FILE_DEPRECATED,
+	ADVICE_HISTORY_UPDATE_REFS,
 	ADVICE_IGNORED_HOOK,
 	ADVICE_IMPLICIT_IDENTITY,
 	ADVICE_MERGE_CONFLICT,
diff --git a/builtin/history.c b/builtin/history.c
index 305bde3102..5a1b42c063 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
 #define USE_THE_REPOSITORY_VARIABLE
 
 #include "builtin.h"
+#include "advice.h"
 #include "cache-tree.h"
 #include "commit.h"
 #include "commit-reach.h"
@@ -30,6 +31,8 @@
 	N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
 #define GIT_HISTORY_SPLIT_USAGE \
 	N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
+#define GIT_HISTORY_SQUASH_USAGE \
+	N_("git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]")
 
 static void change_data_free(void *util, const char *str UNUSED)
 {
@@ -973,6 +976,225 @@ out:
 	return ret;
 }
 
+/*
+ * Resolve a "<base>..<tip>" revision range into the base commit just outside
+ * the range (which becomes the parent of the squashed commit), the oldest
+ * commit contained in the range (whose message the squash reuses), and the
+ * range tip (whose tree becomes the result). A merge inside the range is fine,
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+				const char **argv,
+				struct commit **base_out,
+				struct commit **oldest_out,
+				struct commit **tip_out,
+				struct oidset *interior_out)
+{
+	struct rev_info revs;
+	struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+	struct commit_list *boundaries = NULL, *b;
+	struct strvec args = STRVEC_INIT;
+	size_t i;
+	int ret;
+
+	repo_init_revisions(repo, &revs, NULL);
+	strvec_push(&args, "ignored");
+	strvec_push(&args, "--reverse");
+	strvec_push(&args, "--topo-order");
+	strvec_push(&args, "--boundary");
+	strvec_push(&args, "--ancestry-path");
+	strvec_pushv(&args, argv);
+	setup_revisions_from_strvec(&args, &revs, NULL);
+	if (args.nr != 1) {
+		ret = error(_("unrecognized argument: %s"), args.v[1]);
+		goto out;
+	}
+
+	/*
+	 * A squash needs a base to reparent onto, so the range has to exclude
+	 * something, as in "<base>..<tip>". A revision range with no such
+	 * bottom commit cannot be squashed.
+	 */
+	for (i = 0; i < revs.cmdline.nr; i++)
+		if (revs.cmdline.rev[i].flags & UNINTERESTING)
+			break;
+	if (i == revs.cmdline.nr) {
+		ret = error(_("not a '<base>..<tip>' revision range"));
+		goto out;
+	}
+
+	if (prepare_revision_walk(&revs) < 0) {
+		ret = error(_("error preparing revisions"));
+		goto out;
+	}
+
+	while ((commit = get_revision(&revs))) {
+		if (commit->object.flags & BOUNDARY) {
+			commit_list_insert(commit, &boundaries);
+			continue;
+		}
+		if (!oldest)
+			oldest = commit;
+		if (tip)
+			oidset_insert(interior_out, &tip->object.oid);
+		tip = commit;
+	}
+
+	if (!oldest) {
+		ret = error(_("the revision range is empty"));
+		goto out;
+	}
+
+	if (oldest == tip) {
+		ret = error(_("the revision range holds a single commit; "
+			      "nothing to squash"));
+		goto out;
+	}
+
+	if (!oldest->parents)
+		BUG("an in-range commit must have a parent");
+	base = oldest->parents->item;
+
+	/*
+	 * A boundary other than the base is an in-range commit reaching a
+	 * commit outside the range, so the range has more than one base.
+	 */
+	for (b = boundaries; b; b = b->next) {
+		if (b->item != base) {
+			ret = error(_("the revision range has more than one base; "
+				      "cannot squash"));
+			goto out;
+		}
+	}
+
+	*base_out = base;
+	*oldest_out = oldest;
+	*tip_out = tip;
+	ret = 0;
+
+out:
+	commit_list_free(boundaries);
+	reset_revision_walk();
+	release_revisions(&revs);
+	strvec_clear(&args);
+	return ret;
+}
+
+struct interior_ref_cb {
+	const struct oidset *interior;
+	const char *name;
+};
+
+static int find_interior_ref(const struct reference *ref, void *cb_data)
+{
+	struct interior_ref_cb *data = cb_data;
+
+	if (oidset_contains(data->interior, ref->oid)) {
+		data->name = xstrdup(ref->name);
+		return 1;
+	}
+
+	return 0;
+}
+
+static int cmd_history_squash(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_SQUASH_USAGE,
+		NULL,
+	};
+	enum ref_action action = REF_ACTION_DEFAULT;
+	enum commit_tree_flags flags = 0;
+	int dry_run = 0;
+	struct option options[] = {
+		OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+			       N_("control which refs should be updated"),
+			       PARSE_OPT_NONEG, parse_ref_action),
+		OPT_BOOL('n', "dry-run", &dry_run,
+			 N_("perform a dry-run without updating any refs")),
+		OPT_BIT(0, "reedit-message", &flags,
+			N_("open an editor to modify the commit message"),
+			COMMIT_TREE_EDIT_MESSAGE),
+		OPT_END(),
+	};
+	struct strbuf reflog_msg = STRBUF_INIT;
+	struct oidset interior = OIDSET_INIT;
+	struct commit *base, *oldest, *tip, *rewritten;
+	const struct object_id *base_tree_oid, *tip_tree_oid;
+	struct commit_list *parents = NULL;
+	struct rev_info revs = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (!argc) {
+		ret = error(_("command expects a revision range"));
+		goto out;
+	}
+	repo_config(repo, git_default_config, NULL);
+
+	if (action == REF_ACTION_DEFAULT)
+		action = REF_ACTION_BRANCHES;
+
+	ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
+				   &interior);
+	if (ret < 0)
+		goto out;
+
+	if (action == REF_ACTION_BRANCHES) {
+		struct interior_ref_cb cb = { .interior = &interior };
+
+		refs_for_each_ref(get_main_ref_store(repo),
+				  find_interior_ref, &cb);
+		if (cb.name) {
+			ret = error(_("'%s' points into the squashed range"),
+				    cb.name);
+			advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
+					  _("Use --update-refs=head to rewrite only "
+					    "the current branch and leave such refs "
+					    "untouched."));
+			free((char *)cb.name);
+			goto out;
+		}
+	}
+
+	ret = setup_revwalk(repo, action, tip, &revs);
+	if (ret < 0)
+		goto out;
+
+	base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+	tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+	commit_list_append(base, &parents);
+
+	ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+			      base_tree_oid, tip_tree_oid, &rewritten, flags);
+	if (ret < 0) {
+		ret = error(_("failed writing squashed commit"));
+		goto out;
+	}
+
+	strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
+
+	ret = handle_reference_updates(&revs, action, tip, rewritten,
+				       reflog_msg.buf, dry_run,
+				       REPLAY_EMPTY_COMMIT_ABORT);
+	if (ret < 0) {
+		ret = error(_("failed replaying descendants"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&reflog_msg);
+	oidset_clear(&interior);
+	commit_list_free(parents);
+	release_revisions(&revs);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -982,6 +1204,7 @@ int cmd_history(int argc,
 		GIT_HISTORY_FIXUP_USAGE,
 		GIT_HISTORY_REWORD_USAGE,
 		GIT_HISTORY_SPLIT_USAGE,
+		GIT_HISTORY_SQUASH_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -989,6 +1212,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
 		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
 		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
+		OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 3219264fe7..63ea26b8ed 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -399,6 +399,7 @@ integration_tests = [
   't3451-history-reword.sh',
   't3452-history-split.sh',
   't3453-history-fixup.sh',
+  't3455-history-squash.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
new file mode 100755
index 0000000000..94ee54eb24
--- /dev/null
+++ b/t/t3455-history-squash.sh
@@ -0,0 +1,513 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'setup linear history touching two files' '
+	test_commit base file a &&
+	git tag start &&
+	test_commit --no-tag one other x &&
+	test_commit --no-tag two file c &&
+	test_commit three file d
+'
+
+test_expect_success 'errors on missing range argument' '
+	test_must_fail git history squash 2>err &&
+	test_grep "expects a revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+	test_must_fail git history squash HEAD..HEAD 2>err &&
+	test_grep "the revision range is empty" err
+'
+
+test_expect_success 'errors on a single revision that is not a range' '
+	test_must_fail git history squash HEAD 2>err &&
+	test_grep "not a .*range" err &&
+	test_must_fail git history squash HEAD~1 2>err &&
+	test_grep "not a .*range" err
+'
+
+test_expect_success 'errors on a range holding a single commit' '
+	git reset --hard three &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash "HEAD^!" 2>err &&
+	test_grep "single commit; nothing to squash" err &&
+	test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'accepts multiple revision arguments with an exclusion' '
+	git reset --hard three &&
+	git branch -f keep HEAD~2 &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start..HEAD ^keep &&
+
+	git log --format="%s" start..HEAD >actual &&
+	cat >expect <<-\EOF &&
+	two
+	one
+	EOF
+	test_cmp expect actual &&
+	test_cmp_rev keep HEAD~1 &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+	git branch -D keep
+'
+
+test_expect_success 'squashes a branch the current branch is not on' '
+	git reset --hard three &&
+	main=$(git symbolic-ref --short HEAD) &&
+	head_before=$(git rev-parse HEAD) &&
+	git checkout -b off-history start &&
+	test_commit --no-tag off-one off a &&
+	test_commit --no-tag off-two off b &&
+	git checkout "$main" &&
+
+	git history squash start..off-history &&
+
+	git rev-list --count start..off-history >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git branch -D off-history
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
+	git reset --hard three &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev start HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	git log --format="%s" -1 >subject &&
+	echo one >expect &&
+	test_cmp expect subject &&
+	git reflog >reflog &&
+	test_grep "squash: updating" reflog
+'
+
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+	git reset --hard three &&
+	final_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start..@~1 &&
+
+	git log --format="%s" start..HEAD >actual &&
+	cat >expect <<-\EOF &&
+	three
+	one
+	EOF
+	test_cmp expect actual &&
+
+	test_cmp_rev start HEAD~2 &&
+	test "$final_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes when the base is the root commit' '
+	git reset --hard three &&
+	root=$(git rev-list --max-parents=0 HEAD) &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash "$root.." &&
+
+	git rev-list --count "$root..HEAD" >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$root" HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+
+test_expect_success 'reuses the message of a fixup! commit in the range' '
+	git reset --hard start &&
+	test_commit --no-tag reg1 file b &&
+	git commit --allow-empty -m "fixup! reg1" &&
+	test_commit reg2 file c &&
+
+	git history squash start.. &&
+
+	git log --format="%s" -1 >actual &&
+	echo reg1 >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'keeps the oldest message even if it is a fixup!' '
+	git reset --hard start &&
+	test_commit --no-tag "fixup! something" file b &&
+	test_commit tail file c &&
+
+	git history squash start.. &&
+
+	git log --format="%s" -1 >actual &&
+	echo "fixup! something" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'preserves authorship of the oldest commit' '
+	git reset --hard start &&
+	GIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \
+		test_commit --no-tag oldest file b &&
+	test_commit newest file c &&
+
+	git history squash start.. &&
+
+	git log -1 --format="%an <%ae>" >actual &&
+	echo "Squasher <squash@example.com>" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--dry-run predicts the rewrite without performing it' '
+	git reset --hard three &&
+	head_before=$(git rev-parse HEAD) &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash --dry-run start.. >out &&
+	predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git history squash start.. &&
+	test "$predicted" = "$(git rev-parse HEAD)" &&
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev start HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success '--update-refs=head only moves HEAD' '
+	git reset --hard three &&
+	git branch -f other HEAD &&
+	other_before=$(git rev-parse other) &&
+
+	git history squash --update-refs=head start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$other_before" other
+'
+
+test_expect_success 'refuses to fold a range a ref points into' '
+	git reset --hard three &&
+	git branch -f mid HEAD~1 &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "error: .* points into the squashed range" err &&
+	test_grep "hint: .*--update-refs=head" err &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git branch -D mid
+'
+
+test_expect_success 'advice.historyUpdateRefs silences the hint' '
+	git reset --hard three &&
+	git branch -f mid HEAD~1 &&
+
+	test_must_fail git -c advice.historyUpdateRefs=false \
+		history squash start.. 2>err &&
+	test_grep "points into the squashed range" err &&
+	test_grep ! "hint:" err &&
+
+	git branch -D mid
+'
+
+test_expect_success '--update-refs=head folds past a ref pointing into the range' '
+	git reset --hard three &&
+	git branch -f mid HEAD~1 &&
+	mid_before=$(git rev-parse mid) &&
+
+	git history squash --update-refs=head start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$mid_before" mid &&
+
+	git branch -D mid
+'
+
+test_expect_success 'refuses to fold a range a tag points into' '
+	git reset --hard three &&
+	git tag -f mark HEAD~1 &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "refs/tags/mark" err &&
+	test_grep "points into the squashed range" err &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git tag -d mark
+'
+
+test_expect_success 'squashes a range whose internal merge has a single base' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag before-side file b &&
+	git checkout -b inner-side &&
+	test_commit --no-tag on-inner-side inner x &&
+	git checkout "$main" &&
+	test_commit --no-tag after-side file c &&
+	git merge --no-ff -m merge inner-side &&
+	git branch -D inner-side &&
+	test_commit --no-tag after-merge file d &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	git log --format="%s" -1 >subject &&
+	echo before-side >expect &&
+	test_cmp expect subject &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file inner
+'
+
+test_expect_success 'folds a merge of a branch that forked at the base' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b base-fork-side &&
+	test_commit --no-tag base-fork-side side x &&
+	git checkout "$main" &&
+	test_commit --no-tag base-fork-main file b &&
+	git merge --no-ff -m "merge base-fork-side" base-fork-side &&
+	git branch -D base-fork-side &&
+	test_commit --no-tag base-fork-tail file c &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev start HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file side
+'
+
+test_expect_success 'refuses a merge whose other parent is outside the range' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b outside-parent &&
+	test_commit --no-tag outside-parent outside x &&
+	git checkout "$main" &&
+	test_commit --no-tag outside-main file b &&
+	base=$(git rev-parse HEAD) &&
+	test_commit --no-tag outside-mid file c &&
+	git merge --no-ff -m "merge outside-parent" outside-parent &&
+	git branch -D outside-parent &&
+	merged=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash "$base.." 2>err &&
+	test_grep "more than one base" err &&
+	test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range whose tip is a merge commit' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag tipmerge-base file b &&
+	git checkout -b tipmerge-side &&
+	test_commit --no-tag tipmerge-side side x &&
+	git checkout "$main" &&
+	test_commit --no-tag tipmerge-main file c &&
+	git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
+	git branch -D tipmerge-side &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file side
+'
+
+test_expect_success 'folds a range whose base is a merge commit' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b basemerge-side &&
+	test_commit --no-tag basemerge-side side x &&
+	git checkout "$main" &&
+	test_commit --no-tag basemerge-main file b &&
+	git merge --no-ff -m "merge basemerge-side" basemerge-side &&
+	git branch -D basemerge-side &&
+	base=$(git rev-parse HEAD) &&
+	test_commit --no-tag basemerge-one file c &&
+	test_commit --no-tag basemerge-two file d &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash "$base.." &&
+
+	git rev-list --count "$base..HEAD" >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$base" HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'refuses to squash a range with more than one base' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b forked-before &&
+	test_commit forked-side fside x &&
+	git checkout "$main" &&
+	test_commit forked-base file b &&
+	base=$(git rev-parse HEAD) &&
+	test_commit forked-main file c &&
+	git merge --no-ff -m merge forked-before &&
+	merged=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash "$base.." 2>err &&
+	test_grep "more than one base" err &&
+	test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range with two interior merges' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag two-merge-a file a1 &&
+	git checkout -b two-merge-s1 &&
+	test_commit --no-tag two-merge-s1 s1 x &&
+	git checkout "$main" &&
+	git merge --no-ff -m "merge s1" two-merge-s1 &&
+	test_commit --no-tag two-merge-b file b1 &&
+	git checkout -b two-merge-s2 &&
+	test_commit --no-tag two-merge-s2 s2 y &&
+	git checkout "$main" &&
+	git merge --no-ff -m "merge s2" two-merge-s2 &&
+	git branch -D two-merge-s1 two-merge-s2 &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file s1 &&
+	test_path_is_file s2
+'
+
+test_expect_success 'folds a range with a nested merge' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b nested-outer &&
+	test_commit --no-tag nested-outer outer x &&
+	git checkout -b nested-inner &&
+	test_commit --no-tag nested-inner inner y &&
+	git checkout nested-outer &&
+	git merge --no-ff -m "merge inner" nested-inner &&
+	git checkout "$main" &&
+	test_commit --no-tag nested-main file b1 &&
+	git merge --no-ff -m "merge outer" nested-outer &&
+	git branch -D nested-outer nested-inner &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file outer &&
+	test_path_is_file inner
+'
+
+test_expect_success 'folds a range with an octopus merge' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag octo-base file a1 &&
+	git checkout -b octo-1 &&
+	test_commit --no-tag octo-1 o1 x &&
+	git checkout "$main" &&
+	git checkout -b octo-2 &&
+	test_commit --no-tag octo-2 o2 y &&
+	git checkout "$main" &&
+	git merge --no-ff -m octopus octo-1 octo-2 &&
+	git branch -D octo-1 octo-2 &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file o1 &&
+	test_path_is_file o2
+'
+
+test_expect_success 'refuses an octopus merge with an arm forked before the base' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b octo-pre &&
+	test_commit octo-pre-side pside x &&
+	git checkout "$main" &&
+	test_commit octo-pre-main file b1 &&
+	octo_base=$(git rev-parse HEAD) &&
+	git checkout -b octo-within &&
+	test_commit --no-tag octo-within wside y &&
+	git checkout "$main" &&
+	git merge --no-ff -m octopus octo-pre octo-within &&
+	merged=$(git rev-parse HEAD) &&
+	git branch -D octo-pre octo-within &&
+
+	test_must_fail git history squash "$octo_base.." 2>err &&
+	test_grep "more than one base" err &&
+	test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'refuses when a descendant above the range is a merge' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag desc-one file b &&
+	test_commit --no-tag desc-two file c &&
+	git tag desc-tip &&
+	git checkout -b desc-above &&
+	test_commit --no-tag desc-above above x &&
+	git checkout "$main" &&
+	test_commit --no-tag desc-main file d &&
+	git merge --no-ff -m "merge desc-above" desc-above &&
+	git branch -D desc-above &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash start..desc-tip 2>err &&
+	test_grep "merge commits is not supported" err &&
+	test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'refuses to fold a range a ref points into at a merge' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag refmerge-base file b &&
+	git checkout -b refmerge-side &&
+	test_commit --no-tag refmerge-side side x &&
+	git checkout "$main" &&
+	test_commit --no-tag refmerge-main file c &&
+	git merge --no-ff -m "interior merge" refmerge-side &&
+	git branch -D refmerge-side &&
+	git branch at-merge HEAD &&
+	test_commit --no-tag refmerge-tail file d &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "at-merge" err &&
+	test_grep "points into the squashed range" err &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git branch -D at-merge
+'
+
+test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v6 2/4] history: give commit_tree_ext a message template
From: Harald Nordgren via GitGitGadget @ 2026-06-28  8:29 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

commit_tree_ext() reuses the message of the commit it is handed. A
caller that folds several commits together wants to seed the message
from more than that single commit, so add an optional message_template
parameter. When NULL, the behavior is unchanged.

Pass NULL from the existing fixup and split callers.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/history.c | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/builtin/history.c b/builtin/history.c
index f95f26e684..305bde3102 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -101,6 +101,7 @@ enum commit_tree_flags {
 static int commit_tree_ext(struct repository *repo,
 			   const char *action,
 			   struct commit *commit_with_message,
+			   const char *message_template,
 			   const struct commit_list *parents,
 			   const struct object_id *old_tree,
 			   const struct object_id *new_tree,
@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,
 		original_author = xmemdupz(ptr, len);
 	find_commit_subject(original_message, &original_body);
 
+	if (!message_template)
+		message_template = original_body;
+
 	if (flags & COMMIT_TREE_EDIT_MESSAGE) {
 		ret = fill_commit_message(repo, old_tree, new_tree,
-					  original_body, action, &commit_message);
+					  message_template, action, &commit_message);
 		if (ret < 0)
 			goto out;
 	} else {
-		strbuf_addstr(&commit_message, original_body);
+		strbuf_addstr(&commit_message, message_template);
 	}
 
 	original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
 	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
 		return -1;
 
-	return commit_tree_ext(repo, action, original, original->parents,
+	return commit_tree_ext(repo, action, original, NULL, original->parents,
 			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
 }
 
@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,
 		goto out;
 
 	if (!skip_commit) {
-		ret = commit_tree_ext(repo, "fixup", original, original->parents,
+		ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
 				      &original_tree->object.oid, &merge_result.tree->object.oid,
 				      &rewritten, flags);
 		if (ret < 0) {
@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,
 	 * The first commit is constructed from the split-out tree. The base
 	 * that shall be diffed against is the parent of the original commit.
 	 */
-	ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+	ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
 			      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
 	if (ret < 0) {
 		ret = error(_("failed writing first commit"));
@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,
 	old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
 	new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
 
-	ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+	ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
 			      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
 	if (ret < 0) {
 		ret = error(_("failed writing second commit"));
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v6 1/4] history: extract helper for a commit's parent tree
From: Harald Nordgren via GitGitGadget @ 2026-06-28  8:29 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Three places resolve the tree of a commit's first parent, falling back
to the empty tree for a root commit, each repeating the same parse and
oidcpy dance. Extract a first_parent_tree_oid() helper and route the
existing callers through it.

No change in behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/history.c | 58 +++++++++++++++++++++--------------------------
 1 file changed, 26 insertions(+), 32 deletions(-)

diff --git a/builtin/history.c b/builtin/history.c
index 091465a59e..f95f26e684 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -157,6 +157,25 @@ out:
 	return ret;
 }
 
+static int first_parent_tree_oid(struct repository *repo,
+				 struct commit *commit,
+				 struct object_id *out)
+{
+	struct commit *parent = commit->parents ? commit->parents->item : NULL;
+
+	if (!parent) {
+		oidcpy(out, repo->hash_algo->empty_tree);
+		return 0;
+	}
+
+	if (repo_parse_commit(repo, parent))
+		return error(_("unable to parse parent commit %s"),
+			     oid_to_hex(&parent->object.oid));
+
+	oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
+	return 0;
+}
+
 static int commit_tree_with_edited_message(struct repository *repo,
 					   const char *action,
 					   struct commit *original,
@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,
 {
 	struct object_id parent_tree_oid;
 	const struct object_id *tree_oid;
-	struct commit *parent;
 
 	tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
 
-	parent = original->parents ? original->parents->item : NULL;
-	if (parent) {
-		if (repo_parse_commit(repo, parent)) {
-			return error(_("unable to parse parent commit %s"),
-				     oid_to_hex(&parent->object.oid));
-		}
-
-		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
-	} else {
-		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
-	}
+	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+		return -1;
 
 	return commit_tree_ext(repo, action, original, original->parents,
 			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,
 			       struct commit *original,
 			       struct tree *result)
 {
-	struct commit *parent = original->parents ? original->parents->item : NULL;
 	struct object_id parent_tree_oid;
 
-	if (parent) {
-		if (repo_parse_commit(repo, parent))
-			return error(_("unable to parse parent of %s"),
-				     oid_to_hex(&original->object.oid));
-
-		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
-	} else {
-		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
-	}
+	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+		return -1;
 
 	return oideq(&result->object.oid, &parent_tree_oid);
 }
@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,
 	struct tree *split_tree;
 	int ret;
 
-	if (original->parents) {
-		if (repo_parse_commit(repo, original->parents->item)) {
-			ret = error(_("unable to parse parent commit %s"),
-				    oid_to_hex(&original->parents->item->object.oid));
-			goto out;
-		}
-
-		parent_tree_oid = *get_commit_tree_oid(original->parents->item);
-	} else {
-		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
+		ret = -1;
+		goto out;
 	}
 	original_commit_tree_oid = get_commit_tree_oid(original);
 
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v6 0/4] history: add squash subcommand to fold a range
From: Harald Nordgren via GitGitGadget @ 2026-06-28  8:29 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren
In-Reply-To: <pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com>

Adds git history squash <revision-range> to fold a range of commits.

Changes in v6:

 * git history squash now accepts multiple revision arguments, read like the
   arguments to git-rev-list, so a compound range such as @~3.. ^topic
   works.
 * The base to reparent onto is now the oldest in-range commit's parent; a
   boundary other than that base means the range has more than one base and
   is rejected. This also fixes the earlier overly-restrictive handling of
   merges and side branches.
 * A single-commit range (e.g. @^!) is rejected with "nothing to squash"
   (this also covers the @^!-style example that previously succeeded
   silently).
 * Commit messages reworded: the squash commit now gives an overview of
   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.

Changes in v5:

 * The range walk now uses --ancestry-path, so only commits descended from
   the base are folded; a single revision such as HEAD or HEAD~1 is now
   rejected as "not a <base>..<tip> range" rather than treated as a squash
   down to the root.
 * This adopts the --ancestry-path suggestion; the multi-base rejection is
   unchanged, so a side branch that forked before the base and merged in is
   still refused.
 * Added tests covering more merge topologies: two interior merges, a nested
   merge, an octopus merge, an octopus arm forked before the base, a merge
   among the descendants replayed above the range, and a ref pointing at an
   interior merge commit.

Changes in v4:

 * git history squash now detects when another ref points at a commit inside
   the range being folded and refuses, with an advice.historyUpdateRefs hint
   to use --update-refs=head.
 * A merge inside the range is folded fine as long as the range has a single
   base; a range with merge commit at the tip or base also folds correctly.
   Only a range with more than one base is rejected.

Changes in v3:

 * Moved the feature out of git rebase and into a new git history squash
   <revision-range> subcommand, per the list discussion. git rebase --squash
   is dropped.
 * Takes an arbitrary range (git history squash @~3.., git history squash
   @~5..@~2), folding it into the oldest commit and replaying any
   descendants on top.
 * Implemented as a single tree operation rather than picking each commit,
   so there are no repeated conflict stops (addresses Phillip's efficiency
   point).
 * A merge inside the range is folded fine, only a range with more than one
   base is rejected.
 * --reedit-message seeds the editor with every folded-in message, not just
   the oldest.

Harald Nordgren (4):
  history: extract helper for a commit's parent tree
  history: give commit_tree_ext a message template
  history: add squash subcommand to fold a range
  history: re-edit a squash with every message

 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  29 ++
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 357 +++++++++++++++++---
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 550 +++++++++++++++++++++++++++++++
 7 files changed, 905 insertions(+), 38 deletions(-)
 create mode 100755 t/t3455-history-squash.sh


base-commit: 6c3d7b73556db708feb3b16232fab1efc4353428
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v6
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v6
Pull-Request: https://github.com/git/git/pull/2337

Range-diff vs v5:

 1:  0f1ae9b05a = 1:  fea6b79e60 history: extract helper for a commit's parent tree
 2:  a97ffab1e6 = 2:  e2674e0bc4 history: give commit_tree_ext a message template
 3:  04e18ef979 ! 3:  811e393ab4 history: add squash subcommand to fold a range
     @@ Commit message
      
          Add "git history squash <revision-range>" to do this directly. It folds
          every commit in the range into the oldest one, keeping that commit's
     -    message and authorship and taking the tree of the newest commit, so the
     -    range collapses into a single commit. Commits above the range are
     -    replayed on top of the result.
     +    message and authorship and taking the tree of the newest commit, then
     +    replays the commits above the range on top. fixup!, squash! and amend!
     +    commits are folded like any other and are not interpreted, so the
     +    squashed message comes from the oldest commit, or from an editor with
     +    --reedit-message.
      
     -    The range is given as <base>..<tip>, so "git history squash @~3.."
     -    folds the three most recent commits and "git history squash @~5..@~2"
     -    squashes an interior range. A merge inside the range is folded like any
     -    other commit, but the range must have a single base, so a range with
     -    more than one entry point is rejected.
     -
     -    The folded commits leave the history, so by default the command refuses
     -    when another ref points at one of them. Use "--update-refs=head" to
     -    rewrite only the current branch and leave those refs untouched.
     +    The range is read like the arguments to "git rev-list", so several
     +    arguments such as "@~3.. ^topic" are allowed. A merge inside the range
     +    is folded when its other parent is reachable from the base, otherwise
     +    the range has more than one base and is rejected. By default the command
     +    also refuses when a ref points at a commit that the fold would discard.
     +    Use --update-refs=head to rewrite only the current branch instead.
      
          Inspired-by: Sergey Chernov <serega.morph@gmail.com>
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
      +the commit just below the oldest commit to squash. For example, `git
      +history squash @~3..` folds the three most recent commits into one, and
      +`git history squash @~5..@~2` squashes an interior range while leaving
     -+the two newest commits in place.
     ++the two newest commits in place. _<revision-range>_ is read like the
     ++arguments to linkgit:git-rev-list[1], so several arguments may be given,
     ++for example `@~3.. ^topic` to additionally exclude what is already on
     ++`topic`.
      ++
      +The oldest commit's message and authorship are preserved by default,
      +unless you specify `--reedit-message`. A merge commit inside the range is
     @@ builtin/history.c: out:
      + * but the range must have a single base and must not reach a root commit.
      + */
      +static int resolve_squash_range(struct repository *repo,
     -+				const char *range,
     ++				const char **argv,
      +				struct commit **base_out,
      +				struct commit **oldest_out,
      +				struct commit **tip_out,
     @@ builtin/history.c: out:
      +{
      +	struct rev_info revs;
      +	struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
     ++	struct commit_list *boundaries = NULL, *b;
      +	struct strvec args = STRVEC_INIT;
      +	size_t i;
      +	int ret;
     @@ builtin/history.c: out:
      +	strvec_push(&args, "--topo-order");
      +	strvec_push(&args, "--boundary");
      +	strvec_push(&args, "--ancestry-path");
     -+	strvec_push(&args, range);
     ++	strvec_pushv(&args, argv);
      +	setup_revisions_from_strvec(&args, &revs, NULL);
      +	if (args.nr != 1) {
     -+		ret = error(_("'%s' does not name a revision range"), range);
     ++		ret = error(_("unrecognized argument: %s"), args.v[1]);
      +		goto out;
      +	}
      +
      +	/*
     -+	 * A squash needs a base to reparent onto, so the argument has to
     -+	 * exclude something, as in "<base>..<tip>". A single revision has no
     -+	 * such bottom commit and cannot be squashed.
     ++	 * A squash needs a base to reparent onto, so the range has to exclude
     ++	 * something, as in "<base>..<tip>". A revision range with no such
     ++	 * bottom commit cannot be squashed.
      +	 */
      +	for (i = 0; i < revs.cmdline.nr; i++)
      +		if (revs.cmdline.rev[i].flags & UNINTERESTING)
      +			break;
      +	if (i == revs.cmdline.nr) {
     -+		ret = error(_("'%s' is not a '<base>..<tip>' range"), range);
     ++		ret = error(_("not a '<base>..<tip>' revision range"));
      +		goto out;
      +	}
      +
     @@ builtin/history.c: out:
      +
      +	while ((commit = get_revision(&revs))) {
      +		if (commit->object.flags & BOUNDARY) {
     -+			if (base) {
     -+				ret = error(_("range '%s' has more than one base; "
     -+					      "cannot squash"), range);
     -+				goto out;
     -+			}
     -+			base = commit;
     ++			commit_list_insert(commit, &boundaries);
      +			continue;
      +		}
      +		if (!oldest)
     @@ builtin/history.c: out:
      +	}
      +
      +	if (!oldest) {
     -+		ret = error(_("the range '%s' is empty"), range);
     ++		ret = error(_("the revision range is empty"));
     ++		goto out;
     ++	}
     ++
     ++	if (oldest == tip) {
     ++		ret = error(_("the revision range holds a single commit; "
     ++			      "nothing to squash"));
      +		goto out;
      +	}
      +
     -+	if (!base)
     -+		BUG("a non-empty range must have a boundary commit");
     ++	if (!oldest->parents)
     ++		BUG("an in-range commit must have a parent");
     ++	base = oldest->parents->item;
     ++
     ++	/*
     ++	 * A boundary other than the base is an in-range commit reaching a
     ++	 * commit outside the range, so the range has more than one base.
     ++	 */
     ++	for (b = boundaries; b; b = b->next) {
     ++		if (b->item != base) {
     ++			ret = error(_("the revision range has more than one base; "
     ++				      "cannot squash"));
     ++			goto out;
     ++		}
     ++	}
      +
      +	*base_out = base;
      +	*oldest_out = oldest;
     @@ builtin/history.c: out:
      +	ret = 0;
      +
      +out:
     ++	commit_list_free(boundaries);
      +	reset_revision_walk();
      +	release_revisions(&revs);
      +	strvec_clear(&args);
     @@ builtin/history.c: out:
      +	int ret;
      +
      +	argc = parse_options(argc, argv, prefix, options, usage, 0);
     -+	if (argc != 1) {
     -+		ret = error(_("command expects a single revision range"));
     ++	if (!argc) {
     ++		ret = error(_("command expects a revision range"));
      +		goto out;
      +	}
      +	repo_config(repo, git_default_config, NULL);
     @@ builtin/history.c: out:
      +	if (action == REF_ACTION_DEFAULT)
      +		action = REF_ACTION_BRANCHES;
      +
     -+	ret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip,
     ++	ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
      +				   &interior);
      +	if (ret < 0)
      +		goto out;
     @@ t/t3455-history-squash.sh (new)
      +
      +test_expect_success 'errors on missing range argument' '
      +	test_must_fail git history squash 2>err &&
     -+	test_grep "command expects a single revision range" err
     -+'
     -+
     -+test_expect_success 'errors on too many arguments' '
     -+	test_must_fail git history squash start.. HEAD 2>err &&
     -+	test_grep "command expects a single revision range" err
     ++	test_grep "expects a revision range" err
      +'
      +
      +test_expect_success 'errors on an empty range' '
      +	test_must_fail git history squash HEAD..HEAD 2>err &&
     -+	test_grep "the range .* is empty" err
     ++	test_grep "the revision range is empty" err
      +'
      +
      +test_expect_success 'errors on a single revision that is not a range' '
      +	test_must_fail git history squash HEAD 2>err &&
     -+	test_grep "is not a .*range" err &&
     ++	test_grep "not a .*range" err &&
      +	test_must_fail git history squash HEAD~1 2>err &&
     -+	test_grep "is not a .*range" err
     ++	test_grep "not a .*range" err
     ++'
     ++
     ++test_expect_success 'errors on a range holding a single commit' '
     ++	git reset --hard three &&
     ++	head_before=$(git rev-parse HEAD) &&
     ++
     ++	test_must_fail git history squash "HEAD^!" 2>err &&
     ++	test_grep "single commit; nothing to squash" err &&
     ++	test_cmp_rev "$head_before" HEAD
     ++'
     ++
     ++test_expect_success 'accepts multiple revision arguments with an exclusion' '
     ++	git reset --hard three &&
     ++	git branch -f keep HEAD~2 &&
     ++	tip_tree=$(git rev-parse HEAD^{tree}) &&
     ++
     ++	git history squash start..HEAD ^keep &&
     ++
     ++	git log --format="%s" start..HEAD >actual &&
     ++	cat >expect <<-\EOF &&
     ++	two
     ++	one
     ++	EOF
     ++	test_cmp expect actual &&
     ++	test_cmp_rev keep HEAD~1 &&
     ++	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
     ++
     ++	git branch -D keep
     ++'
     ++
     ++test_expect_success 'squashes a branch the current branch is not on' '
     ++	git reset --hard three &&
     ++	main=$(git symbolic-ref --short HEAD) &&
     ++	head_before=$(git rev-parse HEAD) &&
     ++	git checkout -b off-history start &&
     ++	test_commit --no-tag off-one off a &&
     ++	test_commit --no-tag off-two off b &&
     ++	git checkout "$main" &&
     ++
     ++	git history squash start..off-history &&
     ++
     ++	git rev-list --count start..off-history >count &&
     ++	echo 1 >expect &&
     ++	test_cmp expect count &&
     ++	test_cmp_rev "$head_before" HEAD &&
     ++
     ++	git branch -D off-history
      +'
      +
      +test_expect_success 'squashes a range into a single commit without changing the tree' '
     @@ t/t3455-history-squash.sh (new)
      +	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
      +'
      +
     -+test_expect_success 'squashing a single-commit range replays the rest' '
     -+	git reset --hard three &&
     -+	tip_tree=$(git rev-parse HEAD^{tree}) &&
     -+
     -+	git history squash start..@~2 &&
     -+
     -+	git log --format="%s" start..HEAD >actual &&
     -+	cat >expect <<-\EOF &&
     -+	three
     -+	two
     -+	one
     -+	EOF
     -+	test_cmp expect actual &&
     -+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
     -+'
      +
      +test_expect_success 'reuses the message of a fixup! commit in the range' '
      +	git reset --hard start &&
     @@ t/t3455-history-squash.sh (new)
      +
      +test_expect_success 'squashes a range whose internal merge has a single base' '
      +	git reset --hard start &&
     ++	main=$(git symbolic-ref --short HEAD) &&
      +	test_commit --no-tag before-side file b &&
      +	git checkout -b inner-side &&
      +	test_commit --no-tag on-inner-side inner x &&
     -+	git checkout - &&
     ++	git checkout "$main" &&
      +	test_commit --no-tag after-side file c &&
      +	git merge --no-ff -m merge inner-side &&
      +	git branch -D inner-side &&
     @@ t/t3455-history-squash.sh (new)
      +
      +test_expect_success 'folds a merge of a branch that forked at the base' '
      +	git reset --hard start &&
     ++	main=$(git symbolic-ref --short HEAD) &&
      +	git checkout -b base-fork-side &&
      +	test_commit --no-tag base-fork-side side x &&
     -+	git checkout - &&
     ++	git checkout "$main" &&
      +	test_commit --no-tag base-fork-main file b &&
      +	git merge --no-ff -m "merge base-fork-side" base-fork-side &&
      +	git branch -D base-fork-side &&
     @@ t/t3455-history-squash.sh (new)
      +	test_path_is_file side
      +'
      +
     ++test_expect_success 'refuses a merge whose other parent is outside the range' '
     ++	git reset --hard start &&
     ++	main=$(git symbolic-ref --short HEAD) &&
     ++	git checkout -b outside-parent &&
     ++	test_commit --no-tag outside-parent outside x &&
     ++	git checkout "$main" &&
     ++	test_commit --no-tag outside-main file b &&
     ++	base=$(git rev-parse HEAD) &&
     ++	test_commit --no-tag outside-mid file c &&
     ++	git merge --no-ff -m "merge outside-parent" outside-parent &&
     ++	git branch -D outside-parent &&
     ++	merged=$(git rev-parse HEAD) &&
     ++
     ++	test_must_fail git history squash "$base.." 2>err &&
     ++	test_grep "more than one base" err &&
     ++	test_cmp_rev "$merged" HEAD
     ++'
     ++
      +test_expect_success 'folds a range whose tip is a merge commit' '
      +	git reset --hard start &&
     ++	main=$(git symbolic-ref --short HEAD) &&
      +	test_commit --no-tag tipmerge-base file b &&
      +	git checkout -b tipmerge-side &&
      +	test_commit --no-tag tipmerge-side side x &&
     -+	git checkout - &&
     ++	git checkout "$main" &&
      +	test_commit --no-tag tipmerge-main file c &&
      +	git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
      +	git branch -D tipmerge-side &&
     @@ t/t3455-history-squash.sh (new)
      +
      +test_expect_success 'folds a range whose base is a merge commit' '
      +	git reset --hard start &&
     ++	main=$(git symbolic-ref --short HEAD) &&
      +	git checkout -b basemerge-side &&
      +	test_commit --no-tag basemerge-side side x &&
     -+	git checkout - &&
     ++	git checkout "$main" &&
      +	test_commit --no-tag basemerge-main file b &&
      +	git merge --no-ff -m "merge basemerge-side" basemerge-side &&
      +	git branch -D basemerge-side &&
     @@ t/t3455-history-squash.sh (new)
      +
      +test_expect_success 'refuses to squash a range with more than one base' '
      +	git reset --hard start &&
     -+	head_before=$(git rev-parse HEAD) &&
     ++	main=$(git symbolic-ref --short HEAD) &&
      +	git checkout -b forked-before &&
      +	test_commit forked-side fside x &&
     -+	git checkout - &&
     -+	test_commit forked-main file b &&
     ++	git checkout "$main" &&
     ++	test_commit forked-base file b &&
     ++	base=$(git rev-parse HEAD) &&
     ++	test_commit forked-main file c &&
      +	git merge --no-ff -m merge forked-before &&
      +	merged=$(git rev-parse HEAD) &&
      +
     -+	test_must_fail git history squash forked-main.. 2>err &&
     ++	test_must_fail git history squash "$base.." 2>err &&
      +	test_grep "more than one base" err &&
      +	test_cmp_rev "$merged" HEAD
      +'
      +
      +test_expect_success 'folds a range with two interior merges' '
      +	git reset --hard start &&
     ++	main=$(git symbolic-ref --short HEAD) &&
      +	test_commit --no-tag two-merge-a file a1 &&
      +	git checkout -b two-merge-s1 &&
      +	test_commit --no-tag two-merge-s1 s1 x &&
     -+	git checkout - &&
     ++	git checkout "$main" &&
      +	git merge --no-ff -m "merge s1" two-merge-s1 &&
      +	test_commit --no-tag two-merge-b file b1 &&
      +	git checkout -b two-merge-s2 &&
      +	test_commit --no-tag two-merge-s2 s2 y &&
     -+	git checkout - &&
     ++	git checkout "$main" &&
      +	git merge --no-ff -m "merge s2" two-merge-s2 &&
      +	git branch -D two-merge-s1 two-merge-s2 &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
     @@ t/t3455-history-squash.sh (new)
      +test_expect_success 'refuses when a descendant above the range is a merge' '
      +	git reset --hard start &&
      +	main=$(git symbolic-ref --short HEAD) &&
     -+	test_commit --no-tag desc-base file b &&
     ++	test_commit --no-tag desc-one file b &&
     ++	test_commit --no-tag desc-two file c &&
      +	git tag desc-tip &&
      +	git checkout -b desc-above &&
      +	test_commit --no-tag desc-above above x &&
      +	git checkout "$main" &&
     -+	test_commit --no-tag desc-main file c &&
     ++	test_commit --no-tag desc-main file d &&
      +	git merge --no-ff -m "merge desc-above" desc-above &&
      +	git branch -D desc-above &&
      +	head_before=$(git rev-parse HEAD) &&
 4:  a758e1f084 ! 4:  4edf012b77 history: re-edit a squash with every message
     @@ Commit message
      
          Gather the messages of every commit in the range, oldest first, and use
          them as the editor template when re-editing, mirroring how "git rebase
     -    -i" presents a squash. The combined message is built before the
     -    descendant walk so it is not disturbed by the flags that walk leaves on
     -    the commits.
     +    -i" presents a squash.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Documentation/git-history.adoc ##
     -@@ Documentation/git-history.adoc: history squash @~3..` folds the three most recent commits into one, and
     - `git history squash @~5..@~2` squashes an interior range while leaving
     - the two newest commits in place.
     +@@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several arguments may be given,
     + for example `@~3.. ^topic` to additionally exclude what is already on
     + `topic`.
       +
      -The oldest commit's message and authorship are preserved by default,
      -unless you specify `--reedit-message`. A merge commit inside the range is

-- 
gitgitgadget

^ permalink raw reply

* Re: [PATCH] meson: wire up USE_NSEC build knob
From: Jeff King @ 2026-06-28  8:18 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: D. Ben Knoble, git, brian m . carlson, Junio C Hamano,
	Ramsay Jones
In-Reply-To: <ajjuoS5Qc3K0nCRl@pks.im>

On Mon, Jun 22, 2026 at 10:13:21AM +0200, Patrick Steinhardt wrote:

> > So I guess if we wanted to go further it would take some digging as to
> > how each platform behaves, and then flipping the config.make.uname knob
> > for ones where it can be argued that the behavior is always reasonable.
> 
> Yeah, it would be nice indeed to figure out whether these concerns still
> apply. If they do, I would argue that it might even make sense to remove
> the build option completely. It doesn't really make sense in my opinion
> to have a build option that nobody uses and that is subtly broken when
> enabled.

I suspect it works just fine on some platforms and some filesystems
(i.e., those that actually store nanoseconds on disk). So probably Linux
with ext4 is OK. That's just guessing, though.

If I understand the original problem correctly, then doing this:

  touch foo
  ls --full-time foo
  echo 3 | sudo tee /proc/sys/vm/drop_caches
  ls --full-time foo

should be instructive. If it shows the same time for both "ls" calls,
then USE_NSEC would be fine. If it doesn't, then the system is losing
the nanosecond information when it drops the cache and has to reload
from disk (and thus USE_NSEC would cause spurious stat mismatches).

On my ext4 system, I get the same answers. So far so good.

I get the same answers with a loopback-mounted ext2 system. Which
surprised me a bit, but even unmounting and remounting the filesystem,
the nanosecond times are still there. So...I guess ext2 supports
nanoseconds.

I tried with a vfat mount, and it also works: we don't have nanoseconds
either before or after. That makes sense, and implies that modern Linux
will always be OK (because it limits the cached VFS response to what the
underlying filesystem can handle).

So...maybe this is just a non-issue these days, at least on Linux?

> > But that's all outside the scope of your patch here.
> 
> Kind of, I guess. If we figure that this mechanism is still subtly broken
> then I'd argue that it doesn't make sense to expose the option via
> Meson.

True, but AFAICT it probably is safe these days, at least one some
platforms.

-Peff

^ permalink raw reply

* [PATCH 3/3] t5551: pack refs after creating many tags
From: Jeff King @ 2026-06-28  8:07 UTC (permalink / raw)
  To: Michael Montalbo; +Cc: Patrick Steinhardt, git, Junio C Hamano
In-Reply-To: <20260628075716.GA3525066@coredump.intra.peff.net>

We have two tests that create 2,000 and 100,000 tags respectively.
After doing so, the resulting state can be a bit slow to work with when
using the "files" ref backend, as each of those refs is in its own file.

This isn't a very realistic scenario, as we'd expect most of those refs
to be packed. If they accrue over time along with objects, they'd get
packed by maintenance/gc runs. And if you have a process that creates a
ton of refs at once (like a big fast-import), the usual recommendation
is to run maintenance afterwards.

So let's follow that recommendation and pack the refs ourselves.
Unfortunately, this does not seem to produce an improvement to the
run-time of the test script! That's because after producing this state,
we perform only a few fetches of it. And packing the refs costs at least
as much as serving a ref advertisement (both have to iterate the refs,
but packing additionally must write .lock files as we pack).

My wall-clock time was slightly improved (but within the noise) with
this patch, but my user and system CPU time were slightly worse!
However, on a loaded system with I/O bottlenecks, it may be a net win.
That's somewhat of a guess, though.

It would be nice if we had a way to generate all of these refs without
writing so many individual files. But even if we taught the ref code to
write large cases directly to the packed-refs file, we'd still need to
take individual locks. The real solution is a backend like reftable,
which shaves ~30% off of the test runtime.

Signed-off-by: Jeff King <peff@peff.net>
---
I'm iffy on whether this one is worth it.

If you apply just this patch without patch 2, then the run-time does
improve quite a bit. The cost of packing is amortized by the improved
performance for all of those subsequent tests (but after patch 2, they
never even see the unpacked state).

Likewise, I suspect this would make our timeout problems go away even
without patch 1.

So the whole series _could_ be reduced to just this one patch. But
hopefully the reasoning given in the earlier patches makes sense, at
which point this one is kind of superfluous.

 t/t5551-http-fetch-smart.sh | 1 +
 1 file changed, 1 insertion(+)

diff --git a/t/t5551-http-fetch-smart.sh b/t/t5551-http-fetch-smart.sh
index cd851f24b8..e2e729216f 100755
--- a/t/t5551-http-fetch-smart.sh
+++ b/t/t5551-http-fetch-smart.sh
@@ -393,6 +393,7 @@ create_tags () {
 	tag=$(perl -e "print \"bla\" x 30") &&
 	sed -e "s|^:\([^ ]*\) \(.*\)$|create refs/tags/$tag-\1 \2|" <marks >input &&
 	git update-ref --stdin <input &&
+	git pack-refs --all &&
 	rm input
 }
 
-- 
2.55.0.rc2.353.gf769b6597e

^ permalink raw reply related

* [PATCH 2/3] t5551: put many-tags case into its own repo
From: Jeff King @ 2026-06-28  8:03 UTC (permalink / raw)
  To: Michael Montalbo; +Cc: Patrick Steinhardt, git, Junio C Hamano
In-Reply-To: <20260628075716.GA3525066@coredump.intra.peff.net>

Most of the t5551 http fetch tests use a handful of refs. But there are
a few test cases which check our handling of large numbers of refs.
These tests use the same server-side repo, so all subsequent tests end
up having to consider those extra refs, too.

The result is that the test script is a bit slower than it needs to be.
In a normal run, moving the "2,000 tags" test into its own repo drops my
runtime for the whole script from ~2.7s to ~1.9s.

This is a modest gain, but when we add the "--long" flag it gets much
bigger. There we trigger a test (marked with EXPENSIVE) that adds
100,000 tags, and the script runtime jumps to ~95s. But if we use the
same "many tags" repo for that, our runtime drops to just ~37s.

This is a pretty easy win to drop the cost of the script. It may even be
a larger gain on a heavily loaded system, since one of the main costs
here is unpacked refs, which are heavy on system time and I/O costs.

It's possible we are reducing test coverage, since all of those other
tests were inadvertently using large ref advertisements (and thus could
have uncovered some unexpected interaction). But that seems somewhat
unlikely; the tests targeted at the large number of refs are doing
roughly similar things to the other tests.

Note that the real performance culprit is the 100k-tag --long test, not
the 2k-tag one. So we could just let the 100k one use its own repo, and
keep the 2k tags in the main repo. But since these two tests are
somewhat interlinked, it's easier to just move them both (and it does
provide a small gain even for the 2000-tag test). I also notice that the
2000-tag test is gated on the CMDLINE_LIMIT prereq, and without that the
later EXPENSIVE test will fail (since we won't have a too-many-refs
clone). Nobody seems to have noticed or complained after many years, and
I left it alone for this patch.

Signed-off-by: Jeff King <peff@peff.net>
---
 t/t5551-http-fetch-smart.sh | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/t/t5551-http-fetch-smart.sh b/t/t5551-http-fetch-smart.sh
index e236e526f0..cd851f24b8 100755
--- a/t/t5551-http-fetch-smart.sh
+++ b/t/t5551-http-fetch-smart.sh
@@ -397,15 +397,16 @@ create_tags () {
 }
 
 test_expect_success 'create 2,000 tags in the repo' '
+	git init "$HTTPD_DOCUMENT_ROOT_PATH/many-tags.git" &&
 	(
-		cd "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
+		cd "$HTTPD_DOCUMENT_ROOT_PATH/many-tags.git" &&
 		create_tags 1 2000
 	)
 '
 
 test_expect_success CMDLINE_LIMIT \
 	'clone the 2,000 tag repo to check OS command line overflow' '
-	run_with_limited_cmdline git clone $HTTPD_URL/smart/repo.git too-many-refs &&
+	run_with_limited_cmdline git clone $HTTPD_URL/smart/many-tags.git too-many-refs &&
 	(
 		cd too-many-refs &&
 		git for-each-ref refs/tags >actual &&
@@ -483,12 +484,12 @@ test_expect_success 'test allowanysha1inwant with unreachable' '
 test_expect_success EXPENSIVE 'http can handle enormous ref negotiation' '
 	test_when_finished "rm -f tags" &&
 	(
-		cd "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
+		cd "$HTTPD_DOCUMENT_ROOT_PATH/many-tags.git" &&
 		create_tags 2001 50000
 	) &&
 	git -C too-many-refs fetch -q --tags &&
 	(
-		cd "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
+		cd "$HTTPD_DOCUMENT_ROOT_PATH/many-tags.git" &&
 		create_tags 50001 100000
 	) &&
 	git -C too-many-refs fetch -q --tags &&
-- 
2.55.0.rc2.353.gf769b6597e


^ permalink raw reply related

* [PATCH 1/3] t/lib-httpd: bump apache timeout
From: Jeff King @ 2026-06-28  8:00 UTC (permalink / raw)
  To: Michael Montalbo; +Cc: Patrick Steinhardt, git, Junio C Hamano
In-Reply-To: <20260628075716.GA3525066@coredump.intra.peff.net>

Since enabling more tests with 7a094d68a2 (ci: run expensive tests on
push builds to integration branches, 2026-05-08), we sometimes see test
failures or timeouts in GitHub CI. The culprit seems to be the "enormous
ref negotiation" test in t5551, which creates ~100k tag refs in our http
server-side repo.

Iterating through the loose refs of this repo to generate a ref
advertisement can take a long time, especially on a platform with slow
I/O. On my otherwise unloaded local machine, a cold cache ref
advertisement takes ~10s. On a busy CI machine running tests in
parallel, it can presumably top 60s, which runs afoul of Apache's
default CGI timeout.

The result in t5551 is a test failure, where Apache simply hangs up the
connection and the client reports an error. But worse, t5559 runs the
same test with HTTP/2, and a bug in Apache causes the connection to hang
indefinitely! We eventually see this as a CI timeout after 6 hours.

Let's bump Apache's timeout to something much larger: 600 seconds. This
doesn't eliminate the possibility of a timeout, but it makes it much
less likely. It should eliminate both the test failures and the CI
timeouts in practice, and it protects us from running into similar
problems with other tests in the future.

There are two counter-arguments to consider.

One, could/should we just make the test faster? Probably yes. The
biggest mistake here is having such an absurd number of unpacked refs on
a system which is bottle-necked on I/O. But I think it's worth bumping
the timeout so that we can fix this (and possibly other) correctness
issues, and then consider performance separately (which we'll do in
subsequent patches).

And two, is this just papering over a problem that users might see in
the real world? We could teach Git to handle this case more gracefully
with optimizations or keep-alives. But I think it's really an artificial
situation. You need a combination of this silly number of loose refs,
plus a very heavily loaded system. If you were trying to run a real
server and it took more than 60s to generate the ref advertisement, I
don't think the timeout is your biggest problem. Your crappy service is,
and you should adjust your resources to match your load. I.e., it is
probably reasonable for Git to assume that advertisements happen
fast-ish and don't need protocol-level keepalives.

Though the patch here is small, tons of work went into analyzing the
problem. Many thanks to the contributors credited below.

Helped-by: Michael Montalbo <mmontalbo@gmail.com>
Helped-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Jeff King <peff@peff.net>
---
I didn't reference Michael's bugzilla report directly, because you can't
read it without a login. :(

Maybe it's worth doing anyway?

 t/lib-httpd/apache.conf | 1 +
 1 file changed, 1 insertion(+)

diff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf
index 40a690b0bb..4149fc1078 100644
--- a/t/lib-httpd/apache.conf
+++ b/t/lib-httpd/apache.conf
@@ -4,6 +4,7 @@ DocumentRoot www
 LogFormat "%h %l %u %t \"%r\" %>s %b" common
 CustomLog access.log common
 ErrorLog error.log
+Timeout 600
 <IfModule !mod_log_config.c>
 	LoadModule log_config_module modules/mod_log_config.so
 </IfModule>
-- 
2.55.0.rc2.353.gf769b6597e


^ permalink raw reply related

* [PATCH 0/3] fixing expensive http test timeouts
From: Jeff King @ 2026-06-28  7:57 UTC (permalink / raw)
  To: Michael Montalbo; +Cc: Patrick Steinhardt, git, Junio C Hamano
In-Reply-To: <CAC2QwmLkHUymvtYbjY8aQO9_VogvaSXdbb1_DSZtcBttGfN0tg@mail.gmail.com>

On Fri, Jun 26, 2026 at 04:26:28PM -0700, Michael Montalbo wrote:

> I think Peff and Patrick's suggestion to just increase the Apache timeout
> makes sense. I ran some experiments using a really long timeout with an
> artificially slowed down CI runner and all the jobs made progress
> (if slowly) without stalling, and eventually completed successfully:
> 
> https://github.com/mmontalbo/git/actions/runs/28267019651
> 
> I haven't spent a lot of time trying to figure out what the right timeout
> value should be. An hour definitely seems like overkill, with something
> on the order of 5-10 minutes seeming more reasonable, but I don't
> have a principled number.

Here are some patches to keep things moving along. I arbitrarily picked
10 minutes, because multiplying the 1-minute default by 10 felt right. ;)

The first one just bumps the timeout and should make our problems go
away. The other two are optimizations, but I'm on the fence on whether
the final patch is worth it.

Thanks again for all of the digging.

  [1/3]: t/lib-httpd: bump apache timeout
  [2/3]: t5551: put many-tags case into its own repo
  [3/3]: t5551: pack refs after creating many tags

 t/lib-httpd/apache.conf     |  1 +
 t/t5551-http-fetch-smart.sh | 10 ++++++----
 2 files changed, 7 insertions(+), 4 deletions(-)

-Peff

^ permalink raw reply

* Re: [PATCH v3 1/2] branch: suggest <remote>/<branch> on upstream slip
From: Harald Nordgren @ 2026-06-28  7:21 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: Harald Nordgren via GitGitGadget, git
In-Reply-To: <xmqqfr272lq7.fsf@gitster.g>

Let's do it!


Harald

^ permalink raw reply

* Re: [PATCH v3 1/2] branch: suggest <remote>/<branch> on upstream slip
From: Junio C Hamano @ 2026-06-28  7:00 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
In-Reply-To: <9883c28482be4ad43f0f999c2e6be9f9dd9fb13b.1782583345.git.gitgitgadget@gmail.com>

"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:

> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1572a4f9ef..dede60d27b 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -706,6 +706,29 @@ static int edit_branch_description(const char *branch_name)
>  	return 0;
>  }
>  
> +static void die_if_upstream_looks_like_remote(const char *new_upstream, const char *branch_name)
> +{
> +	struct strbuf remote_ref = STRBUF_INIT;
> +	int code;
> +
> +	if (strchr(new_upstream, '/') ||
> +	    !remote_is_configured(remote_get(new_upstream), 0))
> +		return;
> +
> +	strbuf_addf(&remote_ref, "refs/remotes/%s/%s", new_upstream, branch_name);
> +	if (!refs_ref_exists(get_main_ref_store(the_repository), remote_ref.buf)) {
> +		strbuf_release(&remote_ref);
> +		return;
> +	}
> +
> +	code = die_message(_("--set-upstream-to takes a single <remote>/<branch> argument"));
> +	advise_if_enabled(ADVICE_SET_UPSTREAM_FAILURE,
> +			  _("Did you mean to use: git branch --set-upstream-to=%s/%s?"),
> +			  new_upstream, branch_name);
> +	strbuf_release(&remote_ref);
> +	exit(code);
> +}
> +
>  int cmd_branch(int argc,
>  	       const char **argv,
>  	       const char *prefix,
> @@ -957,6 +980,15 @@ int cmd_branch(int argc,
>  		if (!refs_ref_exists(get_main_ref_store(the_repository), branch->refname)) {
>  			if (!argc || branch_checked_out(branch->refname))
>  				die(_("no commit on branch '%s' yet"), branch->name);
> +			/*
> +			 * Check the advice up front to avoid the ref
> +			 * lookups when the hint is off. The helper still
> +			 * calls advise_if_enabled() so the hint carries the
> +			 * standard "disable this message" instructions.
> +			 */
> +			if (argc == 1 &&
> +			    advice_enabled(ADVICE_SET_UPSTREAM_FAILURE))
> +				die_if_upstream_looks_like_remote(new_upstream, argv[0]);
>  			die(_("branch '%s' does not exist"), branch->name);
>  		}

Hmph, something like adding a single liner in the caller, like this. ...

	code = die_message(_("--set-upstream-to takes a single <remote>/<branch> argument"));
+	/* use _if_enabled here to show the hint on how to disable */
	advise_if_enabled(ADVICE_SET_UPSTREAM_FAILURE,
			  _("Did you mean to use: git branch --set-upstream-to=%s/%s?"),
			  new_upstream, branch_name);
	strbuf_release(&remote_ref);
	exit(code);

... was what I meant, because the most puzzling piece is that the
function calls _if_enabled form there, when the caller is presumably
already checked _enabled() and leaves the reader wondering if there
are other callers of this function that does not check before
calling it.

But this is so tiny a thing that once the code is written, it is
probably not worth the churn to redo it.  Let's declare victory and
mark the topic ready for 'next'?

^ permalink raw reply

* Re: [PATCH] http: accept https:// proxies again
From: Junio C Hamano @ 2026-06-28  5:10 UTC (permalink / raw)
  To: Johannes Schindelin via GitGitGadget; +Cc: git, Aliwoto, Johannes Schindelin
In-Reply-To: <xmqq8q7z4eg3.fsf@gitster.g>

Junio C Hamano <gitster@pobox.com> writes:

> From this function nothing returns an error anymore, and looking at
> the preimage of 663d7abe (http: reject unsupported proxy URL
> schemes, 2026-05-05) that is the source of the bug, the original did
> not do anything when the corresponding code did not find and set any
> proxy settings, either.
>
> So perhaps it is a better fix to make it just a function that
> returns void with early returns?

Nah, I was being stupid.  Disregard the above.

The whole point of 663d7abe was that we wanted to reject what we did
not recognise, and we cannot do so without returning "good/bad" from
that function.  The bug was that we did recognise https:// but still
returned -1 because of the bug, which the patch in the thread fixed.

Thanks.

^ permalink raw reply

* Re: [PATCH v4 1/1] environment: move excludes_file into repo_config_values
From: Tian Yuchen @ 2026-06-28  3:38 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: git, cirnovskyv, szeder.dev, Christian Couder, Ayush Chandekar,
	Olamide Caleb Bello
In-Reply-To: <eabb8169-2c13-4961-9b21-f44b1fa66f70@malon.dev>

On 6/28/26 11:19, Tian Yuchen wrote:

>  Let it be noticed by repo_config_values() function to
> catch offending callers for now, and once the codebase becomes ready
> to use one repo_config_values per repository, this function does not
> have to change.

And

> Wouldn't we rather want to try to be more strict and say
> 
> 	if (!repo || !repo->initialized)
> 		BUG("repo must be an initialied repository");
> 
> here?  Aren't all the callers of this function supposed to be
> dealing with an already initialized repository?

In my opinion, these two suggestions are not entirely consistent, and I 
think we need to determine the most appropriate approach.

Regards, yuchen

^ permalink raw reply


This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox