blame: read precomputed hunks

Before diffing a target blob against a parent, offer the pair's identity to the hunk provider interface. Blame's requests have gone through diff_provider_emit_hunks() since the interface arrived, but carried no identity, so nothing could answer them. Now blame fills in the pair's blob object ids and its diff options, and the chain serves the pair from the store, keyed by the ids and the request's xdiff flags, before the terminal provider falls back to fill-and-compute. Blame diffs at zero context, which is not part of the key. An answer replays the recorded hunks through blame_chunk_cb without loading either blob; a request carrying -I patterns or anchors is outside the key and always computes. Blame withholds the identity where its diff is not the plain blob-pair diff the key describes: reverse blame, ignored revisions, textconv paths, and the working-tree or --contents pseudo-commit, whose blob is not a stored object. Those requests always compute. Whitespace and algorithm options such as -w instead change blame's xdl_opts, so the consult keys a different entry and misses a store warmed without them. Blame's default xdl_opts now come from DIFF_HUNKS_DEFAULT_XDL_OPTS, new here, which records the key-relevant defaults a diff_options-based consumer already carries (today the indent heuristic), so a default blame run and a default "log --stat" warming run share keys by construction. "--show-stats" reports how many pairs the store served and how many consultations it could not, read from diff_hunks_read_stats(); the store counts its own consultations, so blame keeps no tally. Extend t4220 with the blame side: - parity for plain, --porcelain, and --incremental output, and hit and miss accounting across warming runs; - the blame inputs that must bypass or miss the store: -w, indent heuristics, --reverse, textconv, -M/-C, and the --ignore-rev pass; - rename and merge handling, and --contents; - reading a truncated or corrupt store as absent, and a crafted zero-hunk record as a miss that verify flags. Add p4218, measuring the cost of a warming run and the read speedups. Signed-off-by: Michael Montalbo <mmontalbo@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Michael Montalbo committed Aug 1, 2026 at 10:41 UTC 4e6973492ff71a4ba6178527649b01e2fa8ba633
6 files changed +461 -1
blame.c
+39
@@ -24,6 +24,7 @@
24 #include "bloom.h"
25 #include "commit-graph.h"
26 #include "diff-provider.h"
27 +#include "userdiff.h"
28
29 define_commit_slab(blame_suspects, struct blame_origin *);
30 static struct blame_suspects blame_suspects;
@@ -1937,6 +1938,24 @@ static int blame_chunk_cb(long start_a, long count_a,
1938 return 0;
1939 }
1940
1941 +/*
1942 + * A hunk provider's key names the (old blob, new blob) pair and may only
1943 + * serve a diff whose result is determined by that pair and the xdiff
1944 + * settings. Textconv rewrites the buffers being diffed away from the
1945 + * blob contents the key names, so any origin whose path has a textconv
1946 + * driver must withhold the pair's identity.
1947 + */
1948 +static int blame_textconv_active(struct blame_scoreboard *sb,
1949 + const char *path)
1950 +{
1951 + struct userdiff_driver *drv;
1952 +
1953 + if (!sb->revs->diffopt.flags.allow_textconv)
1954 + return 0;
1955 + drv = userdiff_find_by_path(sb->repo->index, path);
1956 + return drv && drv->textconv;
1957 +}
1958 +
1959 struct blame_diff_fill_data {
1960 struct blame_scoreboard *sb;
1961 struct blame_origin *parent, *target;
@@ -1973,6 +1992,7 @@ static void pass_blame_to_parent(struct blame_scoreboard *sb,
1992 struct blame_diff_fill_data fill_data = { sb, parent, target, ignore_diffs };
1993 xpparam_t xpp = { .flags = sb->xdl_opts };
1994 struct diff_provider_request req = { .repo = sb->repo, .xpp = &xpp };
1995 + int provider_usable;
1996
1997 if (!target->suspects)
1998 return; /* nothing remains for this target */
@@ -1983,6 +2003,25 @@ static void pass_blame_to_parent(struct blame_scoreboard *sb,
2003 d.ignore_diffs = ignore_diffs;
2004 d.dstq = &newdest; d.srcq = &target->suspects;
2005
2006 + /*
2007 + * Offer the pair's identity only where blame's diff is the plain
2008 + * blob-pair diff the recording key describes; reverse blame,
2009 + * ignored revisions, and textconv paths withhold it and always
2010 + * compute. The working-tree/--contents pseudo-commit (marked by
2011 + * its null commit id) holds a blob that is not a stored object,
2012 + * so its pairs withhold identity too: no id may be sent that
2013 + * names bytes a provider cannot look up.
2014 + */
2015 + provider_usable = !sb->reverse && !ignore_diffs &&
2016 + !is_null_oid(&target->commit->object.oid) &&
2017 + !blame_textconv_active(sb, target->path) &&
2018 + !blame_textconv_active(sb, parent->path);
2019 +
2020 + if (provider_usable) {
2021 + req.old_oid = &parent->blob_oid;
2022 + req.new_oid = &target->blob_oid;
2023 + }
2024 + req.diffopt = &sb->revs->diffopt;
2025 if (diff_provider_emit_hunks(&req, blame_diff_fill, &fill_data,
2026 blame_chunk_cb, &d) == DIFF_PROVIDER_ERROR)
2027 die("unable to generate diff (%s -> %s)",
builtin/blame.c
+7 -1
@@ -15,6 +15,7 @@
15 #include "hex.h"
16 #include "commit.h"
17 #include "diff.h"
18 +#include "diff-hunks.h"
19 #include "revision.h"
20 #include "quote.h"
21 #include "string-list.h"
@@ -1060,7 +1061,7 @@ int cmd_blame(int argc,
1061 parse_done:
1062 revision_opts_finish(&revs);
1063 no_whole_file_rename = !revs.diffopt.flags.follow_renames;
1063 - xdl_opts |= revs.diffopt.xdl_opts & XDF_INDENT_HEURISTIC;
1064 + xdl_opts |= revs.diffopt.xdl_opts & DIFF_HUNKS_DEFAULT_XDL_OPTS;
1065 revs.diffopt.flags.follow_renames = 0;
1066 argc = parse_options_end(&ctx);
1067
@@ -1315,9 +1316,14 @@ parse_done:
1316 output(&sb, output_option);
1317
1318 if (show_stats) {
1319 + unsigned long hunk_hits, hunk_misses;
1320 +
1321 + diff_hunks_read_stats(sb.repo, &hunk_hits, &hunk_misses);
1322 printf("num read blob: %d\n", sb.num_read_blob);
1323 printf("num get patch: %d\n", sb.num_get_patch);
1324 printf("num commits: %d\n", sb.num_commits);
1325 + printf("num precomputed hits: %lu\n", hunk_hits);
1326 + printf("num precomputed misses: %lu\n", hunk_misses);
1327 }
1328
1329 cleanup:
diff.h
+11
@@ -231,6 +231,17 @@ static inline void diff_flags_or(struct diff_flags *a,
231
232 #define DIFF_WITH_ALG(opts, flag) (((opts)->xdl_opts & ~XDF_DIFF_ALGORITHM_MASK) | XDF_##flag)
233
234 +/*
235 + * The xdl_opts bits git turns on by default that a from-scratch xdl_opts
236 + * (git blame's own option parsing) does not set, and so must OR in to match
237 + * a store warmed at the default diff settings; a diff_options-based consumer
238 + * (diffstat) already has them in o->xdl_opts. Today this is only the indent
239 + * heuristic. It does NOT cover a non-default diff.algorithm: a repo that
240 + * configures one records under that algorithm, and a consumer keying without
241 + * it misses (a lost hit, not wrong output).
242 + */
243 +#define DIFF_HUNKS_DEFAULT_XDL_OPTS XDF_INDENT_HEURISTIC
244 +
245 enum diff_words_type {
246 DIFF_WORDS_NONE = 0,
247 DIFF_WORDS_PORCELAIN,
t/meson.build
+1
@@ -1158,6 +1158,7 @@ benchmarks = [
1158 'perf/p4205-log-pretty-formats.sh',
1159 'perf/p4209-pickaxe.sh',
1160 'perf/p4211-line-log.sh',
1161 + 'perf/p4218-diff-hunks.sh',
1162 'perf/p4220-log-grep-engines.sh',
1163 'perf/p4221-log-grep-engines-fixed.sh',
1164 'perf/p5302-pack-index.sh',
t/perf/p4218-diff-hunks.sh new
+48
@@ -0,0 +1,48 @@
1 +#!/bin/sh
2 +
3 +test_description='diff-hunks store performance'
4 +. ./perf-lib.sh
5 +
6 +test_perf_default_repo
7 +
8 +# Pick a file to blame pseudo-randomly. The sort key is the blob
9 +# hash, so it is stable.
10 +test_expect_success 'select a file' '
11 + git ls-tree -r HEAD | grep ^100644 |
12 + sort -k 3 | head -n 1 | cut -f 2 >filelist
13 +'
14 +
15 +file=$(cat filelist)
16 +export file
17 +
18 +# Warm the store the way an owner would: a stat walk with writing on.
19 +test_perf 'warm the store' '
20 + git diff-hunks clear &&
21 + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null
22 +'
23 +
24 +test_expect_success 'ensure the store is warm for the timed reads' '
25 + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null
26 +'
27 +
28 +test_perf 'log --stat -1000 (store)' '
29 + git log --stat -1000 >/dev/null
30 +'
31 +
32 +test_perf 'log --stat -1000 (no store)' '
33 + git -c core.diffhunks=false log --stat -1000 >/dev/null
34 +'
35 +
36 +test_perf 'blame $file (store)' '
37 + git blame "$file" >/dev/null
38 +'
39 +
40 +test_perf 'blame $file (no store)' '
41 + git -c core.diffhunks=false blame "$file" >/dev/null
42 +'
43 +
44 +test_expect_success 'clean up store' '
45 + git diff-hunks clear
46 +'
47 +
48 +test_done
t/t4220-diff-hunks.sh
+355
@@ -81,6 +81,49 @@ test_expect_success 'a second warming run refreshes the store in place' '
81 test_cmp expect actual
82 '
83
84 +test_expect_success 'core.diffhunks=false disables lookups' '
85 + warm &&
86 + git -c core.diffhunks=false blame --show-stats file.txt >out 2>&1 &&
87 + test_grep "num precomputed hits: 0" out
88 +'
89 +
90 +# Writing seeds from the current store and merges into it, so a later
91 +# warming run keeps the entries an earlier one recorded rather than
92 +# rebuilding. Warm one pair, then a different pair, and confirm the first
93 +# is still served.
94 +test_expect_success 'a later warming run preserves earlier entries' '
95 + git init incr &&
96 + (
97 + cd incr &&
98 + test_commit a1 f.txt "1" &&
99 + test_commit a2 f.txt "1
100 +2" &&
101 + test_commit a3 f.txt "1
102 +2
103 +3" &&
104 + GIT_DIFF_HUNKS_WRITE=1 git diff --stat a1 a2 >/dev/null &&
105 + git diff-hunks verify &&
106 + GIT_DIFF_HUNKS_WRITE=1 git diff --stat a2 a3 >/dev/null &&
107 + git diff-hunks verify &&
108 +
109 + # Blaming as of a2 diffs the a1..a2 pair. If seeding had
110 + # dropped it when the a2..a3 pair was warmed, this would
111 + # report zero precomputed hits.
112 + git blame --show-stats a2 -- f.txt >out 2>&1 &&
113 + test_grep "num precomputed hits: [1-9]" out &&
114 +
115 + # The second warm ADDED the a2..a3 pair; blaming a3 diffs
116 + # both a2..a3 and a1..a2, so a hit on each shows the store
117 + # gained the new pair while keeping the earlier one.
118 + git blame --show-stats a3 -- f.txt >out3 2>&1 &&
119 + test_grep "num precomputed hits: 2" out3 &&
120 +
121 + no_store log --stat >expect &&
122 + git log --stat >actual &&
123 + test_cmp expect actual
124 + )
125 +'
126 +
127 test_expect_success 'log --stat matches with and without the store' '
128 no_store log --stat >expect &&
129 warm &&
@@ -211,6 +254,14 @@ test_expect_success 'log -R --stat matches (reversed pairs keyed apart)' '
254 test_cmp expect actual
255 '
256
257 +# One warm serves both diffstat and blame: the blob pairs a blame
258 +# walks are the same parent-child pairs the diffstat warm recorded.
259 +test_expect_success 'a single warming run serves both blame and diffstat' '
260 + warm &&
261 + git blame --show-stats file.txt >out 2>&1 &&
262 + test_grep "num precomputed hits: [1-9][0-9]*" out
263 +'
264 +
265 # The diffstat read path produces identical output on a hit or a miss, so
266 # it emits a trace2 "read-hits" count to prove it consulted the store.
267 test_expect_success 'diffstat consults the store (trace shows read hits)' '
@@ -221,6 +272,23 @@ test_expect_success 'diffstat consults the store (trace shows read hits)' '
272 test_grep ! read-hits trace_off.json
273 '
274
275 +test_expect_success 'blame matches with and without the store' '
276 + no_store blame file.txt >expect &&
277 + warm &&
278 + git blame file.txt >actual &&
279 + test_cmp expect actual
280 +'
281 +
282 +test_expect_success 'blame --porcelain and --incremental match' '
283 + no_store blame --porcelain file.txt >expect_p &&
284 + no_store blame --incremental file.txt >expect_i &&
285 + warm &&
286 + git blame --porcelain file.txt >got_p &&
287 + git blame --incremental file.txt >got_i &&
288 + test_cmp expect_p got_p &&
289 + test_cmp expect_i got_i
290 +'
291 +
292 # Diff settings that change hunks but are not part of the store key must
293 # bypass it in both directions, so output stays byte-identical to a
294 # store-less run.
@@ -357,6 +425,26 @@ test_expect_success 'a whitespace-ignoring diff is not served default entries' '
425 )
426 '
427
428 +test_expect_success 'blame -w stays correct and does not hit default entries' '
429 + (
430 + cd ws-repo &&
431 + no_store blame -w f >expect &&
432 + git blame -w --show-stats f >out 2>&1 &&
433 + test_grep "num precomputed hits: 0" out &&
434 + git blame -w f >actual &&
435 + test_cmp expect actual
436 + )
437 +'
438 +
439 +test_expect_success 'blame with indentHeuristic off stays correct and misses' '
440 + warm &&
441 + git -c diff.indentHeuristic=false blame --show-stats file.txt >out 2>&1 &&
442 + test_grep "num precomputed hits: 0" out &&
443 + no_store -c diff.indentHeuristic=false blame file.txt >expect &&
444 + git -c diff.indentHeuristic=false blame file.txt >actual &&
445 + test_cmp expect actual
446 +'
447 +
448 test_expect_success 'a driver algorithm override keeps output correct and keys apart' '
449 git init driver-algo &&
450 (
@@ -391,6 +479,94 @@ test_expect_success 'a driver algorithm override keeps output correct and keys a
479 )
480 '
481
482 +test_expect_success 'blame --reverse never consults the store' '
483 + warm &&
484 + git blame --reverse HEAD~3..HEAD file.txt >actual 2>/dev/null &&
485 + no_store blame --reverse HEAD~3..HEAD file.txt >expect 2>/dev/null &&
486 + test_cmp expect actual &&
487 + # Reverse blame withholds the pair identity. Zero hits alone
488 + # cannot prove that: reverse pairs are never warmed, so a
489 + # consulted pair would miss, not hit. Zero misses is what shows
490 + # the store was never consulted.
491 + git blame --reverse --show-stats HEAD~3..HEAD file.txt \
492 + >stats 2>/dev/null &&
493 + test_grep "num precomputed hits: 0" stats &&
494 + test_grep "num precomputed misses: 0" stats
495 +'
496 +
497 +test_expect_success 'blame with a textconv driver bypasses the store' '
498 + echo "tc.txt diff=tc" >>.gitattributes &&
499 + git add .gitattributes &&
500 + git commit -m tc-attr &&
501 + git config diff.tc.textconv "sed -e s/1/one/" &&
502 + test_commit tc1 tc.txt "line 1" &&
503 + test_commit tc2 tc.txt "line 1
504 +line 2" &&
505 + warm &&
506 + git blame --show-stats tc.txt >out 2>&1 &&
507 + test_grep "num precomputed hits: 0" out &&
508 + no_store blame tc.txt >expect &&
509 + git blame tc.txt >actual &&
510 + test_cmp expect actual
511 +'
512 +
513 +test_expect_success 'a replaced blob makes the store step aside' '
514 + git init replace-repo &&
515 + (
516 + cd replace-repo &&
517 + test_commit r1 f.txt "a" &&
518 + test_commit r2 f.txt "a
519 +b" &&
520 + warm &&
521 + # Control: without a replacement the pair is served.
522 + GIT_TRACE2_EVENT="$PWD/trace_ctl.json" \
523 + git log -1 --format= --numstat -- f.txt >/dev/null &&
524 + test_grep read-hits trace_ctl.json &&
525 + # Replace r2 blob: the diff now reads different content
526 + # (through OBJECT_INFO_LOOKUP_REPLACE) under the id the store
527 + # keyed, so a served answer would be the pre-replacement diff.
528 + # Identity is withheld, the store steps aside, and the builtin
529 + # computes from the replaced content.
530 + new_blob=$(git rev-parse HEAD:f.txt) &&
531 + repl=$(printf "a\nB\nC\nD\n" | git hash-object -w --stdin) &&
532 + git replace "$new_blob" "$repl" &&
533 + no_store log -1 --format= --numstat -- f.txt >expect &&
534 + git log -1 --format= --numstat -- f.txt >actual &&
535 + test_cmp expect actual &&
536 + GIT_TRACE2_EVENT="$PWD/trace_repl.json" \
537 + git log -1 --format= --numstat -- f.txt >/dev/null &&
538 + test_grep ! read-hits trace_repl.json
539 + )
540 +'
541 +
542 +test_expect_success 'blame -M and -C stay correct with the store' '
543 + warm &&
544 + no_store blame -M file.txt >expect_m &&
545 + no_store blame -C file.txt >expect_c &&
546 + git blame -M file.txt >got_m &&
547 + git blame -C file.txt >got_c &&
548 + test_cmp expect_m got_m &&
549 + test_cmp expect_c got_c
550 +'
551 +
552 +# Copy-detecting (and reverse) blame still diff blob pairs through
553 +# pass_blame_to_parent, so they must use the real blame xdl_opts. A
554 +# whitespace-only change is invisible under -w; if -w were dropped on
555 +# these paths the -w and non-w results would coincide.
556 +test_expect_success 'blame -C honors -w' '
557 + git init -q blame-cw &&
558 + (
559 + cd blame-cw &&
560 + printf "one\ntwo\nthree\n" >f &&
561 + git add f && git commit -q -m base &&
562 + printf "one\n two \nthree\n" >f &&
563 + git add f && git commit -q -m reindent &&
564 + git blame -C -w f >with_w &&
565 + git blame -C f >without_w &&
566 + ! test_cmp with_w without_w
567 + )
568 +'
569 +
570 # Cover the pair shapes an object walk encounters: binary and
571 # mode-only changes produce no text hunks to record.
572 test_expect_success 'binary and mode-only changes do not break the writer' '
@@ -411,6 +587,92 @@ test_expect_success 'binary and mode-only changes do not break the writer' '
587 test_cmp expect actual
588 '
589
590 +test_expect_success 'blame across a rename matches' '
591 + echo "original content" >rename-src.txt &&
592 + git add rename-src.txt &&
593 + git commit -m "add rename-src" &&
594 + echo "more" >>rename-src.txt &&
595 + git add rename-src.txt &&
596 + git commit -m "modify rename-src" &&
597 + git mv rename-src.txt rename-dst.txt &&
598 + git commit -m "rename" &&
599 + echo "post" >>rename-dst.txt &&
600 + git add rename-dst.txt &&
601 + git commit -m "modify after rename" &&
602 + no_store blame rename-dst.txt >expect &&
603 + warm &&
604 + git blame rename-dst.txt >actual &&
605 + test_cmp expect actual
606 +'
607 +
608 +test_expect_success 'blame handles merge commits' '
609 + git checkout -b merge-side main~2 &&
610 + test_commit merge-change merge-file.txt "side content" &&
611 + git checkout main &&
612 + git merge --no-edit merge-side &&
613 + no_store blame merge-file.txt >expect &&
614 + warm &&
615 + git blame merge-file.txt >actual &&
616 + test_cmp expect actual
617 +'
618 +
619 +test_expect_success 'distinct --contents against one revision do not collide' '
620 + warm &&
621 + test_write_lines "line 1" "appended line" >c1 &&
622 + test_write_lines "rewritten line" >c2 &&
623 + # Ground truth without the store.
624 + no_store blame -s --contents=c2 file.txt initial >expect &&
625 + # With the store, an intervening c1 run must not poison the c2 lookup.
626 + git blame -s --contents=c1 file.txt initial >/dev/null &&
627 + git blame -s --contents=c2 file.txt initial >actual &&
628 + test_cmp expect actual &&
629 + # The --contents side is a working-tree pseudo-commit (a null commit
630 + # id), so its pairs withhold identity and never consult the store.
631 + # Output parity alone cannot show that: a consulted unwarmed pair
632 + # would miss, not hit, so zero misses is what proves the pair was
633 + # never looked up.
634 + git blame -s --show-stats --contents=c2 file.txt initial >stats 2>&1 &&
635 + test_grep "num precomputed hits: 0" stats &&
636 + test_grep "num precomputed misses: 0" stats
637 +'
638 +
639 +test_expect_success 'blame --ignore-rev bypasses the store for ignored pairs' '
640 + git init ignore-rev-repo &&
641 + (
642 + cd ignore-rev-repo &&
643 + test_commit ir1 f.txt "base" &&
644 + test_commit ir2 f.txt "base
645 +more" &&
646 + warm &&
647 + # Control: the ordinary pass is served, nothing is computed.
648 + git blame --show-stats f.txt >ctl 2>&1 &&
649 + test_grep "num precomputed hits: 1" ctl &&
650 + test_grep "num get patch: 0" ctl &&
651 + no_store blame --ignore-rev ir2 f.txt >expect &&
652 + git blame --ignore-rev ir2 f.txt >actual &&
653 + test_cmp expect actual &&
654 + # The ignored revision adds a pass that withholds identity:
655 + # it computes its diff (get patch rises) instead of being
656 + # served or even counted as a store consultation.
657 + git blame --ignore-rev ir2 --show-stats f.txt >stats 2>&1 &&
658 + test_grep "num precomputed hits: 1" stats &&
659 + test_grep "num precomputed misses: 0" stats &&
660 + test_grep "num get patch: 1" stats
661 + )
662 +'
663 +
664 +test_expect_success 'blame counts misses for pairs the store does not hold' '
665 + (
666 + cd ignore-rev-repo &&
667 + test_commit ir3 f.txt "base
668 +more
669 +third" &&
670 + git blame --show-stats f.txt >stats 2>&1 &&
671 + test_grep "num precomputed hits: 1" stats &&
672 + test_grep "num precomputed misses: 1" stats
673 + )
674 +'
675 +
676 test_expect_success 'log -L --stat neither reads nor records' '
677 warm &&
678 GIT_TRACE2_EVENT="$PWD/trace_linelog.json" \
@@ -421,6 +683,67 @@ test_expect_success 'log -L --stat neither reads nor records' '
683 test_path_is_missing $STORE
684 '
685
686 +# Integrity: a structurally broken header is read as absent (the reader
687 +# falls back to xdiff and stays correct); a checksum mismatch is caught
688 +# by verify, which is when integrity is checked.
689 +test_expect_success 'a truncated store is read as absent' '
690 + warm &&
691 + test_copy_bytes 20 <$STORE >truncated &&
692 + mv truncated $STORE &&
693 + no_store blame file.txt >expect &&
694 + git blame file.txt >actual &&
695 + test_cmp expect actual
696 +'
697 +
698 +test_expect_success 'a corrupt signature is read as absent' '
699 + warm &&
700 + printf "XXXX" >corrupt &&
701 + tail -c +5 <$STORE >>corrupt &&
702 + mv corrupt $STORE &&
703 + no_store blame file.txt >expect &&
704 + git blame file.txt >actual &&
705 + test_cmp expect actual
706 +'
707 +
708 +# Byte 6 of the header is the chunk count; a value larger than the file
709 +# can hold must be rejected before the chunk table is walked.
710 +test_expect_success 'an over-claimed chunk count is read as absent' '
711 + warm &&
712 + printf "\377" | dd of=$STORE bs=1 seek=6 count=1 conv=notrunc 2>/dev/null &&
713 + no_store blame file.txt >expect &&
714 + git blame file.txt >actual &&
715 + test_cmp expect actual
716 +'
717 +
718 +# A record with no hunks would replay as an equivalence claim, which
719 +# the writer never records; the reader must treat such a record as a
720 +# miss and recompute, and verify must flag it.
721 +test_expect_success 'a zero-hunk record is read as a miss and fails verify' '
722 + git init zero-hunk &&
723 + (
724 + cd zero-hunk &&
725 + test_commit z1 f.txt "base" &&
726 + test_commit z2 f.txt "base
727 +more" &&
728 + warm &&
729 + # The store holds one entry of one hunk: a 4-byte count and
730 + # one 16-byte hunk record, just before the trailing
731 + # checksum. Zero the count to craft the record the writer
732 + # refuses to produce.
733 + rawsz=$(test_oid rawsz) &&
734 + fsize=$(test_file_size $STORE) &&
735 + printf "\\0\\0\\0\\0" | dd of=$STORE bs=1 \
736 + seek=$((fsize - rawsz - 20)) count=4 conv=notrunc \
737 + 2>/dev/null &&
738 + no_store blame f.txt >expect &&
739 + git blame --show-stats f.txt >stats 2>&1 &&
740 + test_grep "num precomputed hits: 0" stats &&
741 + git blame f.txt >actual &&
742 + test_cmp expect actual &&
743 + test_must_fail git diff-hunks verify
744 + )
745 +'
746 +
747 test_expect_success 'verify succeeds on a valid store and on an absent one' '
748 warm &&
749 git diff-hunks verify &&
@@ -454,6 +777,38 @@ test_expect_success 'a warm discards a corrupt store rather than seeding from it
777 test_cmp expect actual
778 '
779
780 +# A generated patch must carry the builtin diffstat, not one served from
781 +# the sender's local store, so its counts do not depend on whether the
782 +# sender warmed the store. Poison the store so a served answer diverges
783 +# from the builtin, then confirm format-patch shows the builtin counts.
784 +test_expect_success 'format-patch keeps its diffstat off the store' '
785 + git init fp-repo &&
786 + (
787 + cd fp-repo &&
788 + test_commit p1 f.txt "a" &&
789 + test_commit p2 f.txt "a
790 +b" &&
791 + warm &&
792 + # Bump the new-side count of the single recorded hunk. The
793 + # record stays structurally valid, and a read skips the
794 + # trailing checksum, so the store serves this poisoned count.
795 + rawsz=$(test_oid rawsz) &&
796 + fsize=$(test_file_size .git/objects/info/diff-hunks) &&
797 + printf "\\0\\0\\0\\7" | dd of=.git/objects/info/diff-hunks bs=1 \
798 + seek=$((fsize - rawsz - 4)) count=4 conv=notrunc 2>/dev/null &&
799 + # The store now serves a divergent count, proving the poison
800 + # is live and observable through a store consumer.
801 + printf "7\t0\tf.txt\n" >poisoned &&
802 + git log -1 --format= --numstat -- f.txt >served &&
803 + test_cmp poisoned served &&
804 + # format-patch does not consult the store, so its output is
805 + # identical with the store poisoned and with it disabled.
806 + no_store format-patch -1 --stdout --stat -- f.txt >expect &&
807 + git format-patch -1 --stdout --stat -- f.txt >actual &&
808 + test_cmp expect actual
809 + )
810 +'
811 +
812 test_expect_success 'diff-hunks clear removes the store file' '
813 warm &&
814 test_path_is_file $STORE &&