{"thread":{"id":"64766","subject":"[PATCH] contrib/subtree: detect rewritten subtree commits","startedAt":"2026-01-10T01:20:05Z","lastAt":"2026-01-10T01:20:05Z","messageCount":1,"participants":["Colin Stagner"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"533460","messageId":"20260110011811.788219-1-ask+git@howdoi.land","threadId":"64766","inReplyTo":null,"subject":"[PATCH] contrib/subtree: detect rewritten subtree commits","fromName":"Colin Stagner","fromEmail":"ask+git@howdoi.land","sentAt":"2026-01-10T01:18:11Z","receivedAt":"2026-01-10T01:20:05Z","isPatch":true,"sender":{"key":"ask+git@howdoi.land","avatar":null},"body":"    git subtree split --prefix P\n\ndetects splits that are outside of path prefix `P` and prunes\nthem from history graph processing. This improves the performance\nof repeated `split --rejoin` with many different prefixes.\n\nBoth before and after 83f9dad7d6 (contrib/subtree: fix split with\nsquashed subtrees, 2025-09-09), the pruning logic does not detect\n**rebased** or **cherry-picked** git-subtree commits. If `split`\nencounters any of these commits, the split output may have\nincomplete history.\n\nAll commits authored by\n\n    git subtree merge [--squash] --prefix Q\n\nhave a first or second parent that has *only* subtree commits\nas ancestors. When splitting a completely different path `P/`,\nit is safe to ignore:\n\n1. the merged tree\n2. the subtree parent\n3. *all* of that parent's ancestry, which applies only to\n   path `Q/` and not `P/`.\n\nBut this relationship no longer holds if the git-subtree commit\nis rebased or otherwise reauthored. After a rebase, the former\ngit-subtree commit will have other unrelated commits as ancestors.\nIgnoring these commits may exclude the history of `P/`,\nleading to incomplete `subtree split` output.\n\nThe pruning logic relies solely on the `git-subtree-*:` trailers\nto detect git-subtree commits, which it blindly accepts without\nfurther validation. The split logic also takes its time about\nbeing wrong: `cmd_split()` execs a `git show` for *every* commit\nin the split range… twice. This is inefficient in a shell script.\n\nAdd a \"reality check\" to ignore rebased or rewritten commits:\n\n* Rewrites of non-merge commits cannot be detected, so the new\n  detector no longer looks for them.\n\n* Merges carry a `git-subtree-mainline:` trailer with the hash of\n  the **first parent**. If this hash differs, or if the \"merge\"\n  commit no longer has multiple parents, a rewrite has occurred.\n\nTo increase speed, package this logic in a new method,\n`find_other_splits()`. Perform the check up-front by iterating\nover a single `git log`. Add ignored subtrees to:\n\n1. the `notree` cache, which excludes them from the `split` history\n\n2. a `prune` negative refs list. The negative refs prevent\n   recursing into other subtrees. Since there are potentially a\n   *lot* of these, cache them on disk and use rev-list's\n   `--stdin` mode.\n\nReported-by: George <george@mail.dietrich.pub>\n\nSigned-off-by: Colin Stagner <ask+git@howdoi.land>\n---\n\nNotes:\n    * cs/subtree-split-detect-rewritten:\n      \"git subtree\" (in contrib/) did not correctly split history\n      containing rebased git-subtree commits. This has been fixed.\n    \n    This patch is intended for maint.\n    \n    See-also: <176677910605.6.2281395015810449820.1087545551@dietrich.pub>\n\n contrib/subtree/git-subtree.sh     | 141 +++++++++++++++++++----------\n contrib/subtree/t/t7900-subtree.sh |  83 +++++++++++++++--\n 2 files changed, 169 insertions(+), 55 deletions(-)\n\ndiff --git a/contrib/subtree/git-subtree.sh b/contrib/subtree/git-subtree.sh\nindex 17106d1a72..3ebe88cbea 100755\n--- a/contrib/subtree/git-subtree.sh\n+++ b/contrib/subtree/git-subtree.sh\n@@ -325,6 +325,12 @@ check_parents () {\n \tdone\n }\n \n+# Usage: get_notree REV\n+get_notree () {\n+\tassert test $# = 1\n+\ttest -r \"$cachedir/notree/$1\"\n+}\n+\n # Usage: set_notree REV\n set_notree () {\n \tassert test $# = 1\n@@ -511,6 +517,71 @@ find_existing_splits () {\n \tdone || exit $?\n }\n \n+# Usage: find_other_splits DIR REV UNREVS...\n+#\n+# Scan history in REV UNREVS for other `git subtree split --rejoin`\n+# merge commits belonging to prefixes outside of DIR. These\n+# \"other splits\" don't contribute to DIR and can be ignored.\n+#\n+# If any such rejoins are found,\n+#\n+#   * emit their second-parent as an UNREV, avoiding a\n+#     potentially costly history traversal\n+#\n+#   * mark the merge commit as \"notree\" to ignore it\n+find_other_splits () {\n+\tassert test $# -ge 2\n+\tdir=\"${1%/}\"\n+\trev=\"$2\"\n+\tshift 2\n+\tdebug \"Looking for other splits with dir != $dir...\"\n+\n+\tgit log \\\n+\t\t--grep '^git-subtree-mainline:' \\\n+\t\t--no-patch \\\n+\t\t--no-show-signature \\\n+\t\t--format='hash: %H%nparents: %P%n%(trailers:key=git-subtree-dir,key=git-subtree-mainline,key=git-subtree-split)%nEND' \\\n+\t\t\"$rev\" ${@:+\"$@\"} |\n+\twhile read -r key val\n+\tdo\n+\t\tcase \"$key\" in\n+\t\thash:)\n+\t\t\tcommit_hash=\"${val}\"\n+\t\t\tcommit_parents=\n+\t\t\tsubtree_dir=\n+\t\t\tsubtree_mainline=\n+\t\t\tsubtree_split=\n+\t\t\t;;\n+\t\tparents:)\n+\t\t\tcommit_parents=\"${val}\" ;;\n+\t\tgit-subtree-dir:)\n+\t\t\tsubtree_dir=\"${val%/}/\" ;;\n+\t\tgit-subtree-mainline:)\n+\t\t\tsubtree_mainline=\"${val}\" ;;\n+\t\tgit-subtree-split:)\n+\t\t\tsubtree_split=\"${val}\" ;;\n+\t\tEND)\n+\t\t\t# verify:\n+\t\t\t# * all git-subtree-* trailers are present\n+\t\t\t# * this subtree is outside of $dir\n+\t\t\t# * the first parent is the git-subtree-mainline:\n+\t\t\t# * the commit has at least two parents\n+\t\t\tif test -n \"${subtree_dir}\" &&\n+\t\t\t\ttest -n \"${subtree_split}\" &&\n+\t\t\t\ttest -n \"${subtree_mainline}\" &&\n+\t\t\t\ttest \"${subtree_dir}\" = \"${subtree_dir#\"${dir}/\"}\" &&\n+\t\t\t\ttest \"${commit_parents}\" != \"${commit_parents#\"$subtree_mainline \"}\" &&\n+\t\t\t\trev_exists \"${commit_hash}^2\"\n+\t\t\tthen\n+\t\t\t\tdebug \"find_other_splits excluding dir=$subtree_dir merged in ${commit_hash}\"\n+\t\t\t\techo \"^${commit_hash}^2\"\n+\t\t\t\tset_notree \"${commit_hash}\"\n+\t\t\tfi\n+\t\t\t;;\n+\t\tesac\n+\tdone\n+}\n+\n # Usage: copy_commit REV TREE FLAGS_STR\n copy_commit () {\n \tassert test $# = 3\n@@ -785,42 +856,6 @@ ensure_valid_ref_format () {\n \t\tdie \"fatal: '$1' does not look like a ref\"\n }\n \n-# Usage: should_ignore_subtree_split_commit REV\n-#\n-# Check if REV is a commit from another subtree and should be\n-# ignored from processing for splits\n-should_ignore_subtree_split_commit () {\n-\tassert test $# = 1\n-\n-\tgit show \\\n-\t\t--no-patch \\\n-\t\t--no-show-signature \\\n-\t\t--format='%(trailers:key=git-subtree-dir,key=git-subtree-mainline)' \\\n-\t\t\"$1\" |\n-\t(\n-\thave_mainline=\n-\tsubtree_dir=\n-\n-\twhile read -r trailer val\n-\tdo\n-\t\tcase \"$trailer\" in\n-\t\tgit-subtree-dir:)\n-\t\t\tsubtree_dir=\"${val%/}\" ;;\n-\t\tgit-subtree-mainline:)\n-\t\t\thave_mainline=y ;;\n-\t\tesac\n-\tdone\n-\n-\tif test -n \"${subtree_dir}\" &&\n-\t\ttest -z \"${have_mainline}\" &&\n-\t\ttest \"${subtree_dir}\" != \"$arg_prefix\"\n-\tthen\n-\t\treturn 0\n-\tfi\n-\treturn 1\n-\t)\n-}\n-\n # Usage: process_split_commit REV PARENTS\n process_split_commit () {\n \tassert test $# = 2\n@@ -994,31 +1029,39 @@ cmd_split () {\n \tfi\n \n \tunrevs=\"$(find_existing_splits \"$dir\" \"$rev\" \"$repository\")\" || exit $?\n+\t(find_other_splits >\"$cachedir/prune\" \"$dir\" \"$rev\" $unrevs) || exit $?\n \n \t# We can't restrict rev-list to only $dir here, because some of our\n \t# parents have the $dir contents the root, and those won't match.\n \t# (and rev-list --follow doesn't seem to solve this)\n-\tgrl='git rev-list --topo-order --reverse --parents $rev $unrevs'\n-\trevmax=$(eval \"$grl\" | wc -l)\n+\trevmax=\"$(git rev-list \\\n+\t\t<\"$cachedir/prune\" \\\n+\t\t--topo-order \\\n+\t\t--reverse \\\n+\t\t--parents \\\n+\t\t--stdin \\\n+\t\t--count \\\n+\t\t\"$rev\" \\\n+\t\t$unrevs\n+\t)\"\n \trevcount=0\n \tcreatecount=0\n \textracount=0\n-\teval \"$grl\" |\n+\tgit rev-list \\\n+\t\t<\"$cachedir/prune\" \\\n+\t\t--topo-order \\\n+\t\t--reverse \\\n+\t\t--parents \\\n+\t\t--stdin \\\n+\t\t\"$rev\" \\\n+\t\t$unrevs |\n \twhile read rev parents\n \tdo\n-\t\tif should_ignore_subtree_split_commit \"$rev\"\n+\t\tif get_notree \"$rev\"\n \t\tthen\n \t\t\tcontinue\n \t\tfi\n-\t\tparsedparents=''\n-\t\tfor parent in $parents\n-\t\tdo\n-\t\t\tif ! should_ignore_subtree_split_commit \"$parent\"\n-\t\t\tthen\n-\t\t\t\tparsedparents=\"$parsedparents$parent \"\n-\t\t\tfi\n-\t\tdone\n-\t\tprocess_split_commit \"$rev\" \"$parsedparents\"\n+\t\tprocess_split_commit \"$rev\" \"$parents\"\n \tdone || exit $?\n \n \tlatest_new=$(cache_get latest_new) || exit $?\ndiff --git a/contrib/subtree/t/t7900-subtree.sh b/contrib/subtree/t/t7900-subtree.sh\nindex 316dc5269e..4db3a6eff3 100755\n--- a/contrib/subtree/t/t7900-subtree.sh\n+++ b/contrib/subtree/t/t7900-subtree.sh\n@@ -411,8 +411,9 @@ test_expect_success 'split sub dir/ with --rejoin' '\n \t\tgit fetch ./\"sub proj\" HEAD &&\n \t\tgit subtree merge --prefix=\"sub dir\" FETCH_HEAD &&\n \t\tsplit_hash=$(git subtree split --prefix=\"sub dir\" --annotate=\"*\") &&\n-\t\tgit subtree split --prefix=\"sub dir\" --annotate=\"*\" --rejoin &&\n-\t\ttest \"$(last_commit_subject)\" = \"Split '\\''sub dir/'\\'' into commit '\\''$split_hash'\\''\"\n+\t\tgit subtree split --prefix=\"sub dir\" --annotate=\"*\" -b spl --rejoin &&\n+\t\ttest \"$(last_commit_subject)\" = \"Split '\\''sub dir/'\\'' into commit '\\''$split_hash'\\''\" &&\n+\t\ttest \"$(git rev-list --count spl)\" -eq 5\n \t)\n '\n \n@@ -442,18 +443,25 @@ test_expect_success 'split with multiple subtrees' '\n \tgit -C \"$test_count\" subtree add --prefix=subADir FETCH_HEAD &&\n \tgit -C \"$test_count\" fetch ./subB HEAD &&\n \tgit -C \"$test_count\" subtree add --prefix=subBDir FETCH_HEAD &&\n+\ttest \"$(git -C \"$test_count\" rev-list --count main)\" -eq 7 &&\n \ttest_create_commit \"$test_count\" subADir/main-subA1 &&\n \ttest_create_commit \"$test_count\" subBDir/main-subB1 &&\n \tgit -C \"$test_count\" subtree split --prefix=subADir \\\n-\t\t--squash --rejoin -m \"Sub A Split 1\" &&\n+\t\t--squash --rejoin -m \"Sub A Split 1\" -b a1 &&\n+\ttest \"$(git -C \"$test_count\" rev-list --count main..a1)\" -eq 1 &&\n \tgit -C \"$test_count\" subtree split --prefix=subBDir \\\n-\t\t--squash --rejoin -m \"Sub B Split 1\" &&\n+\t\t--squash --rejoin -m \"Sub B Split 1\" -b b1 &&\n+\ttest \"$(git -C \"$test_count\" rev-list --count main..b1)\" -eq 1 &&\n \ttest_create_commit \"$test_count\" subADir/main-subA2 &&\n \ttest_create_commit \"$test_count\" subBDir/main-subB2 &&\n \tgit -C \"$test_count\" subtree split --prefix=subADir \\\n-\t\t--squash --rejoin -m \"Sub A Split 2\" &&\n+\t\t--squash --rejoin -m \"Sub A Split 2\" -b a2 &&\n+\ttest \"$(git -C \"$test_count\" rev-list --count main..a2)\" -eq 2 &&\n+\ttest \"$(git -C \"$test_count\" rev-list --count a1..a2)\" -eq 1 &&\n \ttest \"$(git -C \"$test_count\" subtree split --prefix=subBDir \\\n-\t\t--squash --rejoin -d -m \"Sub B Split 1\" 2>&1 | grep -w \"\\[1\\]\")\" = \"\"\n+\t\t--squash --rejoin -d -m \"Sub B Split 1\" -b b2 2>&1 | grep -w \"\\[1\\]\")\" = \"\" &&\n+\ttest \"$(git -C \"$test_count\" rev-list --count main..b2)\" -eq 2 &&\n+\ttest \"$(git -C \"$test_count\" rev-list --count b1..b2)\" -eq 1\n '\n \n # When subtree split-ing a directory that has other subtree\n@@ -477,6 +485,7 @@ do\n \t\t\ttest_path_is_file subA/file1.t &&\n \t\t\ttest_path_is_file subA/subB/file2.t &&\n \t\t\tgit subtree split --prefix=subA --branch=bsplit &&\n+\t\t\ttest \"$(git rev-list --count bsplit)\" -eq 2 &&\n \t\t\tgit checkout bsplit &&\n \t\t\ttest_path_is_file file1.t &&\n \t\t\ttest_path_is_file subB/file2.t &&\n@@ -489,6 +498,7 @@ do\n \t\t\t\t--prefix=subA/subB mksubtree &&\n \t\t\ttest_path_is_file subA/subB/file3.t &&\n \t\t\tgit subtree split --prefix=subA --branch=bsplit &&\n+\t\t\ttest \"$(git rev-list --count bsplit)\" -eq 3 &&\n \t\t\tgit checkout bsplit &&\n \t\t\ttest_path_is_file file1.t &&\n \t\t\ttest_path_is_file subB/file2.t &&\n@@ -497,6 +507,67 @@ do\n \t'\n done\n \n+# Usually,\n+#\n+#    git subtree merge -P subA --squash f00...\n+#\n+# makes two commits, in this order:\n+#\n+# 1. Squashed 'subA/' content from commit f00...\n+# 2. Merge commit (1) as 'subA'\n+#\n+# Commit 1 updates the subtree but does *not* rewrite paths.\n+# Commit 2 rewrites all trees to start with `subA/`\n+#\n+# Commit 1 either has no parents or depends only on other\n+# \"Squashed 'subA/' content\" commits.\n+#\n+# For merge without --squash, subtree produces just one commit:\n+# a merge commit with git-subtree trailers.\n+#\n+# In either case, if the user rebases these commits, they will\n+# still have the git-subtree-* trailers… but will NOT have\n+# the layout described above.\n+#\n+# Test that subsequent `git subtree split` are not confused by this.\n+test_expect_success 'split with rebased subtree commit' '\n+\tsubtree_test_create_repo \"$test_count\" &&\n+\t(\n+\t\tcd \"$test_count\" &&\n+\t\ttest_commit file0 &&\n+\t\ttest_create_subtree_add \\\n+\t\t\t. mksubtree subA file1 --squash &&\n+\t\ttest_path_is_file subA/file1.t &&\n+\t\tmkdir subB &&\n+\t\ttest_commit subB/bfile &&\n+\t\tgit commit --amend -F - <<'EOF' &&\n+Squashed '\\''subB/'\\'' content from commit '\\''badf00da911bbe895347b4b236f5461d55dc9877'\\''\n+\n+Simulate a cherry-picked or rebased subtree commit.\n+\n+git-subtree-dir: subB\n+git-subtree-split: badf00da911bbe895347b4b236f5461d55dc9877\n+EOF\n+\t\ttest_commit subA/file2 &&\n+\t\ttest_commit subB/bfile2 &&\n+\t\tgit commit --amend -F - <<'EOF' &&\n+Split '\\''subB/'\\'' into commit '\\''badf00da911bbe895347b4b236f5461d55dc9877'\\''\n+\n+Simulate a cherry-picked or rebased subtree commit.\n+\n+git-subtree-dir: subB\n+git-subtree-mainline: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n+git-subtree-split: badf00da911bbe895347b4b236f5461d55dc9877\n+EOF\n+\t\tgit subtree split --prefix=subA --branch=bsplit &&\n+\t\tgit checkout bsplit &&\n+\t\ttest_path_is_file file1.t &&\n+\t\ttest_path_is_file file2.t &&\n+\t\ttest \"$(last_commit_subject)\" = \"subA/file2\" &&\n+\t\ttest \"$(git rev-list --count bsplit)\" -eq 2\n+\t)\n+'\n+\n test_expect_success 'split sub dir/ with --rejoin from scratch' '\n \tsubtree_test_create_repo \"$test_count\" &&\n \ttest_create_commit \"$test_count\" main1 &&\n\nbase-commit: 9a2fb147f2c61d0cab52c883e7e26f5b7948e3ed\n-- \n2.52.0\n\n"}]}