contrib/subtree: detect rewritten subtree commits

git subtree split --prefix P detects splits that are outside of path prefix `P` and prunes them from history graph processing. This improves the performance of repeated `split --rejoin` with many different prefixes. Both before and after 83f9dad7d6 (contrib/subtree: fix split with squashed subtrees, 2025-09-09), the pruning logic does not detect **rebased** or **cherry-picked** git-subtree commits. If `split` encounters any of these commits, the split output may have incomplete history. All commits authored by git subtree merge [--squash] --prefix Q have a first or second parent that has *only* subtree commits as ancestors. When splitting a completely different path `P/`, it is safe to ignore: 1. the merged tree 2. the subtree parent 3. *all* of that parent's ancestry, which applies only to path `Q/` and not `P/`. But this relationship no longer holds if the git-subtree commit is rebased or otherwise reauthored. After a rebase, the former git-subtree commit will have other unrelated commits as ancestors. Ignoring these commits may exclude the history of `P/`, leading to incomplete `subtree split` output. The pruning logic relies solely on the `git-subtree-*:` trailers to detect git-subtree commits, which it blindly accepts without further validation. The split logic also takes its time about being wrong: `cmd_split()` execs a `git show` for *every* commit in the split range… twice. This is inefficient in a shell script. Add a "reality check" to ignore rebased or rewritten commits: * Rewrites of non-merge commits cannot be detected, so the new detector no longer looks for them. * Merges carry a `git-subtree-mainline:` trailer with the hash of the **first parent**. If this hash differs, or if the "merge" commit no longer has multiple parents, a rewrite has occurred. To increase speed, package this logic in a new method, `find_other_splits()`. Perform the check up-front by iterating over a single `git log`. Add ignored subtrees to: 1. the `notree` cache, which excludes them from the `split` history 2. a `prune` negative refs list. The negative refs prevent recursing into other subtrees. Since there are potentially a *lot* of these, cache them on disk and use rev-list's `--stdin` mode. Reported-by: George <george@mail.dietrich.pub> Signed-off-by: Colin Stagner <ask+git@howdoi.land> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Colin Stagner committed Jan 9, 2026 at 19:18 UTC 28a7e27cff717e5ef91f7445e6a418068608082d
2 files changed +169 -55
contrib/subtree/git-subtree.sh
+92 -49
@@ -325,6 +325,12 @@ check_parents () {
325 done
326 }
327
328 +# Usage: get_notree REV
329 +get_notree () {
330 + assert test $# = 1
331 + test -r "$cachedir/notree/$1"
332 +}
333 +
334 # Usage: set_notree REV
335 set_notree () {
336 assert test $# = 1
@@ -511,6 +517,71 @@ find_existing_splits () {
517 done || exit $?
518 }
519
520 +# Usage: find_other_splits DIR REV UNREVS...
521 +#
522 +# Scan history in REV UNREVS for other `git subtree split --rejoin`
523 +# merge commits belonging to prefixes outside of DIR. These
524 +# "other splits" don't contribute to DIR and can be ignored.
525 +#
526 +# If any such rejoins are found,
527 +#
528 +# * emit their second-parent as an UNREV, avoiding a
529 +# potentially costly history traversal
530 +#
531 +# * mark the merge commit as "notree" to ignore it
532 +find_other_splits () {
533 + assert test $# -ge 2
534 + dir="${1%/}"
535 + rev="$2"
536 + shift 2
537 + debug "Looking for other splits with dir != $dir..."
538 +
539 + git log \
540 + --grep '^git-subtree-mainline:' \
541 + --no-patch \
542 + --no-show-signature \
543 + --format='hash: %H%nparents: %P%n%(trailers:key=git-subtree-dir,key=git-subtree-mainline,key=git-subtree-split)%nEND' \
544 + "$rev" ${@:+"$@"} |
545 + while read -r key val
546 + do
547 + case "$key" in
548 + hash:)
549 + commit_hash="${val}"
550 + commit_parents=
551 + subtree_dir=
552 + subtree_mainline=
553 + subtree_split=
554 + ;;
555 + parents:)
556 + commit_parents="${val}" ;;
557 + git-subtree-dir:)
558 + subtree_dir="${val%/}/" ;;
559 + git-subtree-mainline:)
560 + subtree_mainline="${val}" ;;
561 + git-subtree-split:)
562 + subtree_split="${val}" ;;
563 + END)
564 + # verify:
565 + # * all git-subtree-* trailers are present
566 + # * this subtree is outside of $dir
567 + # * the first parent is the git-subtree-mainline:
568 + # * the commit has at least two parents
569 + if test -n "${subtree_dir}" &&
570 + test -n "${subtree_split}" &&
571 + test -n "${subtree_mainline}" &&
572 + test "${subtree_dir}" = "${subtree_dir#"${dir}/"}" &&
573 + test "${commit_parents}" != "${commit_parents#"$subtree_mainline "}" &&
574 + rev_exists "${commit_hash}^2"
575 + then
576 + debug "find_other_splits excluding dir=$subtree_dir merged in ${commit_hash}"
577 + echo "^${commit_hash}^2"
578 + set_notree "${commit_hash}"
579 + fi
580 + ;;
581 + esac
582 + done
583 +}
584 +
585 # Usage: copy_commit REV TREE FLAGS_STR
586 copy_commit () {
587 assert test $# = 3
@@ -785,42 +856,6 @@ ensure_valid_ref_format () {
856 die "fatal: '$1' does not look like a ref"
857 }
858
788 -# Usage: should_ignore_subtree_split_commit REV
789 -#
790 -# Check if REV is a commit from another subtree and should be
791 -# ignored from processing for splits
792 -should_ignore_subtree_split_commit () {
793 - assert test $# = 1
794 -
795 - git show \
796 - --no-patch \
797 - --no-show-signature \
798 - --format='%(trailers:key=git-subtree-dir,key=git-subtree-mainline)' \
799 - "$1" |
800 - (
801 - have_mainline=
802 - subtree_dir=
803 -
804 - while read -r trailer val
805 - do
806 - case "$trailer" in
807 - git-subtree-dir:)
808 - subtree_dir="${val%/}" ;;
809 - git-subtree-mainline:)
810 - have_mainline=y ;;
811 - esac
812 - done
813 -
814 - if test -n "${subtree_dir}" &&
815 - test -z "${have_mainline}" &&
816 - test "${subtree_dir}" != "$arg_prefix"
817 - then
818 - return 0
819 - fi
820 - return 1
821 - )
822 -}
823 -
859 # Usage: process_split_commit REV PARENTS
860 process_split_commit () {
861 assert test $# = 2
@@ -994,31 +1029,39 @@ cmd_split () {
1029 fi
1030
1031 unrevs="$(find_existing_splits "$dir" "$rev" "$repository")" || exit $?
1032 + (find_other_splits >"$cachedir/prune" "$dir" "$rev" $unrevs) || exit $?
1033
1034 # We can't restrict rev-list to only $dir here, because some of our
1035 # parents have the $dir contents the root, and those won't match.
1036 # (and rev-list --follow doesn't seem to solve this)
1001 - grl='git rev-list --topo-order --reverse --parents $rev $unrevs'
1002 - revmax=$(eval "$grl" | wc -l)
1037 + revmax="$(git rev-list \
1038 + <"$cachedir/prune" \
1039 + --topo-order \
1040 + --reverse \
1041 + --parents \
1042 + --stdin \
1043 + --count \
1044 + "$rev" \
1045 + $unrevs
1046 + )"
1047 revcount=0
1048 createcount=0
1049 extracount=0
1006 - eval "$grl" |
1050 + git rev-list \
1051 + <"$cachedir/prune" \
1052 + --topo-order \
1053 + --reverse \
1054 + --parents \
1055 + --stdin \
1056 + "$rev" \
1057 + $unrevs |
1058 while read rev parents
1059 do
1009 - if should_ignore_subtree_split_commit "$rev"
1060 + if get_notree "$rev"
1061 then
1062 continue
1063 fi
1013 - parsedparents=''
1014 - for parent in $parents
1015 - do
1016 - if ! should_ignore_subtree_split_commit "$parent"
1017 - then
1018 - parsedparents="$parsedparents$parent "
1019 - fi
1020 - done
1021 - process_split_commit "$rev" "$parsedparents"
1064 + process_split_commit "$rev" "$parents"
1065 done || exit $?
1066
1067 latest_new=$(cache_get latest_new) || exit $?
contrib/subtree/t/t7900-subtree.sh
+77 -6
@@ -411,8 +411,9 @@ test_expect_success 'split sub dir/ with --rejoin' '
411 git fetch ./"sub proj" HEAD &&
412 git subtree merge --prefix="sub dir" FETCH_HEAD &&
413 split_hash=$(git subtree split --prefix="sub dir" --annotate="*") &&
414 - git subtree split --prefix="sub dir" --annotate="*" --rejoin &&
415 - test "$(last_commit_subject)" = "Split '\''sub dir/'\'' into commit '\''$split_hash'\''"
414 + git subtree split --prefix="sub dir" --annotate="*" -b spl --rejoin &&
415 + test "$(last_commit_subject)" = "Split '\''sub dir/'\'' into commit '\''$split_hash'\''" &&
416 + test "$(git rev-list --count spl)" -eq 5
417 )
418 '
419
@@ -442,18 +443,25 @@ test_expect_success 'split with multiple subtrees' '
443 git -C "$test_count" subtree add --prefix=subADir FETCH_HEAD &&
444 git -C "$test_count" fetch ./subB HEAD &&
445 git -C "$test_count" subtree add --prefix=subBDir FETCH_HEAD &&
446 + test "$(git -C "$test_count" rev-list --count main)" -eq 7 &&
447 test_create_commit "$test_count" subADir/main-subA1 &&
448 test_create_commit "$test_count" subBDir/main-subB1 &&
449 git -C "$test_count" subtree split --prefix=subADir \
448 - --squash --rejoin -m "Sub A Split 1" &&
450 + --squash --rejoin -m "Sub A Split 1" -b a1 &&
451 + test "$(git -C "$test_count" rev-list --count main..a1)" -eq 1 &&
452 git -C "$test_count" subtree split --prefix=subBDir \
450 - --squash --rejoin -m "Sub B Split 1" &&
453 + --squash --rejoin -m "Sub B Split 1" -b b1 &&
454 + test "$(git -C "$test_count" rev-list --count main..b1)" -eq 1 &&
455 test_create_commit "$test_count" subADir/main-subA2 &&
456 test_create_commit "$test_count" subBDir/main-subB2 &&
457 git -C "$test_count" subtree split --prefix=subADir \
454 - --squash --rejoin -m "Sub A Split 2" &&
458 + --squash --rejoin -m "Sub A Split 2" -b a2 &&
459 + test "$(git -C "$test_count" rev-list --count main..a2)" -eq 2 &&
460 + test "$(git -C "$test_count" rev-list --count a1..a2)" -eq 1 &&
461 test "$(git -C "$test_count" subtree split --prefix=subBDir \
456 - --squash --rejoin -d -m "Sub B Split 1" 2>&1 | grep -w "\[1\]")" = ""
462 + --squash --rejoin -d -m "Sub B Split 1" -b b2 2>&1 | grep -w "\[1\]")" = "" &&
463 + test "$(git -C "$test_count" rev-list --count main..b2)" -eq 2 &&
464 + test "$(git -C "$test_count" rev-list --count b1..b2)" -eq 1
465 '
466
467 # When subtree split-ing a directory that has other subtree
@@ -477,6 +485,7 @@ do
485 test_path_is_file subA/file1.t &&
486 test_path_is_file subA/subB/file2.t &&
487 git subtree split --prefix=subA --branch=bsplit &&
488 + test "$(git rev-list --count bsplit)" -eq 2 &&
489 git checkout bsplit &&
490 test_path_is_file file1.t &&
491 test_path_is_file subB/file2.t &&
@@ -489,6 +498,7 @@ do
498 --prefix=subA/subB mksubtree &&
499 test_path_is_file subA/subB/file3.t &&
500 git subtree split --prefix=subA --branch=bsplit &&
501 + test "$(git rev-list --count bsplit)" -eq 3 &&
502 git checkout bsplit &&
503 test_path_is_file file1.t &&
504 test_path_is_file subB/file2.t &&
@@ -497,6 +507,67 @@ do
507 '
508 done
509
510 +# Usually,
511 +#
512 +# git subtree merge -P subA --squash f00...
513 +#
514 +# makes two commits, in this order:
515 +#
516 +# 1. Squashed 'subA/' content from commit f00...
517 +# 2. Merge commit (1) as 'subA'
518 +#
519 +# Commit 1 updates the subtree but does *not* rewrite paths.
520 +# Commit 2 rewrites all trees to start with `subA/`
521 +#
522 +# Commit 1 either has no parents or depends only on other
523 +# "Squashed 'subA/' content" commits.
524 +#
525 +# For merge without --squash, subtree produces just one commit:
526 +# a merge commit with git-subtree trailers.
527 +#
528 +# In either case, if the user rebases these commits, they will
529 +# still have the git-subtree-* trailers… but will NOT have
530 +# the layout described above.
531 +#
532 +# Test that subsequent `git subtree split` are not confused by this.
533 +test_expect_success 'split with rebased subtree commit' '
534 + subtree_test_create_repo "$test_count" &&
535 + (
536 + cd "$test_count" &&
537 + test_commit file0 &&
538 + test_create_subtree_add \
539 + . mksubtree subA file1 --squash &&
540 + test_path_is_file subA/file1.t &&
541 + mkdir subB &&
542 + test_commit subB/bfile &&
543 + git commit --amend -F - <<'EOF' &&
544 +Squashed '\''subB/'\'' content from commit '\''badf00da911bbe895347b4b236f5461d55dc9877'\''
545 +
546 +Simulate a cherry-picked or rebased subtree commit.
547 +
548 +git-subtree-dir: subB
549 +git-subtree-split: badf00da911bbe895347b4b236f5461d55dc9877
550 +EOF
551 + test_commit subA/file2 &&
552 + test_commit subB/bfile2 &&
553 + git commit --amend -F - <<'EOF' &&
554 +Split '\''subB/'\'' into commit '\''badf00da911bbe895347b4b236f5461d55dc9877'\''
555 +
556 +Simulate a cherry-picked or rebased subtree commit.
557 +
558 +git-subtree-dir: subB
559 +git-subtree-mainline: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
560 +git-subtree-split: badf00da911bbe895347b4b236f5461d55dc9877
561 +EOF
562 + git subtree split --prefix=subA --branch=bsplit &&
563 + git checkout bsplit &&
564 + test_path_is_file file1.t &&
565 + test_path_is_file file2.t &&
566 + test "$(last_commit_subject)" = "subA/file2" &&
567 + test "$(git rev-list --count bsplit)" -eq 2
568 + )
569 +'
570 +
571 test_expect_success 'split sub dir/ with --rejoin from scratch' '
572 subtree_test_create_repo "$test_count" &&
573 test_create_commit "$test_count" main1 &&