diff: skip unmodified trees in tree-to-tree diffs by yerseg · Pull Request #6863 · libgit2/libgit2 · GitHub
Skip to content

diff: skip unmodified trees in tree-to-tree diffs - #6863

Open
yerseg wants to merge 8 commits into
libgit2:mainfrom
yerseg:do_not_autoexpand_unmodified_trees
Open

yerseg wants to merge 8 commits into
libgit2:mainfrom
yerseg:do_not_autoexpand_unmodified_trees

Conversation

@yerseg

@yerseg yerseg commented Aug 20, 2024 •

Copy link
Copy Markdown
Contributor

git_diff_tree_to_tree currently expands every subtree on both sides, so diffing two big trees that differ in a single file still walks and compares every file.

This makes the tree iterators return tree entries instead. If both sides have a tree at the same path with the same id, the diff skips it without loading it. Any other tree gets expanded before the rest of the diff sees it, so the resulting deltas don't change.

Trees aren't skipped with GIT_DIFF_INCLUDE_UNMODIFIED (the unmodified entries have to be listed) or with GIT_DIFF_IGNORE_CASE (the case-insensitive tree iterator merges trees whose names differ only in case, so you can't skip just one of them).

Numbers from the benchmark in #7395, RelWithDebInfo, tree with 32768 files:

main this PR
tree vs. itself 4.05 ms 16.1 µs
one file changed 4.05 ms 24.6 µs

Testing: there are new tests in tests/libgit2/diff/tree.c. does_not_load_unmodified_subtrees is expected to fail on main, since the shared subtree isn't in the odb and main tries to read it. I also ran the fuzzer from #7395, which checks every diff against the same diff with GIT_DIFF_INCLUDE_UNMODIFIED (that one expands all trees). About 33M runs, no differences. On the first version of this PR it found a difference within 90 seconds every time. git_blame_file on a few files of this repo gives the same hunks as main.

The first version had several bugs: tree deltas were passed to the notify callback, IGNORE_CASE | INCLUDE_CASECHANGE left a delta for the directory itself, trees with a mode other than 040000 weren't expanded, and nothing was skipped when opts was NULL. The last commit fixes them and adds the tests. The branch is still based on an older main, but it merges cleanly with the current one. I ran the tests on the merge result, and the fuzzer on current main with this change and #7394 (without that one the fuzzer finds the older bug instead).

@yerseg

yerseg commented Oct 8, 2024

Copy link
Copy Markdown
Contributor Author

@ethomson sorry to disturb you but what do you think about these changes? Do they seem valid or not?

@ethomson

ethomson commented Oct 9, 2024

Copy link
Copy Markdown
Member

Hi @yerseg - no disturbance, thanks for the ping. I'll try to 👀 this soon. 🙏

@yerseg

yerseg commented Nov 15, 2024

Copy link
Copy Markdown
Contributor Author

@ethomson what’s up? will you have free time to review this?

@OrHayat

OrHayat commented Sep 23, 2026 •

Copy link
Copy Markdown

hi @ethomson
i analyized a script that uses pygit2 and it was too slow
after checking with claude he found the same gap as this pr
and i checked and found that this pr exists so i built pygit2 with that branch and ran benchamrk
it improved performance by alot.
to make results reproducible i reran it on this git and this is the results.

The branch is based on v1.8.2+27, so I cherry-picked its 7 commits onto v1.9.7 —
applies clean, same +101/-15 in diff_generate.c. Built v1.9.7 and v1.9.7+PR with
identical flags (Release, BUILD_SHARED_LIBS=ON, USE_SSH=OFF, system zlib, SHA1
CollisionDetection) and ran both through pygit2 1.20.1 built from source against each.
macOS 15, arm64.

Test repo is libgit2.git itself at main: 16450 commits, 15606 files, 76 MiB pack.
Each of the newest 3000 single-parent commits diffed against its parent.

v1.9.7 + this PR
diff(main_tree, main_tree) x1000 3.54s 0.01s ~300x
3000 tree diffs, deltas only 7.90s 0.19s 42x
3000 diffs + git_diff_patchid 10.11s 2.13s 4.7x

git on the same 3000 commits: git log --raw 0.15s, git log -p | git patch-id --stable
1.08s. So the tree walk now lands near git, where before it was ~50x off. What is left
in the patchid row is xdiff and hashing, which this PR does not touch.

Both builds produce identical output: same delta count per commit (21394 total), same
git_diff_patchid for all 3000, same path lists on the commits I checked.

Two caveats: this is measured through pygit2 rather than a C harness (same overhead on
both sides, so the ratios hold), and I benchmarked without running the test suite.

Compare only the ids of two trees at the same path instead of calling
maybe_modified on them.  maybe_modified passed a delta for the tree to
the notify callback, and a callback that skipped it made the diff skip
the whole tree.  With GIT_DIFF_IGNORE_CASE and
GIT_DIFF_INCLUDE_CASECHANGE it added two deltas for a tree, and only
one of them was removed.

Detect trees with S_ISDIR like the tree iterator does, so that trees
with a mode like 040755 are expanded too.

Expand only the tree that comes next.  With
GIT_DIFF_INCLUDE_TYPECHANGE_TREES, also expand the trees that sort
between a file `a` and `a/`, since typechange detection compares `a`
with the next entry on the other side.

Do not skip trees in case insensitive diffs: the case insensitive tree
iterator merges trees whose names differ only in case, so one of them
cannot be skipped on its own.  Return tree entries also when the diff
options are NULL.

Add tests for these cases.
@yerseg yerseg changed the title diff: do not autoexpand unmodified trees while calculating tree to tree diff diff: skip unmodified trees in tree-to-tree diffs Sep 30, 2026
@yerseg

yerseg commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants