Merge branch 'kk/connectivity-check-incremental' into seen

The connectivity check in 'git fetch' and 'git receive-pack' has
been optimized with an alternative incremental mode, available via
'transfer.connectivityCheck=incremental'.  Instead of walking the
full object closure of the boundary commits, it compares the trees
of incoming commits against their trusted parent trees, skipping
unchanged subtrees.

* kk/connectivity-check-incremental:
  connected: add incremental connectivity check via rev-list
  connected: add transfer.connectivityCheck config and rev-list flag
  p5413: add perf tests for connectivity check
  Documentation: describe connectivity checking
seen
Junio C Hamano 2026-10-12 14:23:53 -07:00
commit 824e95128e
13 changed files with 1474 additions and 0 deletions

View File

@ -1,3 +1,23 @@
transfer.connectivityCheck::
Choose which algorithm to use for the connectivity check
performed during object transfer operations such as
linkgit:git-fetch[1] and linkgit:git-receive-pack[1].
The connectivity check verifies that all objects reachable
from the incoming tips are available locally or, in a partial
clone, promised by a promisor remote.
The variants are as follows:
+
--
`full` (default);;
Walk the full object closure of the boundary commits.
`incremental`;;
Verify incoming commits by diffing their trees against parent
trees, recursively descending only into entries that differ.
The largest benefits occur when incoming commits change a
small fraction of a large tree closure.
Falls back to `full` for deepening fetches.
--

transfer.credentialsInUrl::
A configured URL can contain plaintext credentials in the form
`<protocol>://<user>:<password>@<domain>/<path>`. You may want

View File

@ -1102,6 +1102,12 @@ It is an error to use `--missing-only` without `--missing=print` or
stronger than `--missing=allow-promisor` because it limits the
traversal, rather than just silencing errors about missing
objects.

`--verify-trees-incremental`::
(For internal use only.) Verify tree connectivity
incrementally by comparing each commit's tree against its
parent trees. Used by `check_connected()` when
`transfer.connectivityCheck` is set to `incremental`.
endif::git-rev-list[]

`--no-walk[=(sorted|unsorted)]`::

View File

@ -0,0 +1,255 @@
Connectivity checking
=====================

After receiving new objects via fetch, push (receive-pack), clone,
or bundle, Git verifies that the new reference tips do not leave
the repository in a state where reachable objects are missing.
This verification is called the connectivity check.

Connectivity invariant
----------------------

A repository is connected when every object reachable from its
references is available locally (with exceptions noted below).
The reverse does not hold: objects that exist in the object
database but are not reachable from any reference may be
incomplete or lack a connected closure.

The connectivity check maintains this invariant when references
are updated. It trusts that all objects reachable from existing
reference tips are already fully connected and verifies that the
new reference tips do not introduce references to unavailable
objects. Verification is permitted to stop when it reaches
objects already reachable from existing references, since their
closure is already connected. These trusted references include
local references and references from alternate object stores.

Without this check, a truncated or corrupted transfer could leave
a repository in a state where later history walks encounter
missing objects.

Exceptions
~~~~~~~~~~

Gitlink entries (submodule references) are excluded from
connectivity checking. Their target objects belong to a separate
repository.

In partial clones, objects promised by a promisor remote are
accepted as connected without requiring local existence. The
check excludes promisor objects from traversal so that it does
not trigger on-demand fetches for them.

Full connectivity check
-----------------------

The full connectivity check runs a `rev-list` subprocess, feeding
the new reference tips via stdin. A normal invocation is roughly:

git rev-list --objects --stdin --not --all --quiet
--alternate-refs [--exclude-promisor-objects]

When promisor remotes are configured, a fast path based on
promisor packfiles is attempted first. If it falls back to
the `rev-list` check, `--exclude-promisor-objects` is added so
that the traversal does not trigger on-demand fetches.

Consider the following graph after a fetch, where all reference
tips point directly to commits. For simplicity, only local
references appear on the already-connected side; alternate refs
play the same role. N3 is a merge commit:

/-------------L2
/
C1---B1---C2---B2-----L1
\ \
N1 N3---T2
\ /
N2-----------T1

L1, L2: local refs
T1, T2: incoming tips (new refs)
N1, N2, N3: incoming commits (N3 is a merge)
B1, B2: boundary commits (already connected)
C1, C2: already connected (but not boundary)

The incoming set is the commits reachable from the incoming
tips but not from the already-connected side. Boundary commits
are the already-connected commits at the edge of that set. Here
B1 is an ancestor of B2, which happens when incoming branches
fork at different depths in the existing history.

The `--not --all` arguments establish the trust boundary: the
incoming set is the commits reachable from the new tips but not
from any existing reference.

The traversal then walks the trees of the boundary commits and
marks those objects uninteresting. Trees of incoming commits
are verified against these marks, stopping at objects already
covered. The cost of marking boundary trees uninteresting
dominates when the boundary commits have large trees, even when
the incoming changes are small.

Deepening fetches
~~~~~~~~~~~~~~~~~

For deepening fetches (where the shallow boundary moves), the
full check omits `--not --all`. There is no existing-reference
boundary at which the walk can stop. Instead, traversal follows
the effective shallow boundary supplied for the deepened
repository. The new content may be below the old shallow
boundary even when the tips themselves have not changed.

Non-commit tips
~~~~~~~~~~~~~~~

When a new reference points to a non-commit object, such as a
tag, tree, or blob, that object is not part of the commit walk.
These non-commit tips are verified during the tree and blob
traversal that follows the commit walk within the same rev-list
invocation.

Incremental connectivity check
------------------------------

The incremental mode, selected by
`transfer.connectivityCheck=incremental`, avoids traversing the
full tree walk of the boundary commits. Instead, it verifies
each incoming commit's tree against the already-trusted trees of
its parent commits.

The same `--not --all` boundary as the full check identifies the
incoming commits. The incremental mode reuses this boundary but
replaces what happens afterwards: rather than walking boundary
trees to mark objects uninteresting, it compares each incoming
commit's tree against its parents' trees, processing commits in
topological order (ancestors first).

Trust model
~~~~~~~~~~~

A tree is trusted when its transitive object closure is known to
be connected.

Every parent of an incoming commit is either on the
already-connected side of the `--not --all` boundary (a boundary
parent whose tree is trusted by definition) or is an earlier
incoming commit that was already verified due to the topological
processing order. In both cases the parent's tree is trusted, so
all parent trees can be unconditionally used as comparison bases.

Once an incoming commit's tree has been verified, it is trusted
and can serve as a comparison base for later descendants. This
gives an inductive correctness argument: the base case is the
trust boundary established by `--not --all`, and the inductive
step is that verifying a commit extends the trusted set.

Tree states
~~~~~~~~~~~

The verifier tracks tree OIDs in three states:

untrusted::
The tree has not yet been established as connected. This
is the implicit state of an OID not present in the state
map.

trusted::
The tree is known to have a connected transitive closure,
but its direct entries have not yet been published into the
verifier's trusted object sets.

expanded::
The tree is trusted and its direct non-gitlink entries
have also been published into the trusted object sets.

State transitions are monotonic: a tree may move from untrusted
to trusted to expanded, but never backwards. An untrusted tree
that is successfully verified goes directly to expanded.

Blobs require only trusted/untrusted state: a blob becomes
trusted when it is found in a trusted tree or when its existence
and type have been verified directly.

The trusted/expanded distinction is an optimization. An expanded
parent does not need to be reread merely to publish entries that
are already trusted, though it may still be read when same-path
subtree bases are needed for recursive comparison.

Algorithm
~~~~~~~~~

At a high level:

verify(commits):
sort topologically (ancestors first)
for each commit:
mark parent root trees as trusted
verify_tree(commit.tree, parent root trees)

verify_tree(tree, base_trees):
if tree already trusted: return
read tree, collect entries not already trusted
for each available base tree:
publish its entries as trusted
record same-path subtrees as bases
for each collected entry:
if now trusted: skip
if blob: verify blob connectivity and type
if tree: verify_tree(entry, its recorded bases)
mark tree as expanded

The important ordering within `verify_tree` is that entries from
the new tree are collected before the base trees are scanned, but
are processed only afterwards. Trust learned from any base can
therefore eliminate work before recursive verification begins.

Same-path parent subtrees are passed down as comparison bases
during recursive descent. If no comparison base is available,
the new subtree is verified from scratch.

Example
~~~~~~~

Consider a commit that changes one file under `lib/` and moves an
unchanged subtree from `src/` to `dev/`:

Parent tree New tree
+-- src/ (aaa) +-- dev/ (aaa)
+-- lib/ (bbb) +-- lib/ (ccc)
+-- foo.c (ddd) +-- foo.c (ddd)
+-- bar.c (eee) +-- bar.c (fff)

Scanning the parent makes `aaa` trusted even though it moved from
`src/` to `dev/`, so that subtree is skipped. The changed `ccc`
subtree is compared against its same-path parent `bbb`; scanning
`bbb` makes `ddd` and `eee` trusted, leaving only the new `fff`
blob to be checked.

This illustrates two properties:

* Trust is OID-based rather than path-based. An unchanged subtree
is recognized after a move.

* Same-path parent subtrees provide recursive comparison bases.
These bases improve pruning efficiency but are not required for
correctness; a new subtree can always be verified from scratch.

Multiple parents
~~~~~~~~~~~~~~~~

For a merge commit, root trees from all parents are comparison
bases. When several parents contain a same-path subtree, each
matching subtree is collected as a recursive comparison base.

Entries published from any trusted parent become globally trusted,
so a matching tree or blob entry present in any parent can be
skipped while verifying the merge tree.

Missing comparison bases
~~~~~~~~~~~~~~~~~~~~~~~~

A promised base tree may not be locally available for comparison.
In that case the verifier skips that base and verifies the new
subtree without it. The missing comparison can reduce pruning but
does not affect correctness.

View File

@ -1351,6 +1351,7 @@ LIB_OBJS += trailer.o
LIB_OBJS += transport-helper.o
LIB_OBJS += transport.o
LIB_OBJS += tree-diff.o
LIB_OBJS += tree-verify.o
LIB_OBJS += tree-walk.o
LIB_OBJS += tree.o
LIB_OBJS += unpack-trees.o

View File

@ -28,6 +28,7 @@
#include "commit-reach.h"
#include "quote.h"
#include "strbuf.h"
#include "tree-verify.h"

struct rev_list_info {
struct rev_info *revs;
@ -727,6 +728,7 @@ int cmd_rev_list(int argc,
int bisect_find_all = 0;
int use_bitmap_index = 0;
int filter_provided_objects = 0;
int verify_trees_incremental = 0;
const char *show_progress = NULL;
int ret = 0;

@ -769,6 +771,9 @@ int cmd_rev_list(int argc,
if (!strcmp(arg, "--exclude-promisor-objects")) {
repo->fetch_if_missing = 0;
revs.exclude_promisor_objects = 1;
} else if (!strcmp(arg, "--verify-trees-incremental")) {
verify_trees_incremental = 1;
repo->fetch_if_missing = 0;
} else if (skip_prefix(arg, "--missing=", &arg)) {
parse_missing_action_value(repo, arg);
} else if (!strcmp(arg, "--missing-only")) {
@ -848,6 +853,8 @@ int cmd_rev_list(int argc,

if (!strcmp(arg, "--exclude-promisor-objects"))
continue; /* already handled above */
if (!strcmp(arg, "--verify-trees-incremental"))
continue; /* already handled above */
if (skip_prefix(arg, "--missing=", &arg))
continue; /* already handled above */

@ -969,6 +976,18 @@ int cmd_rev_list(int argc,

prepare_maximal_independent(&revs);

if (verify_trees_incremental) {
struct commit *commit;
struct commit_list *new_commits = NULL;

while ((commit = get_revision(&revs)) != NULL)
commit_list_insert(commit, &new_commits);

verify_commits_incremental(repo, &new_commits,
revs.exclude_promisor_objects);
commit_list_free(new_commits);
}

if (revs.tree_objects)
mark_edges_uninteresting(&revs, show_edge, 0);


View File

@ -1,6 +1,7 @@
#define USE_THE_REPOSITORY_VARIABLE

#include "git-compat-util.h"
#include "config.h"
#include "gettext.h"
#include "hex.h"
#include "odb.h"
@ -67,6 +68,26 @@ static int check_connected_promisor(oid_iterate_fn fn,
return 1;
}

static int incremental_check_applicable(struct check_connected_options *opt)
{
const char *algorithm = NULL;

if (repo_config_get_string_tmp(the_repository,
"transfer.connectivitycheck",
&algorithm))
return 0;
if (!strcasecmp(algorithm, "full"))
return 0;
if (strcasecmp(algorithm, "incremental"))
die(_("unknown transfer.connectivityCheck algorithm '%s'"),
algorithm);

if (opt->is_deepening_fetch)
return 0;

return 1;
}

/*
* If we feed all the commits we want to verify to this command
*
@ -133,6 +154,9 @@ int check_connected(oid_iterate_fn fn, void *cb_data,
if (opt->progress)
strvec_pushf(&rev_list.args, "--progress=%s",
_("Checking connectivity"));
if (incremental_check_applicable(opt))
strvec_push(&rev_list.args,
"--verify-trees-incremental");

rev_list.git_cmd = 1;
if (opt->env)

View File

@ -565,6 +565,7 @@ libgit_sources = [
'transport-helper.c',
'transport.c',
'tree-diff.c',
'tree-verify.c',
'tree-walk.c',
'tree.c',
'unpack-trees.c',

View File

@ -662,6 +662,7 @@ integration_tests = [
't5410-receive-pack.sh',
't5411-proc-receive-hook.sh',
't5412-receive-report-hook.sh',
't5413-connectivity-check.sh',
't5500-fetch-pack.sh',
't5501-fetch-push-alternates.sh',
't5502-quickfetch.sh',

View File

@ -0,0 +1,44 @@
#!/usr/bin/perl
#
# Generate a fast-import stream for p5412 connectivity check benchmarks.
#
# Usage: generate-repo-p5412-connectivity-check.perl
# <dirs> <files_per_dir> <commits> [<hot_dirs>] [<files_per_commit>]
#
# Creates one initial commit with dirs*files_per_dir files, then
# <commits> additional commits each modifying <files_per_commit>
# files in directories chosen round-robin from 1..<hot_dirs>.

use strict;
use warnings;

my ($nd, $nf, $nc, $hot, $fpc) = @ARGV;
$hot = $nd if !$hot || $hot > $nd;
$fpc = 1 if !$fpc;

sub data {
printf "data %d\n%s\n", length($_[0]), $_[0];
}

# Initial tree: one commit with nd*nf files.
printf "commit refs/heads/main\n";
printf "committer perf <perf\@test.com> now\n";
data("initial");
for my $d (1..$nd) {
for my $f (1..$nf) {
printf "M 100644 inline d-%04d/f-%03d\n", $d, $f;
data(sprintf "%03d%03d", $d, $f);
}
}

# Subsequent commits (auto-chained by fast-import).
for my $i (1..$nc) {
printf "commit refs/heads/main\n";
printf "committer perf <perf\@test.com> now\n";
data(sprintf "change-%03d", $i);
for my $j (0..$fpc-1) {
my $d = (($i + $j) % $hot) + 1;
printf "M 100644 inline d-%04d/f-001\n", $d;
data(sprintf "c%d-%d", $i, $j);
}
}

View File

@ -0,0 +1,92 @@
#!/bin/sh

test_description='performance of connectivity check modes

Compare the default and incremental rev-list connectivity modes
directly, avoiding pack transfer noise.

Each repository has a flat tree of many directories with 100 files
in each. Three axes are scaled independently: tree size, commit
count, and files changed per commit.'

. ./perf-lib.sh

test_perf_fresh_repo

generate="$TEST_DIRECTORY/perf/generate-repo-p5413-connectivity-check.perl"

# $1=dirs $2=files_per_dir $3=commits $4=hot_dirs (optional, default=all)
# $5=files_per_commit (optional, default=1)
test_perf_conn () {
local nd="$1" nf="$2" nc="$3" hot="${4:-$1}" fpc="${5:-1}"
local total=$(($nd * $nf))
local name="repo-${nd}d-${nf}f-${nc}c-${hot}h-${fpc}fpc"
local label="${total} files, ${nc} commits"
if test "$hot" -lt "$nd"
then
label="$label (${hot} hot dirs)"
fi
if test "$fpc" -gt 1
then
label="$label (${fpc} files/commit)"
fi

test_expect_success "setup $label" '
if test -d '"$name"'
then
true
else
git init '"$name"' &&
"$PERL_PATH" '"$generate"' '"$nd"' '"$nf"' '"$nc"' '"$hot"' '"$fpc"' |
git -C '"$name"' fast-import --date-format=now --quiet &&
(
cd '"$name"' &&
git rev-parse main~'"$nc"' >../'"${name}"'_old &&
git rev-parse main >../'"${name}"'_new &&
git update-ref refs/heads/main \
$(cat ../'"${name}"'_old) &&
git repack -ad &&
git config gc.auto 0
)
fi
'

test_perf "$label (full)" '
cat '"${name}"'_new |
git -C '"$name"' rev-list \
--objects --stdin --not --all --quiet \
--exclude-promisor-objects
'

test_perf "$label (incremental)" '
cat '"${name}"'_new |
git -C '"$name"' rev-list --verify-trees-incremental \
--objects --stdin --not --all --quiet \
--exclude-promisor-objects
'
}

# Scaling tree size (10 commits, 10 files/commit).
test_perf_conn 50 100 10 50 10
test_perf_conn 500 100 10 500 10
test_perf_conn 2000 100 10 2000 10
test_perf_conn 8000 100 10 8000 10

# Scaling commit count (200K files, 10 files/commit).
test_perf_conn 2000 100 1 2000 10
test_perf_conn 2000 100 10 2000 10
test_perf_conn 2000 100 100 2000 10
test_perf_conn 2000 100 500 2000 10
test_perf_conn 2000 100 3000 2000 10
test_perf_conn 2000 100 5000 2000 10
test_perf_conn 2000 100 10000 2000 10

# Scaling files per commit (200K files, 10 commits).
test_perf_conn 2000 100 10 2000 1
test_perf_conn 2000 100 10 2000 10
test_perf_conn 2000 100 10 2000 100
test_perf_conn 2000 100 10 2000 500
test_perf_conn 2000 100 10 2000 1000
test_perf_conn 2000 100 10 2000 2000

test_done

680
t/t5413-connectivity-check.sh Executable file
View File

@ -0,0 +1,680 @@
#!/bin/sh

test_description='connectivity check (transfer.connectivityCheck)'
GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME

. ./test-lib.sh

test_oid_cache <<-\EOF
missing sha1:0000000000000000000000000000000000000001
missing sha256:0000000000000000000000000000000000000000000000000000000000000001
EOF

set_connectivity_check () {
git -C "$1" config transfer.connectivityCheck "$2"
}

# Run a connectivity check via rev-list directly. Uses $mode
# (set by the enclosing for-loop) to choose full or incremental.
check_connected () {
flags= &&
if test "$mode" = incremental
then
flags=--verify-trees-incremental
fi &&
printf '%s\n' "$@" |
git rev-list $flags \
--objects --stdin --not --all --quiet \
--exclude-promisor-objects
}

# Run git with a temporary index, leaving the real index untouched.
tmpgit () {
GIT_INDEX_FILE=.git/tmp-idx git "$@"
}

# Create a commit with one file changed, without modifying HEAD,
# index, or worktree. Prints the new commit OID on stdout.
# Usage: commit_with_change <parent> <path> <content>
commit_with_change () {
new_blob=$(echo "$3" | git hash-object -w --stdin) &&
tmpgit read-tree "$1" &&
tmpgit update-index --replace \
--cacheinfo "100644,$new_blob,$2" &&
new_tree=$(tmpgit write-tree) &&
rm -f .git/tmp-idx &&
git commit-tree "$new_tree" -p "$1" -m "modify $2"
}

# Check OIDs and optionally verify trace2 counts.
# Usage: check_connected_trace <trace-file> <trees> <blobs> <oid>...
# An empty string for <trees> or <blobs> skips that assertion.
check_connected_trace () {
trace_file=$1 trees=$2 blobs=$3 &&
shift 3 &&
test_env GIT_TRACE2_EVENT="$(pwd)/$trace_file" \
check_connected "$@" &&
if test "$mode" != incremental
then
return
fi &&
if test -n "$trees"
then
test_trace2_data_singular connectivity trees_loaded "$trees" \
<"$trace_file"
fi &&
if test -n "$blobs"
then
test_trace2_data_singular connectivity blobs_checked "$blobs" \
<"$trace_file"
fi
}

# Shared setup: a repo with several root-level files and nested dirs.
# The unchanged/ subtree (10 dirs x 10 files = 100 blobs, 11 trees)
# acts as a canary: any test asserting small tree/blob counts would
# fail dramatically if incremental accidentally walked into it.

test_expect_success 'setup main repo' '
git init main-repo &&
(
cd main-repo &&
for i in $(test_seq 1 5)
do
echo "file $i" >"file-$i.txt" || return 1
done &&
git add file-*.txt &&
git commit -m "initial" &&

mkdir -p a/b/c &&
echo deep >a/b/c/deep.txt &&
echo other >a/other.txt &&
git add a/b/c/deep.txt a/other.txt &&
git commit -m "add nested dirs" &&

for i in $(test_seq 1 10)
do
d="unchanged/dir-$i" &&
mkdir -p "$d" &&
for j in $(test_seq 1 10)
do
echo "$i $j" >"$d/file-$j.txt" || return 1
done
done &&
git add unchanged/ &&
git commit -m "add unchanged canary subtree"
)
'

test_expect_success 'setup replacement object repo' '
git init replace-test &&
(
cd replace-test &&

test_commit --no-tag original file.txt &&
original=$(git rev-parse HEAD) &&
orig_blob=$(git rev-parse HEAD:file.txt) &&

# Orphan replacement commit with a different tree
replacement_tree=$(echo replaced | git hash-object -w --stdin |
xargs -I{} git mktree <<-EOF
100644 blob {} file.txt
EOF
) &&
replacement=$(git commit-tree -m "replacement" \
"$replacement_tree") &&

git replace "$original" "$replacement" &&

# Remove the original blob so only the replacement
# tree is complete.
rm .git/objects/$(test_oid_to_path "$orig_blob") &&

# Drop branch and HEAD so --not --all does not
# exclude the original commit.
git update-ref -d refs/heads/main &&
git update-ref -d HEAD &&

echo "$original" >.git/test-oid
)
'

missing_oid=$(test_oid missing)
original_oid=$(cat replace-test/.git/test-oid)

for mode in full incremental
do

# Corruption detection: craft broken object graphs and verify detection.
# All tests use main-repo without modifying its refs or worktree.

test_expect_success "$mode: rejects commit with missing blob" '
(
cd main-repo &&
bad_tree=$(printf "100644 blob ${missing_oid}\tfile.txt\n" |
git mktree --missing) &&
bad_commit=$(git commit-tree "$bad_tree" -p HEAD -m "bad") &&
test_expect_code 128 check_connected "$bad_commit" 2>err &&
test_grep "missing blob object" err
)
'

test_expect_success "$mode: rejects commit with missing subtree" '
(
cd main-repo &&
bad_tree=$(printf "40000 tree ${missing_oid}\tdir\n" |
git mktree --missing) &&
bad_commit=$(git commit-tree "$bad_tree" -p HEAD -m "bad") &&
test_expect_code 128 check_connected "$bad_commit" 2>err &&
test_grep "bad tree object" err
)
'

test_expect_success "$mode: verifies direct tree tip" '
(
cd main-repo &&
bad_tree=$(printf "100644 blob ${missing_oid}\tfile.txt\n" |
git mktree --missing) &&
test_expect_code 128 check_connected "$bad_tree" 2>err &&
test_grep "missing blob object" err
)
'

test_expect_success "$mode: verifies direct blob tip" '
(
cd main-repo &&
blob_oid=$(echo "hello" | git hash-object -w --stdin) &&
check_connected "$blob_oid"
)
'

test_expect_success "$mode: rejects missing direct blob tip" '
(
cd main-repo &&
test_expect_code 128 check_connected \
"$missing_oid" 2>err
)
'

test_expect_success PERL_TEST_HELPERS \
"$mode: rejects blob OID reused as tree entry" '
(
cd main-repo &&

blob_oid=$(git rev-parse HEAD:file-1.txt) &&
bin_oid=$(echo "$blob_oid" | hex2oct) &&

bad_tree=$(printf "40000 subdir\0$bin_oid" |
git hash-object -t tree -w --stdin) &&
bad_commit=$(git commit-tree -p HEAD -m "child" "$bad_tree") &&

test_expect_code 128 check_connected "$bad_commit" 2>err &&
test_grep "not a tree" err
)
'

test_expect_success PERL_TEST_HELPERS \
"$mode: rejects tree OID reused as blob entry" '
(
cd main-repo &&

tree_oid=$(git rev-parse HEAD:a) &&
bin_oid=$(echo "$tree_oid" | hex2oct) &&

bad_tree=$(printf "100644 fakefile\0$bin_oid" |
git hash-object -t tree -w --stdin) &&
bad_commit=$(git commit-tree -p HEAD -m "child" "$bad_tree") &&

test_expect_code 128 check_connected "$bad_commit" 2>err &&
test_grep "not a blob" err
)
'

test_expect_success "$mode: checks multiple tips" '
(
cd main-repo &&
c1=$(commit_with_change HEAD file-1.txt "tip-a") &&
c2=$(commit_with_change HEAD file-2.txt "tip-b") &&
git tag -a -m "tagged" multi-tag "$c1" &&
tag_oid=$(git rev-parse multi-tag) &&
git tag -d multi-tag &&
check_connected "$c2" "$tag_oid"
)
'

# Tree-diff optimization: verify trace2 counts.

test_expect_success "$mode: handles root commit (no parents)" '
(
cd main-repo &&
blob_oid=$(echo "root-content" | git hash-object -w --stdin) &&
tree_oid=$(printf "100644 blob %s\tfile.txt\n" \
"$blob_oid" | git mktree) &&
root_oid=$(git commit-tree "$tree_oid" -m "root") &&

# No parent trees, so the full tree is verified.
# 1 tree loaded (root), 1 blob checked.
check_connected_trace trace-root.txt 1 1 "$root_oid"
)
'

test_expect_success "$mode: handles single file change" '
(
cd main-repo &&
oid=$(commit_with_change HEAD file-1.txt "changed") &&

# 2 trees loaded (new root + parent root), 1 blob checked.
check_connected_trace trace-flat.txt 2 1 "$oid"
)
'

test_expect_success "$mode: handles nested change" '
(
cd main-repo &&
oid=$(commit_with_change HEAD a/b/c/deep.txt "deep-changed") &&
# 4 new trees + 4 parent trees = 8 loaded, 1 blob checked.
check_connected_trace trace-nested.txt 8 1 "$oid"
)
'

test_expect_success "$mode: handles change-then-revert" '
(
cd main-repo &&
c1=$(commit_with_change HEAD file-1.txt "revert-tmp") &&
c2=$(commit_with_change "$c1" file-1.txt "file 1") &&
c3=$(commit_with_change "$c2" file-1.txt "revert-final") &&

# c1: new root + parent root = 2 loads. c2: root matches
# HEAD (already trusted), skipped. c3: new root + parent
# already expanded = 1 load. Total: 3 trees, 2 blobs.
check_connected_trace trace-revert.txt 3 2 "$c3"
)
'

test_expect_success "$mode: handles subtree moved to another path" '
(
cd main-repo &&
moved_tree=$(git ls-tree HEAD |
sed "s/ a$/ moved/" |
git mktree) &&
moved=$(git commit-tree "$moved_tree" -p HEAD -m move) &&
# New root + parent root scanned, but the moved subtree
# (same OID) is trusted and not descended into.
check_connected_trace trace-move.txt 2 0 "$moved"
)
'

test_expect_success "$mode: handles multi-parent merge" '
(
cd main-repo &&
left=$(commit_with_change HEAD file-1.txt left) &&
right=$(commit_with_change HEAD file-2.txt right) &&
git update-ref refs/heads/left "$left" &&
git update-ref refs/heads/right "$right" &&
left_blob=$(git rev-parse "$left:file-1.txt") &&
right_blob=$(git rev-parse "$right:file-2.txt") &&
tmpgit read-tree HEAD &&
tmpgit update-index --replace \
--cacheinfo "100644,$left_blob,file-1.txt" &&
tmpgit update-index --replace \
--cacheinfo "100644,$right_blob,file-2.txt" &&
merge_tree=$(tmpgit write-tree) &&
rm -f .git/tmp-idx &&
merge=$(git commit-tree "$merge_tree" \
-p "$left" -p "$right" -m merge) &&
# Both parent root trees are scanned as bases, so
# blobs from each parent are trusted without ODB checks.
check_connected_trace trace-merge.txt 3 0 "$merge" &&
git update-ref -d refs/heads/left &&
git update-ref -d refs/heads/right
)
'

test_expect_success "$mode: handles merge with incoming and boundary parents" '
(
cd main-repo &&
# parent1 is incoming (not a ref), HEAD is boundary.
parent1=$(commit_with_change HEAD file-1.txt merge-inc) &&
merge=$(git commit-tree \
"$(git rev-parse "$parent1^{tree}")" \
-p "$parent1" -p HEAD -m merge-mixed) &&
# parent1 verified first (topo order): 2 trees, 1 blob.
# merge tree = parent1 tree (already trusted): 0 extra.
check_connected_trace trace-merge-inc.txt 2 1 "$merge"
)
'

test_expect_success "$mode: handles gitlink entries (submodules)" '
(
cd main-repo &&
tmpgit read-tree HEAD &&
tmpgit update-index --add \
--cacheinfo "160000,$missing_oid,my-submodule" &&
gitlink_tree=$(tmpgit write-tree) &&
rm -f .git/tmp-idx &&
gitlink_commit=$(git commit-tree "$gitlink_tree" -p HEAD \
-m "add gitlink") &&

# Gitlink entries are skipped -- the missing submodule
# commit OID does not cause a failure.
check_connected_trace trace-gitlink.txt 2 0 "$gitlink_commit"
)
'

test_expect_success "$mode: handles file-to-directory transition" '
(
cd main-repo &&

# Parent: "foo" is a blob at root.
blob_a=$(echo "file-content" | git hash-object -w --stdin) &&
tmpgit read-tree HEAD &&
tmpgit update-index --add \
--cacheinfo "100644,$blob_a,foo" &&
parent_tree=$(tmpgit write-tree) &&
rm -f .git/tmp-idx &&
parent=$(git commit-tree "$parent_tree" -p HEAD \
-m "add foo as file") &&

# Child: "foo" becomes a directory (foo/bar.txt).
blob_b=$(echo "dir-content" | git hash-object -w --stdin) &&
tmpgit read-tree "$parent" &&
tmpgit update-index --remove foo &&
tmpgit update-index --add \
--cacheinfo "100644,$blob_b,foo/bar.txt" &&
child_tree=$(tmpgit write-tree) &&
rm -f .git/tmp-idx &&
child=$(git commit-tree "$child_tree" -p "$parent" \
-m "foo: file to directory") &&
check_connected_trace trace-f2d.txt "" "" "$child"
)
'

test_expect_success "$mode: handles directory-to-file transition" '
(
cd main-repo &&

# Parent: "bar/baz.txt" exists (bar is a directory).
blob_a=$(echo "nested" | git hash-object -w --stdin) &&
tmpgit read-tree HEAD &&
tmpgit update-index --add \
--cacheinfo "100644,$blob_a,bar/baz.txt" &&
parent_tree=$(tmpgit write-tree) &&
rm -f .git/tmp-idx &&
parent=$(git commit-tree "$parent_tree" -p HEAD \
-m "add bar as directory") &&

# Child: "bar" becomes a plain file.
blob_b=$(echo "flat" | git hash-object -w --stdin) &&
tmpgit read-tree "$parent" &&
tmpgit update-index --remove bar/baz.txt &&
tmpgit update-index --add \
--cacheinfo "100644,$blob_b,bar" &&
child_tree=$(tmpgit write-tree) &&
rm -f .git/tmp-idx &&
child=$(git commit-tree "$child_tree" -p "$parent" \
-m "bar: directory to file") &&
check_connected_trace trace-d2f.txt "" "" "$child"
)
'

# Replacement objects.

test_expect_success "$mode: accepts with replacement objects" '
(
cd replace-test &&
check_connected "$original_oid"
)
'

test_expect_success "$mode: rejects without replacement objects" '
(
cd replace-test &&
GIT_NO_REPLACE_OBJECTS=1 &&
export GIT_NO_REPLACE_OBJECTS &&
test_expect_code 128 check_connected \
"$original_oid" 2>err &&
test_grep "missing blob object" err
)
'

test_expect_success "$mode: accepts missing promised blob" '
test_when_finished "rm -rf prom-src prom-server.git prom-client" &&
git init prom-src &&
test_commit -C prom-src --no-tag base file.txt original &&
test_commit -C prom-src --no-tag "add file2" file2.txt extra &&
git clone --bare prom-src prom-server.git &&
git -C prom-server.git config uploadpack.allowfilter true &&
git -C prom-server.git config uploadpack.allowanysha1inwant true &&
git clone --no-checkout --filter=blob:none \
"file://$(pwd)/prom-server.git" prom-client &&
(
cd prom-client &&
promised_blob=$(git rev-parse HEAD:file2.txt) &&
test_must_fail env GIT_NO_LAZY_FETCH=1 \
git cat-file -e "$promised_blob" &&
new_tree=$(printf "100644 blob %s\tnewname.txt\n" \
"$promised_blob" |
git mktree --missing) &&
new_commit=$(git commit-tree "$new_tree" \
-p HEAD -m "reuse promised blob") &&
check_connected "$new_commit" &&
test_must_fail env GIT_NO_LAZY_FETCH=1 \
git cat-file -e "$promised_blob"
)
'

test_expect_success "$mode: does not lazy-fetch promised blob" '
test_when_finished "rm -rf nofetch-src nofetch-server.git nofetch-client" &&
git init nofetch-src &&
test_commit -C nofetch-src --no-tag base file.txt content &&
git clone --bare nofetch-src nofetch-server.git &&
git -C nofetch-server.git config uploadpack.allowfilter true &&
git -C nofetch-server.git config uploadpack.allowanysha1inwant true &&
git clone --no-checkout --filter=blob:none \
"file://$(pwd)/nofetch-server.git" nofetch-client &&
(
cd nofetch-client &&
promised_blob=$(git rev-parse HEAD:file.txt) &&
test_must_fail env GIT_NO_LAZY_FETCH=1 \
git cat-file -e "$promised_blob" &&
new_tree=$(printf "100644 blob %s\tfile.txt\n" \
"$promised_blob" |
git mktree --missing) &&
new_commit=$(git commit-tree "$new_tree" \
-m "root commit with promised blob") &&
check_connected "$new_commit" &&
test_must_fail env GIT_NO_LAZY_FETCH=1 \
git cat-file -e "$promised_blob"
)
'

test_expect_success "$mode: accepts missing promised tree" '
test_when_finished "rm -rf prom-tree-src prom-tree-server.git prom-tree-client" &&
git init prom-tree-src &&
mkdir -p prom-tree-src/a/b &&
test_commit -C prom-tree-src --no-tag "nested dirs" a/b/file.txt deep &&
git clone --bare prom-tree-src prom-tree-server.git &&
git -C prom-tree-server.git config uploadpack.allowfilter true &&
git -C prom-tree-server.git config uploadpack.allowanysha1inwant true &&
git clone --no-checkout --filter=tree:1 \
"file://$(pwd)/prom-tree-server.git" prom-tree-client &&
(
cd prom-tree-client &&
promised_tree=$(git ls-tree HEAD -- a |
awk "{print \$3}") &&
test_must_fail env GIT_NO_LAZY_FETCH=1 \
git cat-file -e "$promised_tree" &&
new_tree=$(printf "40000 tree %s\trenamed\n" \
"$promised_tree" |
git mktree --missing) &&
new_commit=$(git commit-tree "$new_tree" \
-p HEAD -m "reuse promised tree") &&
check_connected "$new_commit" &&
test_must_fail env GIT_NO_LAZY_FETCH=1 \
git cat-file -e "$promised_tree"
)
'

test_expect_success "$mode: verifies new subtree when parent subtree is promised" '
test_when_finished "rm -rf prom-base-src prom-base-server.git prom-base-client" &&
git init prom-base-src &&
mkdir -p prom-base-src/a &&
test_commit -C prom-base-src --no-tag "base" a/file.txt deep &&
git clone --bare prom-base-src prom-base-server.git &&
git -C prom-base-server.git config uploadpack.allowfilter true &&
git -C prom-base-server.git config uploadpack.allowanysha1inwant true &&
git clone --no-checkout --filter=tree:1 \
"file://$(pwd)/prom-base-server.git" prom-base-client &&
(
cd prom-base-client &&
parent_subtree=$(git ls-tree HEAD -- a |
awk "{print \$3}") &&
test_must_fail env GIT_NO_LAZY_FETCH=1 \
git cat-file -e "$parent_subtree" &&
new_blob=$(echo "local-content" | git hash-object -w --stdin) &&
new_subtree=$(printf "100644 blob %s\tfile.txt\n" \
"$new_blob" | git mktree) &&
new_root=$(printf "40000 tree %s\ta\n" \
"$new_subtree" | git mktree) &&
new_commit=$(git commit-tree "$new_root" \
-p HEAD -m "replace promised subtree") &&
check_connected "$new_commit" &&
test_must_fail env GIT_NO_LAZY_FETCH=1 \
git cat-file -e "$parent_subtree"
)
'

test_expect_success "$mode: verifies local commit in partial clone" '
test_when_finished "rm -rf pc-src pc-server.git pc-client" &&
git init pc-src &&
test_commit -C pc-src --no-tag base file.txt &&
git clone --bare pc-src pc-server.git &&
git -C pc-server.git config uploadpack.allowfilter true &&
git -C pc-server.git config uploadpack.allowanysha1inwant true &&
git clone --filter=blob:none \
"file://$(pwd)/pc-server.git" pc-client &&
(
cd pc-client &&
local_commit=$(commit_with_change HEAD file.txt local-content) &&
check_connected "$local_commit"
)
'

test_expect_success "$mode: respects shallow boundary" '
test_when_finished "rm -rf shallow-src shallow" &&
git init shallow-src &&
test_commit -C shallow-src --no-tag base file content-1 &&
mkdir shallow-src/sub &&
test_commit -C shallow-src --no-tag change sub/other content-2 &&
git clone --depth=1 "file://$(pwd)/shallow-src" shallow &&
(
cd shallow &&
tip=$(git rev-parse HEAD) &&
git for-each-ref --format="delete %(refname)" |
git update-ref --no-deref --stdin &&
check_connected "$tip"
)
'

test_expect_success "$mode: deepening fetch succeeds" '
test_when_finished "rm -rf deepen-src deepen-server.git deepen-client" &&
git init deepen-src &&
test_commit -C deepen-src --no-tag c1 file.txt &&
test_commit -C deepen-src --no-tag c2 file.txt &&
test_commit -C deepen-src --no-tag c3 file.txt &&
git clone --bare deepen-src deepen-server.git &&
git clone --depth=1 "file://$(pwd)/deepen-server.git" deepen-client &&
set_connectivity_check deepen-client $mode &&
test -f deepen-client/.git/shallow &&
GIT_TRACE2_EVENT="$(pwd)/deepen-trace.txt" \
git -C deepen-client fetch --deepen=2 origin main &&
# Incremental falls back to full for deepening fetches,
# so the trees_loaded event should not appear.
test_grep ! trees_loaded deepen-trace.txt
'

test_expect_success "$mode: malformed tree detected" '
(
cd main-repo &&
echo abc >malformed-tree &&
malformed_tree=$(git hash-object --literally -t tree -w \
malformed-tree) &&
malformed_commit=$(git commit-tree "$malformed_tree" \
-p HEAD -m malformed) &&
test_expect_code 128 check_connected \
"$malformed_commit" 2>err
)
'

test_expect_success PERL_TEST_HELPERS \
"$mode: mid-tree corruption detected" '
(
cd main-repo &&
# Build a tree with one valid entry followed by garbage.
blob_oid=$(echo "valid" | git hash-object -w --stdin) &&
bin_oid=$(echo "$blob_oid" | hex2oct) &&
printf "100644 good\0${bin_oid}GARBAGE" >corrupt-mid-tree &&
corrupt_tree=$(git hash-object --literally -t tree -w \
corrupt-mid-tree) &&
corrupt_commit=$(git commit-tree "$corrupt_tree" \
-p HEAD -m "mid-tree corruption") &&
test_expect_code 128 check_connected \
"$corrupt_commit" 2>err &&
test_grep "too-short tree object" err
)
'

done

# Algorithm selection.

test_expect_success 'invalid transfer.connectivityCheck is rejected' '
test_when_finished "rm -rf invalid-cfg-src invalid-cfg-dst" &&
git init invalid-cfg-src &&
test_commit -C invalid-cfg-src --no-tag base file.txt &&
git clone invalid-cfg-src invalid-cfg-dst &&
test_commit -C invalid-cfg-src --no-tag update file.txt updated &&
git -C invalid-cfg-dst config transfer.connectivityCheck bogus &&
test_must_fail git -C invalid-cfg-dst fetch origin main 2>err &&
test_grep "unknown transfer.connectivityCheck" err
'

test_expect_success 'push uses incremental when configured' '
test_when_finished "rm -rf int-src int-dst.git" &&
git init int-src &&
test_commit -C int-src --no-tag base file.txt &&
git clone --bare int-src int-dst.git &&
test_commit -C int-src --no-tag update file.txt updated &&
set_connectivity_check int-dst.git incremental &&
GIT_TRACE2_EVENT="$(pwd)/push-trace.txt" \
git -C int-src push ../int-dst.git main &&
test_trace2_data_singular connectivity trees_loaded 2 \
<push-trace.txt
'

test_expect_success 'fetch uses incremental when configured' '
test_when_finished "rm -rf fetch-src fetch-dst" &&
git init fetch-src &&
test_commit -C fetch-src --no-tag base file.txt &&
git clone fetch-src fetch-dst &&
test_commit -C fetch-src --no-tag update file.txt updated &&
set_connectivity_check fetch-dst incremental &&
GIT_TRACE2_EVENT="$(pwd)/fetch-trace.txt" \
git -C fetch-dst fetch origin main &&
test_trace2_data_singular connectivity trees_loaded 2 \
<fetch-trace.txt
'

test_expect_success 'clone respects transfer.connectivityCheck' '
test_when_finished "rm -rf clone-src clone-dst" &&
git init clone-src &&
test_commit -C clone-src --no-tag base file.txt &&
GIT_TRACE2_EVENT="$(pwd)/clone-trace.txt" \
git -c transfer.connectivityCheck=incremental \
clone --no-local clone-src clone-dst &&
test_trace2_data_singular connectivity trees_loaded 0 \
<clone-trace.txt
'

test_done

316
tree-verify.c Normal file
View File

@ -0,0 +1,316 @@
#include "git-compat-util.h"
#include "commit.h"
#include "gettext.h"
#include "hex.h"
#include "khash.h"
#include "object.h"
#include "odb.h"
#include "oid-array.h"
#include "oidset.h"
#include "tree.h"
#include "tree-walk.h"
#include "tree-verify.h"
#include "packfile.h"
#include "trace2.h"

enum tree_state {
TREE_UNTRUSTED = 0,
TREE_TRUSTED = 1,
TREE_EXPANDED = 2,
};

KHASH_INIT(oid_tree, struct object_id, unsigned char, 1,
oidhash_by_value, oideq_by_value)

static enum tree_state tree_map_get(kh_oid_tree_t *m,
const struct object_id *oid)
{
khint_t pos = kh_get_oid_tree(m, *oid);
if (pos == kh_end(m))
return TREE_UNTRUSTED;
return kh_val(m, pos);
}

static void tree_map_add(kh_oid_tree_t *m, const struct object_id *oid,
enum tree_state state)
{
int added;
khint_t pos = kh_put_oid_tree(m, *oid, &added);
if (added)
kh_val(m, pos) = state;
else if (state > kh_val(m, pos))
kh_val(m, pos) = state;
}

struct work_item {
struct name_entry entry;
struct oid_array parent_trees;
};

struct verify_state {
kh_oid_tree_t *trees;
struct oidset trusted_blobs;
int trees_loaded;
int blobs_checked;
int exclude_promisor_objects;
};

/*
* Merge-walk the work list against one base tree entry, recording
* same-path parent subtrees as recursive comparison bases.
* Returns the updated work-list cursor.
*/
static size_t collect_subtree_bases(struct work_item *work, size_t nr_work,
size_t wi, const struct name_entry *entry)
{
while (wi < nr_work) {
int cmp = base_name_compare(
work[wi].entry.path, work[wi].entry.pathlen,
work[wi].entry.mode,
entry->path, entry->pathlen,
entry->mode);
if (cmp > 0)
break;
if (cmp < 0) {
wi++;
continue;
}
if (S_ISDIR(work[wi].entry.mode) &&
S_ISDIR(entry->mode))
oid_array_append(&work[wi].parent_trees,
&entry->oid);
return wi + 1;
}
return wi;
}

static void verify_blob(struct repository *repo,
const struct object_id *oid,
struct verify_state *vs)
{
int type;

if (oidset_contains(&vs->trusted_blobs, oid))
return;

vs->blobs_checked++;
type = odb_read_object_info(repo->objects, oid, NULL);
if (type == OBJ_BLOB) {
oidset_insert(&vs->trusted_blobs, oid);
return;
}
if (type >= 0)
die(_("object %s is a %s, not a %s"),
oid_to_hex(oid), type_name(type), "blob");
if (vs->exclude_promisor_objects &&
is_promisor_object(repo, oid))
return;
die(_("missing blob object '%s'"), oid_to_hex(oid));
}

static void *read_tree_object(struct object_database *odb,
const struct object_id *oid,
size_t *sizep)
{
enum object_type type;
void *buf = odb_read_object(odb, oid, &type, sizep);

if (buf && type != OBJ_TREE) {
free(buf);
die(_("object %s is a %s, not a %s"),
oid_to_hex(oid), type_name(type), "tree");
}
return buf;
}

static void verify_tree(struct repository *repo,
const struct object_id *new_tree_oid,
const struct oid_array *base_trees,
struct verify_state *vs, int depth)
{
struct tree_desc desc;
struct name_entry entry;
struct work_item *work = NULL;
size_t nr_work = 0, alloc_work = 0;
int need_subtree_bases = 0;
size_t i, tree_size;
void *tree_buf;

if (depth > repo->settings.max_allowed_tree_depth)
die(_("exceeded maximum allowed tree depth"));

if (tree_map_get(vs->trees, new_tree_oid) >= TREE_TRUSTED)
return;

tree_buf = read_tree_object(repo->objects, new_tree_oid, &tree_size);
if (!tree_buf) {
if (vs->exclude_promisor_objects &&
is_promisor_object(repo, new_tree_oid))
return;
die(_("bad tree object %s"),
oid_to_hex(new_tree_oid));
}

vs->trees_loaded++;
init_tree_desc(&desc, new_tree_oid, tree_buf, tree_size);

while (tree_entry(&desc, &entry)) {
if (S_ISGITLINK(entry.mode))
continue;
if (S_ISDIR(entry.mode)) {
if (tree_map_get(vs->trees, &entry.oid) >= TREE_TRUSTED)
continue;
need_subtree_bases = 1;
} else {
if (oidset_contains(&vs->trusted_blobs, &entry.oid))
continue;
}
ALLOC_GROW(work, nr_work + 1, alloc_work);
memset(&work[nr_work], 0, sizeof(work[nr_work]));
work[nr_work].entry = entry;
nr_work++;
}

if (!nr_work) {
free(tree_buf);
goto done;
}

for (i = 0; base_trees && i < base_trees->nr; i++) {
const struct object_id *base_oid = &base_trees->oid[i];
int expanded = tree_map_get(vs->trees, base_oid) >= TREE_EXPANDED;
struct tree_desc base_desc;
struct name_entry scan_entry;
size_t wi = 0, base_size;
void *base_buf;

if (expanded && !need_subtree_bases)
continue;

base_buf = read_tree_object(repo->objects, base_oid,
&base_size);
if (!base_buf) {
if (vs->exclude_promisor_objects &&
is_promisor_object(repo, base_oid))
continue;
die(_("bad tree object %s"),
oid_to_hex(base_oid));
}

vs->trees_loaded++;
init_tree_desc(&base_desc, base_oid, base_buf, base_size);

while (tree_entry(&base_desc, &scan_entry)) {
if (S_ISGITLINK(scan_entry.mode))
continue;

if (need_subtree_bases)
wi = collect_subtree_bases(work, nr_work,
wi, &scan_entry);

if (!expanded) {
if (S_ISDIR(scan_entry.mode))
tree_map_add(vs->trees,
&scan_entry.oid,
TREE_TRUSTED);
else
oidset_insert(&vs->trusted_blobs,
&scan_entry.oid);
}
}

if (!expanded)
tree_map_add(vs->trees, base_oid, TREE_EXPANDED);

free(base_buf);
}

for (i = 0; i < nr_work; i++) {
if (S_ISDIR(work[i].entry.mode))
verify_tree(repo, &work[i].entry.oid,
&work[i].parent_trees, vs,
depth + 1);
else
verify_blob(repo, &work[i].entry.oid, vs);
}

free(tree_buf);

done:
tree_map_add(vs->trees, new_tree_oid, TREE_EXPANDED);
for (i = 0; i < nr_work; i++)
oid_array_clear(&work[i].parent_trees);
free(work);
}

static void verify_commit_tree(struct repository *repo,
struct commit *commit,
struct verify_state *vs)
{
struct oid_array base_trees = OID_ARRAY_INIT;
const struct object_id *tree_oid;
struct commit_list *p;

/*
* Parent trees are trusted: boundary parents are already
* connected, and earlier incoming parents were verified
* first due to the topological processing order.
*/
for (p = commit->parents; p; p = p->next) {
parse_commit_or_die(p->item);
tree_oid = get_commit_tree_oid(p->item);
if (!tree_oid)
die(_("unable to load root tree for commit %s"),
oid_to_hex(&p->item->object.oid));
tree_map_add(vs->trees, tree_oid, TREE_TRUSTED);
oid_array_append(&base_trees, tree_oid);
}

tree_oid = get_commit_tree_oid(commit);
if (!tree_oid)
die(_("unable to load root tree for commit %s"),
oid_to_hex(&commit->object.oid));
verify_tree(repo, tree_oid, &base_trees, vs, 0);
oid_array_clear(&base_trees);
}

void verify_commits_incremental(struct repository *repo,
struct commit_list **commits,
int exclude_promisor_objects)
{
struct verify_state vs = { 0 };
struct commit_list *iter;
unsigned nr_before;

if (repo->fetch_if_missing)
BUG("verify_commits_incremental must not be called "
"with fetch_if_missing set");

vs.trees = kh_init_oid_tree();
vs.exclude_promisor_objects = exclude_promisor_objects;

/*
* Ancestors must be verified before descendants so that parent
* trees can be trusted without re-verification. Sort explicitly
* rather than relying on the caller's ordering.
*
* sort_in_topological_order() silently drops cycle members,
* so explicitly check if the size has changed.
*/
nr_before = commit_list_count(*commits);
sort_in_topological_order(commits, REV_SORT_IN_GRAPH_ORDER);
if (commit_list_count(*commits) < nr_before)
die(_("cycle detected in incoming commit graph"));

*commits = commit_list_reverse(*commits);

for (iter = *commits; iter; iter = iter->next)
verify_commit_tree(repo, iter->item, &vs);

kh_destroy_oid_tree(vs.trees);
oidset_clear(&vs.trusted_blobs);
trace2_data_intmax("connectivity", repo,
"trees_loaded", vs.trees_loaded);
trace2_data_intmax("connectivity", repo,
"blobs_checked", vs.blobs_checked);
}

15
tree-verify.h Normal file
View File

@ -0,0 +1,15 @@
#ifndef TREE_VERIFY_H
#define TREE_VERIFY_H

struct commit_list;
struct repository;

/*
* Verify trees of commits incrementally against their parents.
* Dies on verification failure.
*/
void verify_commits_incremental(struct repository *repo,
struct commit_list **commits,
int exclude_promisor_objects);

#endif /* TREE_VERIFY_H */